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