1: -module(mod_http_upload_s3_SUITE).
    2: -compile([export_all, nowarn_export_all]).
    3: 
    4: -include_lib("common_test/include/ct.hrl").
    5: -include_lib("eunit/include/eunit.hrl").
    6: 
    7: -import(config_parser_helper, [config/2]).
    8: 
    9: all() -> [
   10:           creates_slot_with_given_timestamp,
   11:           cretes_slot_with_aws_v4_auth_queries,
   12:           signs_url_with_expected_size,
   13:           creates_slot_with_given_expiration_time,
   14:           signs_url_with_expected_content_type_if_given,
   15:           provides_and_signs_acl,
   16:           does_not_provide_acl_when_disabled,
   17:           parses_bucket_url_with_custom_port,
   18:           %% uri_string allows percent-encoded strings only
   19: %         parses_unicode_bucket_url,
   20:           parses_bucket_url_with_path,
   21:           parse_bucket_url_with_slashful_path,
   22:           includes_token_in_url,
   23:           creates_get_url_to_the_resource
   24:          ].
   25: 
   26: %% Tests
   27: 
   28: creates_slot_with_given_timestamp(_Config) ->
   29:     Timestamp = calendar:universal_time(),
   30:     {PutUrl, _} = create_slot(#{timestamp => Timestamp}),
   31:     Queries = parse_url(PutUrl, queries),
   32: 
   33:     {_, BinTimestamp} = lists:keyfind(<<"X-Amz-Date">>, 1, Queries),
   34:     ?assertEqual(Timestamp, binary_to_timestamp(BinTimestamp)),
   35: 
   36:     {_, Credential} = lists:keyfind(<<"X-Amz-Credential">>, 1, Queries),
   37:     [_, BinDate | _] = binary:split(Credential, <<"/">>, [global]),
   38:     {Datestamp, _} = Timestamp,
   39:     ?assertEqual(Datestamp, binary_to_timestamp(BinDate)).
   40: 
   41: cretes_slot_with_aws_v4_auth_queries(_Config) ->
   42:     {PutUrl, _} = create_slot(#{}),
   43:     Queries = parse_url(PutUrl, queries),
   44:     ?assert(lists:keymember(<<"X-Amz-Credential">>, 1, Queries)),
   45:     ?assert(lists:keymember(<<"X-Amz-Date">>, 1, Queries)),
   46:     ?assert(lists:keymember(<<"X-Amz-Expires">>, 1, Queries)),
   47:     ?assert(lists:keymember(<<"X-Amz-SignedHeaders">>, 1, Queries)),
   48:     ?assert(lists:keymember(<<"X-Amz-Signature">>, 1, Queries)),
   49:     ?assertEqual({<<"X-Amz-Algorithm">>, <<"AWS4-HMAC-SHA256">>},
   50:                  lists:keyfind(<<"X-Amz-Algorithm">>, 1, Queries)).
   51: 
   52: creates_slot_with_given_expiration_time(_Config) ->
   53:     Opts = config([modules, mod_http_upload], #{expiration_time => 1234,
   54:          s3 => config([modules, mod_http_upload, s3], required_opts())}),
   55:     {PutUrl, _} = create_slot(#{opts => Opts}),
   56:     Queries = parse_url(PutUrl, queries),
   57:     {_, BinExpires} = lists:keyfind(<<"X-Amz-Expires">>, 1, Queries),
   58:     ?assertEqual(1234, binary_to_integer(BinExpires)).
   59: 
   60: required_opts() ->
   61:     #{
   62:         bucket_url => <<"http://bucket.s3-eu-east-25.example.com">>,
   63:         region => <<"eu-east-25">>,
   64:         access_key_id => <<"AKIAIAOAONIULXQGMOUA">>,
   65:         secret_access_key => <<"CG5fGqG0/n6NCPJ10FylpdgRnuV52j8IZvU7BSj8">>
   66:     }.
   67: 
   68: signs_url_with_expected_size(_Config) ->
   69:     meck:new(aws_signature_v4, [passthrough]),
   70:     meck:expect(aws_signature_v4, sign,
   71:                 fun
   72:                     (_, _, _, Headers, _, _, _, _) ->
   73:                         maps:get(<<"content-length">>, Headers, <<"noheader">>)
   74:                 end),
   75: 
   76:     {PutUrl, _} = create_slot(#{size => 4321}),
   77:     Queries = parse_url(PutUrl, queries),
   78:     ?assertEqual({<<"X-Amz-Signature">>, <<"4321">>},
   79:                  lists:keyfind(<<"X-Amz-Signature">>, 1, Queries)),
   80: 
   81:     meck:unload(aws_signature_v4).
   82: 
   83: signs_url_with_expected_content_type_if_given(_Config) ->
   84:     meck:new(aws_signature_v4, [passthrough]),
   85:     meck:expect(aws_signature_v4, sign,
   86:                 fun
   87:                     (_, _, _, Headers, _, _, _, _) ->
   88:                         maps:get(<<"content-type">>, Headers, <<"noheader">>)
   89:                 end),
   90: 
   91:     {PutUrl, _} = create_slot(#{content_type => <<"content/type">>}),
   92:     Queries = parse_url(PutUrl, queries),
   93:     ?assertEqual({<<"X-Amz-Signature">>, <<"content/type">>},
   94:                  lists:keyfind(<<"X-Amz-Signature">>, 1, Queries)),
   95: 
   96:     {PutUrlNoCT, _} = create_slot(#{content_type => undefined}),
   97:     QueriesNoCT = parse_url(PutUrlNoCT, queries),
   98:     ?assertEqual({<<"X-Amz-Signature">>, <<"noheader">>},
   99:                  lists:keyfind(<<"X-Amz-Signature">>, 1, QueriesNoCT)),
  100: 
  101:     meck:unload(aws_signature_v4).
  102: 
  103: provides_and_signs_acl(_Config) ->
  104:     meck:new(aws_signature_v4, [passthrough]),
  105:     meck:expect(aws_signature_v4, sign,
  106:                 fun
  107:                     (_, _, _, Headers, _, _, _, _) ->
  108:                         maps:get(<<"x-amz-acl">>, Headers, <<"noquery">>)
  109:                 end),
  110: 
  111:     Opts = with_s3_opts(#{add_acl => true}),
  112:     {PutUrl, _} = create_slot(#{opts => Opts}),
  113:     Queries = parse_url(PutUrl, queries),
  114:     ?assertEqual(
  115:         {<<"X-Amz-SignedHeaders">>, <<"content-length;content-type;host;x-amz-acl">>},
  116:         lists:keyfind(<<"X-Amz-SignedHeaders">>, 1, Queries)),
  117: 
  118:     ?assertEqual({<<"X-Amz-Signature">>, <<"public-read">>},
  119:                  lists:keyfind(<<"X-Amz-Signature">>, 1, Queries)),
  120: 
  121:     meck:unload(aws_signature_v4).
  122: 
  123: does_not_provide_acl_when_disabled(_Config) ->
  124:     meck:expect(aws_signature_v4, sign,
  125:                 fun
  126:                     (_, _, _, Headers, _, _, _, _) ->
  127:                         maps:get(<<"x-amz-acl">>, Headers, <<"noquery">>)
  128:                 end),
  129: 
  130:     {PutUrl, _} = create_slot(#{}),
  131:     Queries = parse_url(PutUrl, queries),
  132:     ?assertEqual({<<"X-Amz-SignedHeaders">>, <<"content-length;content-type;host">>},
  133:                  lists:keyfind(<<"X-Amz-SignedHeaders">>, 1, Queries)),
  134:     ?assertEqual({<<"X-Amz-Signature">>, <<"noquery">>},
  135:                  lists:keyfind(<<"X-Amz-Signature">>, 1, Queries)),
  136: 
  137:     meck:unload(aws_signature_v4).
  138: 
  139: parses_bucket_url_with_custom_port(_Config) ->
  140:     Opts = with_s3_opts(#{bucket_url => <<"http://localhost:1234">>}),
  141:     {PutUrl, _} = create_slot(#{opts => Opts}),
  142:     ?assertEqual(1234, parse_url(PutUrl, port)).
  143: 
  144: parses_unicode_bucket_url(_Config) ->
  145:     Opts = with_s3_opts(#{bucket_url => <<"http://example.com/❤☀☆☂☻♞"/utf8>>}),
  146:     {PutUrl, _} = create_slot(#{opts => Opts}),
  147:     ?assertMatch(<<"/❤☀☆☂☻♞"/utf8, _/binary>>, parse_url(PutUrl, path)).
  148: 
  149: parses_bucket_url_with_path(_Config) ->
  150:     Opts = with_s3_opts(#{bucket_url => <<"http://example.com/a/path">>}),
  151:     {PutUrl, _} = create_slot(#{opts => Opts}),
  152:     Path = parse_url(PutUrl, path),
  153:     ?assertMatch(<<"/a/path/", _/binary>>, Path),
  154:     ?assertNotMatch(<<"/a/path//", _/binary>>, Path).
  155: 
  156: parse_bucket_url_with_slashful_path(_Config) ->
  157:     Opts = with_s3_opts(#{bucket_url => <<"http://example.com/p/">>}),
  158:     {PutUrl, _} = create_slot(#{opts => Opts}),
  159:     Path = parse_url(PutUrl, path),
  160:     ?assertMatch(<<"/p/", _/binary>>, Path),
  161:     ?assertNotMatch(<<"/p//", _/binary>>, Path).
  162: 
  163: includes_token_in_url(_Config) ->
  164:     {PutUrl, _} = create_slot(#{token => <<"1234token">>}),
  165:     ?assertMatch(<<"/1234token/", _/binary>>, parse_url(PutUrl, path)).
  166: 
  167: creates_get_url_to_the_resource(_Config) ->
  168:     {PutUrl, GetUrl} = create_slot(#{}),
  169:     GetUrlSize = byte_size(GetUrl),
  170:     ?assertMatch(<<GetUrl:GetUrlSize/binary, _/binary>>, PutUrl),
  171:     ?assertEqual([], parse_url(GetUrl, queries)).
  172: 
  173: %% Helpers
  174: create_slot(Args) ->
  175:     {PutUrl, GetUrl, #{}} = mod_http_upload_s3:create_slot(
  176:                               maps:get(timestamp, Args, {{1234, 5, 6}, {7, 8, 9}}),
  177:                               maps:get(token, Args, <<"TOKEN">>),
  178:                               maps:get(filename, Args, <<"filename.jpg">>),
  179:                               maps:get(content_type, Args, <<"image/jpeg">>),
  180:                               maps:get(size, Args, 1234),
  181:                               maps:get(opts, Args, with_s3_opts(#{}))),
  182:     {PutUrl, GetUrl}.
  183: 
  184: with_s3_opts(Opts) ->
  185:     config([modules, mod_http_upload],
  186:         #{s3 => config([modules, mod_http_upload, s3], maps:merge(required_opts(), Opts))}).
  187: 
  188: parse_url(URL) ->
  189:     #{host := Host, path := Path, scheme := Scheme} = Map = uri_string:parse(URL),
  190:     Query = maps:get(query, Map, <<>>),
  191:     Port = maps:get(port, Map, 80),
  192:     Queries = cow_qs:parse_qs(Query),
  193:     #{scheme => Scheme, host => Host, path => Path, port => Port, queries => Queries}.
  194: 
  195: parse_url(URL, Element) -> maps:get(Element, parse_url(URL)).
  196: 
  197: binary_to_timestamp(<<Y:4/binary, M:2/binary, D:2/binary, "T",
  198:                       HH:2/binary, MM:2/binary, SS:2/binary, "Z">>) ->
  199:     {{binary_to_integer(Y), binary_to_integer(M), binary_to_integer(D)},
  200:      {binary_to_integer(HH), binary_to_integer(MM), binary_to_integer(SS)}};
  201: binary_to_timestamp(<<Y:4/binary, M:2/binary, D:2/binary>>) ->
  202:     {binary_to_integer(Y), binary_to_integer(M), binary_to_integer(D)}.