From dd0d1b297e903a330c79c30c3515f4bad0befc28 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Feb 2026 21:57:15 +0100 Subject: [PATCH 1/4] feat: add CSRF protection plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nova_csrf_plugin 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). Also fixes a session limitation where nova_session couldn't read the session ID on the very first request because nova_stream_h only set it as a response cookie. Now the session ID is also injected into the Req map so it's immediately available to the plugin pipeline. Co-Authored-By: Claude Opus 4.6 --- rebar.config | 1 + src/nova_basic_handler.erl | 10 +- src/nova_session.erl | 13 +- src/nova_stream_h.erl | 3 +- src/plugins/nova_csrf_plugin.erl | 160 ++++++++++++++++++++ test/nova_csrf_plugin_test.erl | 250 +++++++++++++++++++++++++++++++ 6 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 src/plugins/nova_csrf_plugin.erl create mode 100644 test/nova_csrf_plugin_test.erl diff --git a/rebar.config b/rebar.config index e7a6c92a..0ae858ee 100644 --- a/rebar.config +++ b/rebar.config @@ -19,6 +19,7 @@ ]}. {profiles, [ + {test, [{deps, [{meck, "0.9.2"}]}]}, {prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]} ]}. diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl index 05669b23..bdfec51a 100644 --- a/src/nova_basic_handler.erl +++ b/src/nova_basic_handler.erl @@ -289,7 +289,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 +320,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_session.erl b/src/nova_session.erl index 6f4a7041..098dc79b 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 17560124..f3509c27 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 00000000..bb061383 --- /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 == +%% +-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_csrf_plugin_test.erl b/test/nova_csrf_plugin_test.erl new file mode 100644 index 00000000..aaa1cf8f --- /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]. From 95d688cf54c0b5ef485f23fb636ec5e5dddaefe1 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Feb 2026 22:00:40 +0100 Subject: [PATCH 2/4] test: add tests for csrf token injection and session fallback - nova_basic_handler_test: 8 tests for maybe_inject_csrf_token/2 (proplist, map, empty, no token in req) - nova_session_test: 6 tests for nova_session_id Req map fallback (get/set via Req key, cookie fallback, priority over cookie, error when no session) Co-Authored-By: Claude Opus 4.6 --- src/nova_basic_handler.erl | 4 ++ test/nova_basic_handler_test.erl | 52 ++++++++++++++++ test/nova_session_test.erl | 101 +++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 test/nova_basic_handler_test.erl create mode 100644 test/nova_session_test.erl diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl index bdfec51a..803d8d86 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()}]. diff --git a/test/nova_basic_handler_test.erl b/test/nova_basic_handler_test.erl new file mode 100644 index 00000000..edd27164 --- /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_session_test.erl b/test/nova_session_test.erl new file mode 100644 index 00000000..f9b03bf9 --- /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). From e0f40180fede94a1fa71d99ddf7809a61d80b829 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Feb 2026 22:26:52 +0100 Subject: [PATCH 3/4] fix: export reply/0 type from nova_plugin to fix dialyzer Co-Authored-By: Claude Opus 4.6 --- src/nova_plugin.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nova_plugin.erl b/src/nova_plugin.erl index 042ab4b9..2577e011 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()} | From 1b969c53b8e56ad2920b0c2cf9354f1a4a3859fd Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 13 Feb 2026 22:17:18 +0100 Subject: [PATCH 4/4] fix: pass fork repo URL to nova_request_app CI When contributors open PRs from forks, nova_request_app CI fails because it only receives the branch name, not the fork repo. Pass the full repo name so nova_request_app can fetch from the correct fork. Depends on novaframework/nova_request_app accepting nova_repo input. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/erlang.yml | 1 + .github/workflows/run_nra.yml | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index 00761704..3d5bab7f 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 78b83e39..a3b27b9c 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