diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml
index 0076170..3d5bab7 100644
--- a/.github/workflows/erlang.yml
+++ b/.github/workflows/erlang.yml
@@ -117,4 +117,5 @@ jobs:
uses: ./.github/workflows/run_nra.yml
with:
branch: ${{ github.head_ref || github.ref_name }}
+ repo: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
secrets: inherit
diff --git a/.github/workflows/run_nra.yml b/.github/workflows/run_nra.yml
index 78b83e3..a3b27b9 100644
--- a/.github/workflows/run_nra.yml
+++ b/.github/workflows/run_nra.yml
@@ -5,9 +5,15 @@ on:
description: "Branch name"
required: true
type: string
+ repo:
+ description: "Repository full name (owner/repo)"
+ required: false
+ type: string
+ default: "novaframework/nova"
jobs:
run_nra:
uses: novaframework/nova_request_app/.github/workflows/run_nra.yml@main
with:
- nova_branch: "${{ inputs.branch }}"
\ No newline at end of file
+ nova_branch: "${{ inputs.branch }}"
+ nova_repo: "${{ inputs.repo }}"
\ No newline at end of file
diff --git a/rebar.config b/rebar.config
index 640d8f0..62a190b 100644
--- a/rebar.config
+++ b/rebar.config
@@ -22,7 +22,7 @@
{prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]},
{test, [
{erl_opts, [debug_info, nowarn_export_all]},
- {deps, [{proper, "1.4.0"}]}
+ {deps, [{proper, "1.4.0"}, {meck, "0.9.2"}]}
]}
]}.
diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl
index 728f5c7..ad44095 100644
--- a/src/nova_basic_handler.erl
+++ b/src/nova_basic_handler.erl
@@ -12,6 +12,10 @@
-include_lib("kernel/include/logger.hrl").
+-ifdef(TEST).
+-export([maybe_inject_csrf_token/2]).
+-endif.
+
-type erlydtl_vars() :: map() | [{Key :: atom() | binary() | string(), Value :: any()}].
@@ -289,7 +293,8 @@ handle_ws(ok, State) ->
%%%===================================================================
handle_view(View, Variables, Options, Req) ->
- {ok, HTML} = render_dtl(View, Variables, []),
+ Variables1 = maybe_inject_csrf_token(Variables, Req),
+ {ok, HTML} = render_dtl(View, Variables1, []),
Headers =
case maps:get(headers, Options, undefined) of
undefined ->
@@ -319,6 +324,13 @@ render_dtl(View, Variables, Options) ->
end.
+maybe_inject_csrf_token(Variables, #{csrf_token := Token}) when is_list(Variables) ->
+ [{csrf_token, Token} | Variables];
+maybe_inject_csrf_token(Variables, #{csrf_token := Token}) when is_map(Variables) ->
+ Variables#{csrf_token => Token};
+maybe_inject_csrf_token(Variables, _Req) ->
+ Variables.
+
get_view_name({Mod, _Opts}) -> get_view_name(Mod);
get_view_name(Mod) when is_atom(Mod) ->
StrName = get_view_name(erlang:atom_to_list(Mod)),
diff --git a/src/nova_plugin.erl b/src/nova_plugin.erl
index fd02148..4a21609 100644
--- a/src/nova_plugin.erl
+++ b/src/nova_plugin.erl
@@ -28,7 +28,7 @@
-module(nova_plugin).
-type request_type() :: pre_request | post_request.
--export_type([request_type/0]).
+-export_type([request_type/0, reply/0]).
-type reply() :: {reply, Body :: binary()} |
{reply, Status :: integer(), Body :: binary()} |
diff --git a/src/nova_session.erl b/src/nova_session.erl
index 6f4a704..098dc79 100644
--- a/src/nova_session.erl
+++ b/src/nova_session.erl
@@ -119,11 +119,16 @@ get_session_module() ->
get_session_id(Req) ->
case nova:get_env(use_sessions, true) of
true ->
- #{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req),
- case SessionId of
+ case maps:get(nova_session_id, Req, undefined) of
undefined ->
- {error, not_found};
- _ ->
+ #{session_id := SessionId} = cowboy_req:match_cookies([{session_id, [], undefined}], Req),
+ case SessionId of
+ undefined ->
+ {error, not_found};
+ _ ->
+ {ok, SessionId}
+ end;
+ SessionId ->
{ok, SessionId}
end;
_ ->
diff --git a/src/nova_stream_h.erl b/src/nova_stream_h.erl
index 1756012..f3509c2 100644
--- a/src/nova_stream_h.erl
+++ b/src/nova_stream_h.erl
@@ -27,7 +27,8 @@ init(StreamID, Req, Opts) ->
{_, _} -> Req;
_ ->
{ok, SessionId} = nova_session:generate_session_id(),
- cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req)
+ ReqWithCookie = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req),
+ ReqWithCookie#{nova_session_id => SessionId}
end;
_ ->
Req
diff --git a/src/plugins/nova_csrf_plugin.erl b/src/plugins/nova_csrf_plugin.erl
new file mode 100644
index 0000000..bb06138
--- /dev/null
+++ b/src/plugins/nova_csrf_plugin.erl
@@ -0,0 +1,160 @@
+%% @doc CSRF protection plugin for Nova using the synchronizer token pattern.
+%%
+%% Generates a random token per session, stores it server-side, and validates
+%% it on state-changing requests (POST, PUT, PATCH, DELETE).
+%%
+%% Important: `nova_request_plugin' must run before this plugin so that
+%% form params are parsed into the `params' key of the request map.
+%%
+%% == Options ==
+%%
+%% - `field_name' — form field name (default `<<"_csrf_token">>')
+%% - `header_name' — header name (default `<<"x-csrf-token">>')
+%% - `session_key' — session storage key (default `<<"_csrf_token">>')
+%% - `excluded_paths' — list of path prefixes to skip (default `[]')
+%%
+-module(nova_csrf_plugin).
+-behaviour(nova_plugin).
+
+-export([
+ pre_request/4,
+ post_request/4,
+ plugin_info/0
+ ]).
+
+-ifdef(TEST).
+-export([
+ generate_token/0,
+ is_safe_method/1,
+ is_excluded_path/2,
+ get_submitted_token/3,
+ constant_time_compare/2
+ ]).
+-endif.
+
+%%--------------------------------------------------------------------
+%% @doc
+%% Pre-request callback. On safe methods, ensures a CSRF token exists
+%% in the session and injects it into the Req map. On unsafe methods,
+%% validates the submitted token against the session token.
+%% @end
+%%--------------------------------------------------------------------
+-spec pre_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) ->
+ {ok, Req0 :: cowboy_req:req(), NewState :: any()} |
+ {stop, nova_plugin:reply(), Req0 :: cowboy_req:req(), NewState :: any()}.
+pre_request(Req = #{method := Method, path := Path}, _Env, Options, State) ->
+ FieldName = maps:get(field_name, Options, <<"_csrf_token">>),
+ HeaderName = maps:get(header_name, Options, <<"x-csrf-token">>),
+ SessionKey = maps:get(session_key, Options, <<"_csrf_token">>),
+ ExcludedPaths = maps:get(excluded_paths, Options, []),
+ case is_safe_method(Method) orelse is_excluded_path(Path, ExcludedPaths) of
+ true ->
+ handle_safe_request(Req, SessionKey, State);
+ false ->
+ handle_unsafe_request(Req, SessionKey, FieldName, HeaderName, State)
+ end.
+
+%%--------------------------------------------------------------------
+%% @doc
+%% Post-request callback. Pass-through.
+%% @end
+%%--------------------------------------------------------------------
+-spec post_request(Req :: cowboy_req:req(), Env :: any(), Options :: map(), State :: any()) ->
+ {ok, Req0 :: cowboy_req:req(), NewState :: any()}.
+post_request(Req, _Env, _Options, State) ->
+ {ok, Req, State}.
+
+%%--------------------------------------------------------------------
+%% @doc
+%% Plugin info callback.
+%% @end
+%%--------------------------------------------------------------------
+-spec plugin_info() -> #{title := binary(),
+ version := binary(),
+ url := binary(),
+ authors := [binary()],
+ description := binary(),
+ options := [{Key :: atom(), OptionDescription :: binary()}]}.
+plugin_info() ->
+ #{title => <<"Nova CSRF Plugin">>,
+ version => <<"0.1.0">>,
+ url => <<"https://github.com/novaframework/nova">>,
+ authors => [<<"Nova team >],
+ description => <<"CSRF protection using synchronizer token pattern.">>,
+ options => [
+ {field_name, <<"Form field name for CSRF token (default: _csrf_token)">>},
+ {header_name, <<"Header name for CSRF token (default: x-csrf-token)">>},
+ {session_key, <<"Session key for CSRF token (default: _csrf_token)">>},
+ {excluded_paths, <<"List of path prefixes to exclude from CSRF protection">>}
+ ]}.
+
+%%%%%%%%%%%%%%%%%%%%%%
+%% Private functions
+%%%%%%%%%%%%%%%%%%%%%%
+
+handle_safe_request(Req, SessionKey, State) ->
+ case nova_session:get(Req, SessionKey) of
+ {ok, Token} ->
+ {ok, Req#{csrf_token => Token}, State};
+ {error, _} ->
+ %% No session yet (first visit) — generate token and store it
+ Token = generate_token(),
+ case nova_session:set(Req, SessionKey, Token) of
+ ok ->
+ {ok, Req#{csrf_token => Token}, State};
+ {error, _} ->
+ %% Session not established yet (no cookie), proceed without token
+ {ok, Req, State}
+ end
+ end.
+
+handle_unsafe_request(Req, SessionKey, FieldName, HeaderName, State) ->
+ case nova_session:get(Req, SessionKey) of
+ {ok, SessionToken} ->
+ SubmittedToken = get_submitted_token(Req, FieldName, HeaderName),
+ case constant_time_compare(SessionToken, SubmittedToken) of
+ true ->
+ {ok, Req#{csrf_token => SessionToken}, State};
+ false ->
+ reject(Req, State)
+ end;
+ {error, _} ->
+ reject(Req, State)
+ end.
+
+reject(Req, State) ->
+ {stop, {reply, 403, [{<<"content-type">>, <<"text/plain">>}], <<"Forbidden - CSRF token invalid">>}, Req, State}.
+
+generate_token() ->
+ base64:encode(crypto:strong_rand_bytes(32)).
+
+is_safe_method(<<"GET">>) -> true;
+is_safe_method(<<"HEAD">>) -> true;
+is_safe_method(<<"OPTIONS">>) -> true;
+is_safe_method(_) -> false.
+
+is_excluded_path(_Path, []) ->
+ false;
+is_excluded_path(Path, [Prefix | Rest]) ->
+ case binary:match(Path, Prefix) of
+ {0, _} -> true;
+ _ -> is_excluded_path(Path, Rest)
+ end.
+
+get_submitted_token(Req, FieldName, HeaderName) ->
+ case cowboy_req:header(HeaderName, Req) of
+ undefined ->
+ case Req of
+ #{params := Params} when is_map(Params) ->
+ maps:get(FieldName, Params, undefined);
+ _ ->
+ undefined
+ end;
+ HeaderValue ->
+ HeaderValue
+ end.
+
+constant_time_compare(A, B) when is_binary(A), is_binary(B), byte_size(A) =:= byte_size(B) ->
+ crypto:hash_equals(A, B);
+constant_time_compare(_, _) ->
+ false.
diff --git a/test/nova_basic_handler_test.erl b/test/nova_basic_handler_test.erl
new file mode 100644
index 0000000..edd2716
--- /dev/null
+++ b/test/nova_basic_handler_test.erl
@@ -0,0 +1,52 @@
+-module(nova_basic_handler_test).
+-include_lib("eunit/include/eunit.hrl").
+
+%%====================================================================
+%% maybe_inject_csrf_token/2 tests
+%%====================================================================
+
+inject_token_into_proplist_test() ->
+ Vars = [{title, <<"Home">>}],
+ Req = #{csrf_token => <<"tok123">>},
+ Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
+ ?assertEqual(<<"tok123">>, proplists:get_value(csrf_token, Result)).
+
+inject_token_into_proplist_preserves_existing_test() ->
+ Vars = [{title, <<"Home">>}, {user, <<"alice">>}],
+ Req = #{csrf_token => <<"tok123">>},
+ Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
+ ?assertEqual(<<"Home">>, proplists:get_value(title, Result)),
+ ?assertEqual(<<"alice">>, proplists:get_value(user, Result)).
+
+inject_token_into_map_test() ->
+ Vars = #{title => <<"Home">>},
+ Req = #{csrf_token => <<"tok123">>},
+ Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
+ ?assertEqual(<<"tok123">>, maps:get(csrf_token, Result)).
+
+inject_token_into_map_preserves_existing_test() ->
+ Vars = #{title => <<"Home">>, user => <<"alice">>},
+ Req = #{csrf_token => <<"tok123">>},
+ Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
+ ?assertEqual(<<"Home">>, maps:get(title, Result)),
+ ?assertEqual(<<"alice">>, maps:get(user, Result)).
+
+no_token_in_req_leaves_proplist_unchanged_test() ->
+ Vars = [{title, <<"Home">>}],
+ Req = #{method => <<"GET">>},
+ Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
+ ?assertEqual(Vars, Result).
+
+no_token_in_req_leaves_map_unchanged_test() ->
+ Vars = #{title => <<"Home">>},
+ Req = #{method => <<"GET">>},
+ Result = nova_basic_handler:maybe_inject_csrf_token(Vars, Req),
+ ?assertEqual(Vars, Result).
+
+inject_token_into_empty_proplist_test() ->
+ Result = nova_basic_handler:maybe_inject_csrf_token([], #{csrf_token => <<"tok">>}),
+ ?assertEqual([{csrf_token, <<"tok">>}], Result).
+
+inject_token_into_empty_map_test() ->
+ Result = nova_basic_handler:maybe_inject_csrf_token(#{}, #{csrf_token => <<"tok">>}),
+ ?assertEqual(#{csrf_token => <<"tok">>}, Result).
diff --git a/test/nova_csrf_plugin_test.erl b/test/nova_csrf_plugin_test.erl
new file mode 100644
index 0000000..aaa1cf8
--- /dev/null
+++ b/test/nova_csrf_plugin_test.erl
@@ -0,0 +1,250 @@
+-module(nova_csrf_plugin_test).
+-include_lib("eunit/include/eunit.hrl").
+
+%%====================================================================
+%% Token generation tests
+%%====================================================================
+
+generate_token_is_binary_test() ->
+ Token = nova_csrf_plugin:generate_token(),
+ ?assert(is_binary(Token)).
+
+generate_token_correct_length_test() ->
+ %% 32 bytes base64-encoded = 44 characters
+ Token = nova_csrf_plugin:generate_token(),
+ ?assertEqual(44, byte_size(Token)).
+
+generate_token_unique_test() ->
+ Token1 = nova_csrf_plugin:generate_token(),
+ Token2 = nova_csrf_plugin:generate_token(),
+ ?assertNotEqual(Token1, Token2).
+
+%%====================================================================
+%% constant_time_compare tests
+%%====================================================================
+
+constant_time_compare_equal_test() ->
+ ?assert(nova_csrf_plugin:constant_time_compare(<<"abc">>, <<"abc">>)).
+
+constant_time_compare_different_test() ->
+ ?assertNot(nova_csrf_plugin:constant_time_compare(<<"abc">>, <<"def">>)).
+
+constant_time_compare_different_length_test() ->
+ ?assertNot(nova_csrf_plugin:constant_time_compare(<<"abc">>, <<"abcd">>)).
+
+constant_time_compare_undefined_test() ->
+ ?assertNot(nova_csrf_plugin:constant_time_compare(<<"abc">>, undefined)).
+
+constant_time_compare_both_undefined_test() ->
+ ?assertNot(nova_csrf_plugin:constant_time_compare(undefined, undefined)).
+
+%%====================================================================
+%% is_safe_method tests
+%%====================================================================
+
+is_safe_method_get_test() ->
+ ?assert(nova_csrf_plugin:is_safe_method(<<"GET">>)).
+
+is_safe_method_head_test() ->
+ ?assert(nova_csrf_plugin:is_safe_method(<<"HEAD">>)).
+
+is_safe_method_options_test() ->
+ ?assert(nova_csrf_plugin:is_safe_method(<<"OPTIONS">>)).
+
+is_safe_method_post_test() ->
+ ?assertNot(nova_csrf_plugin:is_safe_method(<<"POST">>)).
+
+is_safe_method_put_test() ->
+ ?assertNot(nova_csrf_plugin:is_safe_method(<<"PUT">>)).
+
+is_safe_method_patch_test() ->
+ ?assertNot(nova_csrf_plugin:is_safe_method(<<"PATCH">>)).
+
+is_safe_method_delete_test() ->
+ ?assertNot(nova_csrf_plugin:is_safe_method(<<"DELETE">>)).
+
+%%====================================================================
+%% is_excluded_path tests
+%%====================================================================
+
+is_excluded_path_match_test() ->
+ ?assert(nova_csrf_plugin:is_excluded_path(<<"/api/webhooks/stripe">>, [<<"/api/webhooks">>])).
+
+is_excluded_path_no_match_test() ->
+ ?assertNot(nova_csrf_plugin:is_excluded_path(<<"/admin/settings">>, [<<"/api/webhooks">>])).
+
+is_excluded_path_empty_list_test() ->
+ ?assertNot(nova_csrf_plugin:is_excluded_path(<<"/anything">>, [])).
+
+is_excluded_path_exact_match_test() ->
+ ?assert(nova_csrf_plugin:is_excluded_path(<<"/api">>, [<<"/api">>])).
+
+is_excluded_path_multiple_prefixes_test() ->
+ ?assert(nova_csrf_plugin:is_excluded_path(<<"/health">>, [<<"/api">>, <<"/health">>])).
+
+%%====================================================================
+%% get_submitted_token tests
+%%====================================================================
+
+get_submitted_token_from_header_test() ->
+ Req = #{headers => #{<<"x-csrf-token">> => <<"header-token">>}},
+ ?assertEqual(<<"header-token">>,
+ nova_csrf_plugin:get_submitted_token(Req, <<"_csrf_token">>, <<"x-csrf-token">>)).
+
+get_submitted_token_from_params_test() ->
+ %% No header present — falls through to params
+ Req = #{headers => #{}, params => #{<<"_csrf_token">> => <<"form-token">>}},
+ ?assertEqual(<<"form-token">>,
+ nova_csrf_plugin:get_submitted_token(Req, <<"_csrf_token">>, <<"x-csrf-token">>)).
+
+get_submitted_token_header_priority_test() ->
+ Req = #{headers => #{<<"x-csrf-token">> => <<"header-token">>},
+ params => #{<<"_csrf_token">> => <<"form-token">>}},
+ ?assertEqual(<<"header-token">>,
+ nova_csrf_plugin:get_submitted_token(Req, <<"_csrf_token">>, <<"x-csrf-token">>)).
+
+get_submitted_token_missing_test() ->
+ Req = #{headers => #{}},
+ ?assertEqual(undefined,
+ nova_csrf_plugin:get_submitted_token(Req, <<"_csrf_token">>, <<"x-csrf-token">>)).
+
+%%====================================================================
+%% pre_request integration tests (using meck)
+%%====================================================================
+
+safe_method_with_existing_session_token_test() ->
+ setup_meck([nova_session]),
+ try
+ Token = <<"existing-token">>,
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {ok, Token} end),
+ Req = #{method => <<"GET">>, path => <<"/">>},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{}),
+ ?assertEqual(Token, maps:get(csrf_token, Req1))
+ after
+ cleanup_meck([nova_session])
+ end.
+
+safe_method_generates_token_when_missing_test() ->
+ setup_meck([nova_session]),
+ try
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {error, not_found} end),
+ meck:expect(nova_session, set, fun(_Req, <<"_csrf_token">>, _Token) -> ok end),
+ Req = #{method => <<"GET">>, path => <<"/">>},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{}),
+ ?assert(maps:is_key(csrf_token, Req1)),
+ ?assert(is_binary(maps:get(csrf_token, Req1)))
+ after
+ cleanup_meck([nova_session])
+ end.
+
+safe_method_no_session_proceeds_without_token_test() ->
+ setup_meck([nova_session]),
+ try
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {error, not_found} end),
+ meck:expect(nova_session, set, fun(_Req, <<"_csrf_token">>, _Token) -> {error, session_id_not_set} end),
+ Req = #{method => <<"GET">>, path => <<"/">>},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{}),
+ ?assertNot(maps:is_key(csrf_token, Req1))
+ after
+ cleanup_meck([nova_session])
+ end.
+
+unsafe_method_valid_token_from_header_test() ->
+ setup_meck([nova_session, cowboy_req]),
+ try
+ Token = <<"valid-token">>,
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {ok, Token} end),
+ meck:expect(cowboy_req, header, fun(<<"x-csrf-token">>, _Req) -> Token end),
+ Req = #{method => <<"POST">>, path => <<"/submit">>,
+ headers => #{<<"x-csrf-token">> => Token}},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{}),
+ ?assertEqual(Token, maps:get(csrf_token, Req1))
+ after
+ cleanup_meck([cowboy_req, nova_session])
+ end.
+
+unsafe_method_valid_token_from_params_test() ->
+ setup_meck([nova_session, cowboy_req]),
+ try
+ Token = <<"valid-token">>,
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {ok, Token} end),
+ meck:expect(cowboy_req, header, fun(<<"x-csrf-token">>, _Req) -> undefined end),
+ Req = #{method => <<"POST">>, path => <<"/submit">>,
+ params => #{<<"_csrf_token">> => Token}},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{}),
+ ?assertEqual(Token, maps:get(csrf_token, Req1))
+ after
+ cleanup_meck([cowboy_req, nova_session])
+ end.
+
+unsafe_method_invalid_token_test() ->
+ setup_meck([nova_session, cowboy_req]),
+ try
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {ok, <<"real-token0">>} end),
+ meck:expect(cowboy_req, header, fun(<<"x-csrf-token">>, _Req) -> <<"wrong-token">> end),
+ Req = #{method => <<"POST">>, path => <<"/submit">>,
+ headers => #{<<"x-csrf-token">> => <<"wrong-token">>}},
+ {stop, {reply, 403, _, _}, _Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{})
+ after
+ cleanup_meck([cowboy_req, nova_session])
+ end.
+
+unsafe_method_no_session_test() ->
+ setup_meck([nova_session]),
+ try
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {error, not_found} end),
+ Req = #{method => <<"POST">>, path => <<"/submit">>},
+ {stop, {reply, 403, _, _}, _Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{})
+ after
+ cleanup_meck([nova_session])
+ end.
+
+unsafe_method_missing_submitted_token_test() ->
+ setup_meck([nova_session, cowboy_req]),
+ try
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {ok, <<"real-token">>} end),
+ meck:expect(cowboy_req, header, fun(<<"x-csrf-token">>, _Req) -> undefined end),
+ Req = #{method => <<"DELETE">>, path => <<"/resource/1">>},
+ {stop, {reply, 403, _, _}, _Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, #{}, #{})
+ after
+ cleanup_meck([cowboy_req, nova_session])
+ end.
+
+excluded_path_skips_validation_test() ->
+ setup_meck([nova_session]),
+ try
+ meck:expect(nova_session, get, fun(_Req, <<"_csrf_token">>) -> {ok, <<"token">>} end),
+ Req = #{method => <<"POST">>, path => <<"/api/webhooks/stripe">>},
+ Options = #{excluded_paths => [<<"/api/webhooks">>]},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, Options, #{}),
+ ?assertEqual(<<"token">>, maps:get(csrf_token, Req1))
+ after
+ cleanup_meck([nova_session])
+ end.
+
+custom_field_name_test() ->
+ setup_meck([nova_session, cowboy_req]),
+ try
+ Token = <<"custom-token">>,
+ meck:expect(nova_session, get, fun(_Req, <<"csrf">>) -> {ok, Token} end),
+ meck:expect(cowboy_req, header, fun(<<"x-my-csrf">>, _Req) -> undefined end),
+ Req = #{method => <<"POST">>, path => <<"/submit">>,
+ params => #{<<"my_csrf">> => Token}},
+ Options = #{field_name => <<"my_csrf">>,
+ header_name => <<"x-my-csrf">>,
+ session_key => <<"csrf">>},
+ {ok, Req1, _State} = nova_csrf_plugin:pre_request(Req, #{}, Options, #{}),
+ ?assertEqual(Token, maps:get(csrf_token, Req1))
+ after
+ cleanup_meck([cowboy_req, nova_session])
+ end.
+
+%%====================================================================
+%% Helpers
+%%====================================================================
+
+setup_meck(Modules) ->
+ [meck:new(M, [passthrough]) || M <- Modules].
+
+cleanup_meck(Modules) ->
+ [meck:unload(M) || M <- Modules].
diff --git a/test/nova_session_test.erl b/test/nova_session_test.erl
new file mode 100644
index 0000000..f9b03bf
--- /dev/null
+++ b/test/nova_session_test.erl
@@ -0,0 +1,101 @@
+-module(nova_session_test).
+-include_lib("eunit/include/eunit.hrl").
+
+%%====================================================================
+%% Tests for nova_session_id fallback in get_session_id/1
+%%
+%% When nova_stream_h creates a new session, it injects nova_session_id
+%% into the Req map. nova_session should use this before falling back
+%% to cookie lookup.
+%%====================================================================
+
+get_uses_nova_session_id_from_req_test() ->
+ setup_meck(),
+ try
+ SessionId = <<"new-session-id">>,
+ meck:expect(nova_session_ets, get_value,
+ fun(SId, <<"key">>) when SId =:= SessionId -> {ok, <<"val">>} end),
+ Req = #{nova_session_id => SessionId},
+ ?assertEqual({ok, <<"val">>}, nova_session:get(Req, <<"key">>))
+ after
+ cleanup_meck()
+ end.
+
+set_uses_nova_session_id_from_req_test() ->
+ setup_meck(),
+ try
+ SessionId = <<"new-session-id">>,
+ meck:expect(nova_session_ets, set_value,
+ fun(SId, <<"key">>, <<"val">>) when SId =:= SessionId -> ok end),
+ Req = #{nova_session_id => SessionId},
+ ?assertEqual(ok, nova_session:set(Req, <<"key">>, <<"val">>))
+ after
+ cleanup_meck()
+ end.
+
+get_falls_back_to_cookie_test() ->
+ setup_meck(),
+ try
+ SessionId = <<"cookie-session-id">>,
+ meck:expect(cowboy_req, match_cookies,
+ fun([{session_id, [], undefined}], _Req) -> #{session_id => SessionId} end),
+ meck:expect(nova_session_ets, get_value,
+ fun(SId, <<"key">>) when SId =:= SessionId -> {ok, <<"val">>} end),
+ Req = #{},
+ ?assertEqual({ok, <<"val">>}, nova_session:get(Req, <<"key">>))
+ after
+ cleanup_meck()
+ end.
+
+get_returns_error_when_no_session_test() ->
+ setup_meck(),
+ try
+ meck:expect(cowboy_req, match_cookies,
+ fun([{session_id, [], undefined}], _Req) -> #{session_id => undefined} end),
+ Req = #{},
+ ?assertEqual({error, not_found}, nova_session:get(Req, <<"key">>))
+ after
+ cleanup_meck()
+ end.
+
+set_returns_error_when_no_session_test() ->
+ setup_meck(),
+ try
+ meck:expect(cowboy_req, match_cookies,
+ fun([{session_id, [], undefined}], _Req) -> #{session_id => undefined} end),
+ Req = #{},
+ ?assertEqual({error, session_id_not_set}, nova_session:set(Req, <<"key">>, <<"val">>))
+ after
+ cleanup_meck()
+ end.
+
+nova_session_id_takes_priority_over_cookie_test() ->
+ setup_meck(),
+ try
+ ReqSessionId = <<"req-session-id">>,
+ %% cowboy_req:match_cookies should NOT be called since nova_session_id is present
+ meck:expect(cowboy_req, match_cookies,
+ fun(_, _) -> error(should_not_be_called) end),
+ meck:expect(nova_session_ets, get_value,
+ fun(SId, <<"key">>) when SId =:= ReqSessionId -> {ok, <<"val">>} end),
+ Req = #{nova_session_id => ReqSessionId},
+ ?assertEqual({ok, <<"val">>}, nova_session:get(Req, <<"key">>))
+ after
+ cleanup_meck()
+ end.
+
+%%====================================================================
+%% Helpers
+%%====================================================================
+
+setup_meck() ->
+ meck:new(nova, [passthrough]),
+ meck:new(cowboy_req, [passthrough]),
+ meck:new(nova_session_ets, [passthrough]),
+ meck:expect(nova, get_env, fun(use_sessions, true) -> true end),
+ application:set_env(nova, session_manager, nova_session_ets).
+
+cleanup_meck() ->
+ meck:unload(nova_session_ets),
+ meck:unload(cowboy_req),
+ meck:unload(nova).