Fosi Fossil Service
Small teams. Open source. Built on Fossil.

CaaS / Lamina

Unified Management for Fossil Repositories

Public | Owner: CaaS | Default branch: trunk | Last update: 2026-10-25 00:41

Artifact ab372308323c1a3afbf7cc7b28423bb53a377c9e8b081c2e1c6b892bba509af8:


-module(nova_multipart_plugin_tests).
-include_lib("eunit/include/eunit.hrl").

plugin_info_test() ->
    Info = nova_multipart_plugin:plugin_info(),
    ?assertEqual(<<"Nova multipart plugin">>, maps:get(title, Info)),
    ?assert(is_list(maps:get(options, Info))).

deadline_expired_test() ->
    Now = erlang:monotonic_time(millisecond),
    ?assert(nova_multipart_plugin:deadline_expired(Now - 1)),
    ?assertNot(nova_multipart_plugin:deadline_expired(Now + 100000)).

non_multipart_req_test() ->
    Req = nova_test_helper:mock_req(<<"POST">>, <<"/upload">>),
    Req0 = nova_test_helper:with_content_type(<<"application/json">>, Req),
    Options = #{handler => {nova_multipart_test_handler, #{owner => self()}}},
    {ok, Req1, state} = nova_multipart_plugin:pre_request(Req0, env, Options, state),
    ?assertEqual(Req0#{params => #{}, files => []}, Req1).

missing_boundary_test_() ->
    {setup,
     fun() -> meck:new(cowboy_req, [passthrough]),
              meck:expect(cowboy_req, reply, fun(Status, Req) -> Req#{replied => Status} end) end,
     fun(_) -> meck:unload(cowboy_req) end,
     fun() ->
         Req = nova_test_helper:mock_req(<<"POST">>, <<"/upload">>),
         Req0 = nova_test_helper:with_content_type(<<"multipart/form-data">>, Req),
         Options = #{handler => {nova_multipart_test_handler, #{owner => self()}}},
         {stop, Req1, state} = nova_multipart_plugin:pre_request(Req0, env, Options, state),
         ?assertEqual(400, maps:get(replied, Req1))
     end}.

misconfigured_handler_test_() ->
    {setup,
     fun() -> meck:new(cowboy_req, [passthrough]),
              meck:expect(cowboy_req, reply, fun(Status, Req) -> Req#{replied => Status} end) end,
     fun(_) -> meck:unload(cowboy_req) end,
     [fun() ->
          Req = nova_test_helper:mock_req(<<"POST">>, <<"/upload">>),
          {stop, Req1, state} = nova_multipart_plugin:pre_request(Req, env, Options, state),
          ?assertEqual(500, maps:get(replied, Req1))
      end || Options <- [#{}, #{handler => nova_multipart_test_handler}, #{handler => {"mod", #{}}}]]}.

non_multipart_req_preserves_existing_params_test() ->
    Req = nova_test_helper:mock_req(<<"POST">>, <<"/upload">>),
    Req0 = nova_test_helper:with_content_type(<<"application/json">>, Req),
    Req1 = Req0#{params => #{<<"csrf">> => <<"token">>}},
    Options = #{handler => {nova_multipart_test_handler, #{owner => self()}}},
    {ok, Req2, state} = nova_multipart_plugin:pre_request(Req1, env, Options, state),
    ?assertEqual(#{<<"csrf">> => <<"token">>}, maps:get(params, Req2)).

case_insensitive_content_type_test_() ->
    Parts = [{data_part(<<"title">>), <<"a picture">>}],
    {setup,
     fun() -> mock_cowboy_req(Parts) end,
     fun(_) -> meck:unload(cowboy_req) end,
     fun() ->
         Req = nova_test_helper:mock_req(<<"POST">>, <<"/upload">>),
         Req0 = nova_test_helper:with_content_type(
                  <<"MULTIPART/FORM-DATA; boundary=----abc">>, Req),
         Options = #{handler => {nova_multipart_test_handler, #{owner => self()}}},
         {ok, Req1, state} = nova_multipart_plugin:pre_request(Req0#{has_body => true}, env, Options, state),
         ?assertEqual(#{<<"title">> => <<"a picture">>}, maps:get(params, Req1))
     end}.

%%====================================================================
%% cowboy_req scripting - same pattern as nova_request_plugin_tests
%%====================================================================

multipart_req() ->
    Req = nova_test_helper:mock_req(<<"POST">>, <<"/upload">>),
    Req0 = nova_test_helper:with_content_type(
             <<"multipart/form-data; boundary=----abc">>, Req),
    Req0#{has_body => true}.

%% Parts is a list of {Headers, Body}. Body is a binary or a list of chunks
%% delivered as {more, ...}; a chunk or Headers of {raise, Class, Reason}
%% raises the way cowboy_req does on a stalled or malformed read.
mock_cowboy_req(Parts) ->
    meck:new(cowboy_req, [passthrough]),
    Pid = spawn(fun() -> part_server(Parts) end),
    meck:expect(cowboy_req, read_part,
                fun(Req) ->
                    case call(Pid, next_part) of
                        done -> {done, Req};
                        {part, {raise, Class, Reason}} -> erlang:Class(Reason);
                        {part, Headers} -> {ok, Headers, Req}
                    end
                end),
    meck:expect(cowboy_req, read_part_body,
                fun(Req, _Opts) ->
                    case call(Pid, next_chunk) of
                        {last, {raise, Class, Reason}} -> erlang:Class(Reason);
                        {more, {raise, Class, Reason}} -> erlang:Class(Reason);
                        {last, Data} -> {ok, Data, Req};
                        {more, Data} -> {more, Data, Req}
                    end
                end),
    meck:expect(cowboy_req, reply, fun(Status, Req) -> Req#{replied => Status} end).

call(Pid, Msg) ->
    Pid ! {self(), Msg},
    receive {Pid, Reply} -> Reply end.

part_server(Parts) ->
    part_server(Parts, []).

part_server(Parts, Chunks) ->
    receive
        {From, next_part} ->
            case Parts of
                [] ->
                    From ! {self(), done},
                    part_server([], []);
                [{Headers, Body}|Tl] ->
                    From ! {self(), {part, Headers}},
                    part_server(Tl, chunks(Body))
            end;
        {From, next_chunk} ->
            case Chunks of
                [Last] ->
                    From ! {self(), {last, Last}},
                    part_server(Parts, []);
                [Hd|Tl] ->
                    From ! {self(), {more, Hd}},
                    part_server(Parts, Tl)
            end
    end.

chunks(Body) when is_binary(Body) -> [Body];
chunks(Body) when is_list(Body) -> Body.

data_part(Name) ->
    #{<<"content-disposition">> =>
          <<"form-data; name=\"", Name/binary, "\"">>}.

file_part(Name, Filename, ContentType) ->
    #{<<"content-disposition">> =>
          <<"form-data; name=\"", Name/binary, "\"; filename=\"", Filename/binary, "\"">>,
      <<"content-type">> => ContentType}.

%% OptionsFun gets the self() of the process eunit runs the test in.
multipart_test_(Parts, OptionsFun, Assertions) ->
    {setup,
     fun() -> mock_cowboy_req(Parts) end,
     fun(_) -> meck:unload(cowboy_req) end,
     fun() ->
         Options = OptionsFun(self()),
         Result = nova_multipart_plugin:pre_request(multipart_req(), env, Options, state),
         Assertions(Result)
     end}.

handler_opts(Owner) -> #{owner => Owner}.

multipart_reads_fields_and_files_test_() ->
    Parts = [{data_part(<<"title">>), <<"a picture">>},
             {file_part(<<"upload">>, <<"logo.png">>, <<"image/png">>), <<"binarydata">>}],
    multipart_test_(
      Parts, fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}} end,
      fun({ok, Req, state}) ->
          ?assertEqual(#{<<"title">> => <<"a picture">>}, maps:get(params, Req)),
          [File] = maps:get(files, Req),
          ?assertEqual(<<"upload">>, maps:get(name, File)),
          ?assertEqual(<<"logo.png">>, maps:get(filename, File)),
          ?assertEqual(<<"image/png">>, maps:get(content_type, File)),
          ?assertEqual(#{body => <<"binarydata">>}, maps:get(result, File)),
          ?assertEqual({handler_init, #{name => <<"upload">>,
                                        filename => <<"logo.png">>,
                                        content_type => <<"image/png">>}},
                        receive_one())
      end).

multipart_joins_chunked_part_test_() ->
    Parts = [{file_part(<<"upload">>, <<"big.bin">>, <<"application/octet-stream">>),
              [<<"one">>, <<"two">>, <<"three">>]}],
    multipart_test_(
      Parts, fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}} end,
      fun({ok, Req, state}) ->
          [File] = maps:get(files, Req),
          ?assertEqual(#{body => <<"onetwothree">>}, maps:get(result, File)),
          ?assertMatch({handler_init, _}, receive_one())
      end).

multipart_max_parts_test_() ->
    Parts = [{data_part(<<"a">>), <<"1">>}, {data_part(<<"b">>), <<"2">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}, max_parts => 1} end,
      fun({stop, Req, state}) ->
          ?assertEqual(413, maps:get(replied, Req))
      end).

multipart_max_part_size_test_() ->
    Parts = [{file_part(<<"upload">>, <<"big.bin">>, <<"application/octet-stream">>),
              [<<"aaaa">>, <<"bbbb">>]}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}, max_part_size => 4} end,
      fun({stop, Req, state}) ->
          ?assertEqual(413, maps:get(replied, Req)),
          ?assertMatch({handler_init, _}, receive_one()),
          ?assertEqual({handler_abort, too_large}, receive_one())
      end).

multipart_max_total_size_test_() ->
    Parts = [{data_part(<<"a">>), <<"aaaaa">>}, {data_part(<<"b">>), <<"bbbbb">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}, max_total_size => 8} end,
      fun({stop, Req, state}) ->
          ?assertEqual(413, maps:get(replied, Req))
      end).

multipart_malformed_part_test_() ->
    Parts = [{#{<<"content-type">> => <<"text/plain">>}, <<"no disposition">>}],
    multipart_test_(
      Parts, fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(400, maps:get(replied, Req))
      end).

multipart_handler_rejects_init_test_() ->
    Parts = [{file_part(<<"upload">>, <<"x">>, <<"application/octet-stream">>), <<"data">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, #{owner => Owner, reject_init => quota_exceeded}}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(400, maps:get(replied, Req))
      end).

multipart_handler_init_error_test_() ->
    Parts = [{file_part(<<"upload">>, <<"x">>, <<"application/octet-stream">>), <<"data">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, #{owner => Owner, error_init => enospc}}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(500, maps:get(replied, Req))
      end).

multipart_handler_rejects_chunk_test_() ->
    Parts = [{file_part(<<"upload">>, <<"x">>, <<"application/octet-stream">>), <<"data">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, #{owner => Owner, reject_data => true}}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(500, maps:get(replied, Req)),
          ?assertMatch({handler_init, _}, receive_one()),
          ?assertEqual({handler_abort, chunk_rejected}, receive_one())
      end).

multipart_field_bounded_by_max_field_size_test_() ->
    Parts = [{data_part(<<"title">>), <<"way too much text">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}, max_field_size => 4} end,
      fun({stop, Req, state}) ->
          ?assertEqual(413, maps:get(replied, Req))
      end).

multipart_handle_end_error_triggers_abort_test_() ->
    Parts = [{file_part(<<"upload">>, <<"x">>, <<"application/octet-stream">>), <<"data">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, #{owner => Owner, reject_end => true}}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(500, maps:get(replied, Req)),
          ?assertMatch({handler_init, _}, receive_one()),
          ?assertEqual({handler_abort, disk_full}, receive_one())
      end).

multipart_handler_raise_is_contained_test_() ->
    Parts = [{file_part(<<"upload">>, <<"x">>, <<"application/octet-stream">>), <<"data">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, #{owner => Owner, raise_data => true}}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(500, maps:get(replied, Req)),
          ?assertMatch({handler_init, _}, receive_one()),
          ?assertEqual({handler_abort, handler_crashed}, receive_one())
      end).

multipart_handler_bad_return_is_contained_test_() ->
    Parts = [{file_part(<<"upload">>, <<"x">>, <<"application/octet-stream">>), <<"data">>}],
    multipart_test_(
      Parts,
      fun(Owner) -> #{handler => {nova_multipart_test_handler, #{owner => Owner, bad_return_data => true}}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(500, maps:get(replied, Req)),
          ?assertMatch({handler_init, _}, receive_one()),
          ?assertEqual({handler_abort, handler_crashed}, receive_one())
      end).

multipart_cowboy_exit_mid_file_triggers_abort_test_() ->
    Parts = [{file_part(<<"upload">>, <<"big.bin">>, <<"application/octet-stream">>),
              [<<"partial">>, {raise, exit, timeout}]}],
    multipart_test_(
      Parts, fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(408, maps:get(replied, Req)),
          ?assertMatch({handler_init, _}, receive_one()),
          ?assertEqual({handler_abort, timeout}, receive_one())
      end).

multipart_cowboy_exit_mid_field_test_() ->
    Parts = [{data_part(<<"title">>), [<<"partial">>, {raise, exit, timeout}]}],
    multipart_test_(
      Parts, fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(408, maps:get(replied, Req))
      end).

%% cowboy_req:read_part/1 raises error:badmatch on duplicate part headers.
multipart_cowboy_error_on_duplicate_headers_test_() ->
    Parts = [{{raise, error, {badmatch, false}}, <<>>}],
    multipart_test_(
      Parts, fun(Owner) -> #{handler => {nova_multipart_test_handler, handler_opts(Owner)}} end,
      fun({stop, Req, state}) ->
          ?assertEqual(400, maps:get(replied, Req))
      end).

multipart_memory_handler_test_() ->
    Parts = [{file_part(<<"upload">>, <<"logo.png">>, <<"image/png">>), [<<"bin">>, <<"ary">>]}],
    multipart_test_(
      Parts, fun(_Owner) -> #{handler => {nova_multipart_memory_handler, #{}}} end,
      fun({ok, Req, state}) ->
          [File] = maps:get(files, Req),
          ?assertEqual(#{body => <<"binary">>}, maps:get(result, File))
      end).

receive_one() ->
    receive Msg -> Msg after 1000 -> timeout end.

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/CaaS/Lamina

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.