1: -module(mod_http_upload_SUITE).
    2: 
    3: -include_lib("escalus/include/escalus.hrl").
    4: -include_lib("common_test/include/ct.hrl").
    5: -include_lib("eunit/include/eunit.hrl").
    6: -include_lib("escalus/include/escalus_xmlns.hrl").
    7: -include_lib("exml/include/exml.hrl").
    8: 
    9: -import(domain_helper, [host_type/0]).
   10: 
   11: -define(NS_XDATA, <<"jabber:x:data">>).
   12: -define(NS_HTTP_UPLOAD_030, <<"urn:xmpp:http:upload:0">>).
   13: 
   14: -define(S3_HOSTNAME, <<"http://bucket.s3-eu-east-25.example.com">>).
   15: -define(MINIO_HOSTNAME, <<"http://127.0.0.1:9000/mybucket/">>).
   16: 
   17: -define(MINIO_TEST_DATA, "qwerty").
   18: 
   19: -export([all/0, groups/0, suite/0,
   20: 	 init_per_suite/1, end_per_suite/1,
   21: 	 init_per_group/2, end_per_group/2,
   22: 	 init_per_testcase/2, end_per_testcase/2]).
   23: 
   24: -export([
   25: 	 test_minio_upload_without_content_type/1,
   26: 	 test_minio_upload_with_content_type/1,
   27: 
   28: 	 http_upload_item_discovery/1,
   29: 	 http_upload_feature_discovery/1,
   30: 	 advertises_max_file_size/1,
   31: 	 request_slot/1,
   32: 	 rejects_set_iq/1,
   33: 	 rejects_disco_set_iq/1,
   34: 	 rejects_feature_discovery_with_node/1,
   35: 	 get_url_ends_with_filename/1,
   36: 	 urls_contain_s3_hostname/1,
   37: 	 rejects_empty_filename/1,
   38: 	 rejects_negative_filesize/1,
   39: 	 rejects_invalid_size_type/1,
   40: 	 denies_slots_over_max_file_size/1,
   41: 	 sends_different_put_and_get_urls/1,
   42: 	 escapes_urls_once/1
   43: 	]).
   44: 
   45: %%--------------------------------------------------------------------
   46: %% Suite configuration
   47: %%--------------------------------------------------------------------
   48: 
   49: all() ->
   50:     [{group, mod_http_upload_s3}, {group, real_upload_with_acl},
   51:      {group, real_upload_without_acl}].
   52: 
   53: groups() ->
   54:     [{real_upload_with_acl, [], [test_minio_upload_without_content_type,
   55:                                  test_minio_upload_with_content_type]},
   56:      {real_upload_without_acl, [], [test_minio_upload_without_content_type,
   57:                                     test_minio_upload_with_content_type]},
   58:      {mod_http_upload_s3, [], [
   59:                                http_upload_item_discovery,
   60:                                http_upload_feature_discovery,
   61:                                advertises_max_file_size,
   62:                                request_slot,
   63:                                rejects_set_iq,
   64:                                rejects_disco_set_iq,
   65:                                rejects_feature_discovery_with_node,
   66:                                get_url_ends_with_filename,
   67:                                urls_contain_s3_hostname,
   68:                                rejects_empty_filename,
   69:                                rejects_negative_filesize,
   70:                                rejects_invalid_size_type,
   71:                                denies_slots_over_max_file_size,
   72:                                sends_different_put_and_get_urls,
   73:                                escapes_urls_once
   74:                               ]}].
   75: 
   76: suite() ->
   77:     escalus:suite().
   78: 
   79: %%--------------------------------------------------------------------
   80: %% Init & teardown
   81: %%--------------------------------------------------------------------
   82: 
   83: init_per_suite(Config) ->
   84:     escalus:init_per_suite(Config).
   85: end_per_suite(Config) ->
   86:     escalus:end_per_suite(Config).
   87: 
   88: init_per_group(real_upload_without_acl, Config) ->
   89:     case mongoose_helper:should_minio_be_running(Config) of
   90:         true ->
   91:             dynamic_modules:start(host_type(), mod_http_upload,
   92:                 create_opts(?MINIO_HOSTNAME, false)),
   93:             escalus:create_users(Config, escalus:get_users([bob]));
   94:         false -> {skip, "minio is not running"}
   95:     end;
   96: init_per_group(real_upload_with_acl, Config) ->
   97:     case mongoose_helper:should_minio_be_running(Config) of
   98:         true ->
   99:             dynamic_modules:start(host_type(), mod_http_upload, create_opts(?MINIO_HOSTNAME, true)),
  100:             [{with_acl, true} | escalus:create_users(Config, escalus:get_users([bob]))];
  101:         false -> {skip, "minio is not running"}
  102:     end;
  103: init_per_group(_, Config) ->
  104:     dynamic_modules:start(host_type(), mod_http_upload, create_opts(?S3_HOSTNAME, true)),
  105:     escalus:create_users(Config, escalus:get_users([bob])).
  106: 
  107: end_per_group(_, Config) ->
  108:     dynamic_modules:stop(host_type(), mod_http_upload),
  109:     escalus:delete_users(Config, escalus:get_users([bob])).
  110: 
  111: init_per_testcase(CaseName, Config) ->
  112:     escalus:init_per_testcase(CaseName, Config).
  113: 
  114: end_per_testcase(CaseName, Config) ->
  115:     escalus:end_per_testcase(CaseName, Config).
  116: 
  117: create_opts(Host, AddAcl) ->
  118:     config_parser_helper:mod_config(mod_http_upload,
  119:     #{
  120:         max_file_size => 1234,
  121:         s3 => #{
  122:             bucket_url => Host,
  123:             add_acl => AddAcl,
  124:             region => <<"eu-east-25">>,
  125:             access_key_id => <<"AKIAIAOAONIULXQGMOUA">>,
  126:             secret_access_key => <<"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">>
  127:         }
  128:     }).
  129: 
  130: %%--------------------------------------------------------------------
  131: %% Service discovery test
  132: %%--------------------------------------------------------------------
  133: 
  134: http_upload_item_discovery(Config) ->
  135:     escalus:story(
  136:       Config, [{bob, 1}],
  137:       fun(Bob) ->
  138:               ServJID = escalus_client:server(Bob),
  139:               Result = escalus:send_and_wait(Bob, escalus_stanza:disco_items(ServJID)),
  140:               escalus:assert(is_iq_result, Result),
  141:               Query = exml_query:subelement(Result, <<"query">>),
  142:               Item = exml_query:subelement_with_attr(Query, <<"jid">>, upload_service(Bob)),
  143:               ?assertEqual(<<"HTTP File Upload">>, exml_query:attr(Item, <<"name">>))
  144:       end).
  145: 
  146: http_upload_feature_discovery(Config) ->
  147:     escalus:story(
  148:       Config, [{bob, 1}],
  149:       fun(Bob) ->
  150:               ServJID = escalus_client:server(Bob),
  151:               Result = escalus:send_and_wait(Bob, escalus_stanza:disco_info(ServJID)),
  152:               escalus:assert(fun has_no_feature/2, [ns()], Result),
  153:               SubServJID = upload_service(Bob),
  154:               SubResult = escalus:send_and_wait(Bob, escalus_stanza:disco_info(SubServJID)),
  155:               escalus:assert(has_feature, [ns()], SubResult)
  156:       end).
  157: 
  158: advertises_max_file_size(Config) ->
  159:     escalus:story(
  160:       Config, [{bob, 1}],
  161:       fun(Bob) ->
  162:               ServJID = upload_service(Bob),
  163:               Result = escalus:send_and_wait(Bob, escalus_stanza:disco_info(ServJID)),
  164:               Forms = exml_query:paths(Result, [{element, <<"query">>}, {element, <<"x">>}]),
  165:               [Form] = lists:filter(
  166:                          fun(F) -> has_field(<<"FORM_TYPE">>, <<"hidden">>, ns(), F) end,
  167:                          Forms),
  168: 
  169:               escalus:assert(has_type, [<<"result">>], Form),
  170:               escalus:assert(has_ns, [?NS_XDATA], Form),
  171:               escalus:assert(fun has_field/4, [<<"max-file-size">>, undefined, <<"1234">>], Form),
  172:               escalus:assert(has_identity, [<<"store">>, <<"file">>], Result)
  173:       end).
  174: 
  175: rejects_set_iq(Config) ->
  176:     escalus:story(
  177:       Config, [{bob, 1}],
  178:       fun(Bob) ->
  179:               ServJID = upload_service(Bob),
  180:               IQ = escalus_stanza:iq_set(ns(), []),
  181:               Request = escalus_stanza:to(IQ, ServJID),
  182:               Result = escalus:send_and_wait(Bob, Request),
  183:               escalus_assert:is_error(Result, <<"cancel">>, <<"not-allowed">>)
  184:       end).
  185: 
  186: rejects_disco_set_iq(Config) ->
  187:     escalus:story(
  188:       Config, [{bob, 1}],
  189:       fun(Bob) ->
  190:               ServJID = upload_service(Bob),
  191:               IQ = escalus_stanza:iq_set(?NS_DISCO_INFO, []),
  192:               Request = escalus_stanza:to(IQ, ServJID),
  193:               Stanza = escalus:send_and_wait(Bob, Request),
  194:               escalus:assert(is_iq_error, [Request], Stanza),
  195:               escalus:assert(is_error, [<<"cancel">>, <<"not-allowed">>], Stanza),
  196:               escalus:assert(is_stanza_from, [ServJID], Stanza)
  197:       end).
  198: 
  199: rejects_feature_discovery_with_node(Config) ->
  200:     escalus:story(
  201:       Config, [{bob, 1}],
  202:       fun(Bob) ->
  203:               ServJID = upload_service(Bob),
  204:               Request = escalus_stanza:disco_info(ServJID, <<"bad-node">>),
  205:               Stanza = escalus:send_and_wait(Bob, Request),
  206:               escalus:assert(is_iq_error, [Request], Stanza),
  207:               escalus:assert(is_error, [<<"cancel">>, <<"item-not-found">>], Stanza),
  208:               escalus:assert(is_stanza_from, [ServJID], Stanza)
  209:       end).
  210: 
  211: request_slot(Config) ->
  212:     escalus:story(
  213:       Config, [{bob, 1}],
  214:       fun(Bob) ->
  215:               ServJID = upload_service(Bob),
  216:               Request = create_slot_request_stanza(ServJID, <<"filename.jpg">>, 123, undefined),
  217:               Result = escalus:send_and_wait(Bob, Request),
  218:               escalus:assert(is_iq_result, Result),
  219:               escalus:assert(fun has_upload_namespace/1, Result),
  220:               escalus:assert(fun has_put_and_get_fields/1, Result)
  221:       end).
  222: 
  223: get_url_ends_with_filename(Config) ->
  224:     escalus:story(
  225:       Config, [{bob, 1}],
  226:       fun(Bob) ->
  227:               ServJID = upload_service(Bob),
  228:               Filename = <<"filename.jpg">>,
  229:               Request = create_slot_request_stanza(ServJID, Filename, 123, undefined),
  230:               Result = escalus:send_and_wait(Bob, Request),
  231:               escalus:assert(fun path_ends_with/3, [<<"get">>, Filename], Result)
  232:       end).
  233: 
  234: urls_contain_s3_hostname(Config) ->
  235:     escalus:story(
  236:       Config, [{bob, 1}],
  237:       fun(Bob) ->
  238:               ServJID = upload_service(Bob),
  239:               Request = create_slot_request_stanza(ServJID, <<"filename.jpg">>, 123, undefined),
  240:               Result = escalus:send_and_wait(Bob, Request),
  241:               escalus:assert(fun url_contains/3, [<<"get">>, ?S3_HOSTNAME], Result),
  242:               escalus:assert(fun url_contains/3, [<<"put">>, ?S3_HOSTNAME], Result)
  243:       end).
  244: 
  245: test_minio_upload_without_content_type(Config) ->
  246:     test_minio_upload(Config, undefined).
  247: 
  248: test_minio_upload_with_content_type(Config) ->
  249:     test_minio_upload(Config, <<"text/plain">>).
  250: 
  251: test_minio_upload(Config, ContentType) ->
  252:     escalus:story(
  253:         Config, [{bob, 1}],
  254:         fun(Bob) ->
  255:             ServJID = upload_service(Bob),
  256:             FileSize = length(?MINIO_TEST_DATA),
  257:             Request = create_slot_request_stanza(ServJID, <<"file.txt">>, FileSize, ContentType),
  258:             Result = escalus:send_and_wait(Bob, Request),
  259:             GetUrl = binary_to_list(extract_url(Result, <<"get">>)),
  260:             PutUrl = binary_to_list(extract_url(Result, <<"put">>)),
  261:             Header = generate_header(Config, ContentType),
  262:             PutRetValue = request(put, PutUrl, Header, ?MINIO_TEST_DATA),
  263:             ?assertMatch({200, _}, PutRetValue),
  264:             GetRetValue = request(get, GetUrl, [], []),
  265:             ?assertMatch({200, ?MINIO_TEST_DATA}, GetRetValue)
  266:         end).
  267: 
  268: request(Method, PutUrl, Header, Data) ->
  269:     PURL = #{host := Host, port := Port, path := Path} = uri_string:parse(PutUrl),
  270:     {ok, ConnPid} = gun:open(Host, Port),
  271:     {ok, _} = gun:await_up(ConnPid),
  272:     FullPath =
  273:         case PURL of
  274:             #{query := Query} ->
  275:                 Path ++ "?" ++ Query;
  276:             _ ->
  277:                 Path
  278:         end,
  279:     StreamRef =
  280:         case Method of
  281:             put ->
  282:                 gun:put(ConnPid, FullPath, Header, Data);
  283:             get ->
  284:                 gun:get(ConnPid, FullPath)
  285:         end,
  286:     RespOpts = #{pid => ConnPid, stream_ref => StreamRef, acc => <<>>},
  287:     #{status := Status, acc := Acc} = get_reponse(RespOpts),
  288:     ok = gun:close(ConnPid),
  289:     {Status, binary_to_list(Acc)}.
  290: 
  291: get_reponse(#{pid := Pid, stream_ref := StreamRef, acc := Acc} = Opts) ->
  292:     case gun:await(Pid, StreamRef) of
  293:         {response, fin, Status, _} ->
  294:             Opts#{status => Status, acc => Acc};
  295:         {response, nofin, Status, _} ->
  296:             get_reponse(Opts#{status => Status});
  297:         {data, nofin, Data} ->
  298:             get_reponse(Opts#{acc => <<Acc/binary, Data/binary>>});
  299:         {data, fin, Data} ->
  300:             Opts#{acc => <<Acc/binary, Data/binary>>};
  301:         Error ->
  302:             Error
  303:     end.
  304: 
  305: generate_header(Config, undefined) ->
  306:     case proplists:get_value(with_acl, Config, false) of
  307:         true ->
  308:             [{<<"x-amz-acl">>, <<"public-read">>}];
  309:         false ->
  310:             []
  311:     end;
  312: generate_header(Config, ContentType) ->
  313:     [{<<"content-type">>, ContentType} | generate_header(Config, undefined)].
  314: 
  315: rejects_empty_filename(Config) ->
  316:     escalus:story(
  317:       Config, [{bob, 1}],
  318:       fun(Bob) ->
  319:               ServJID = upload_service(Bob),
  320:               Request = create_slot_request_stanza(ServJID, <<>>, 123, undefined),
  321:               Result = escalus:send_and_wait(Bob, Request),
  322:               escalus_assert:is_error(Result, <<"modify">>, <<"bad-request">>)
  323:       end).
  324: 
  325: rejects_negative_filesize(Config) ->
  326:     escalus:story(
  327:       Config, [{bob, 1}],
  328:       fun(Bob) ->
  329:               ServJID = upload_service(Bob),
  330:               Request = create_slot_request_stanza(ServJID, <<"filename.jpg">>, -1, undefined),
  331:               Result = escalus:send_and_wait(Bob, Request),
  332:               escalus_assert:is_error(Result, <<"modify">>, <<"bad-request">>)
  333:       end).
  334: 
  335: rejects_invalid_size_type(Config) ->
  336:     escalus:story(
  337:       Config, [{bob, 1}],
  338:       fun(Bob) ->
  339:               ServJID = upload_service(Bob),
  340:               Request = create_slot_request_stanza(ServJID, <<"a.jpg">>, <<"filesize">>, undefined),
  341:               Result = escalus:send_and_wait(Bob, Request),
  342:               escalus_assert:is_error(Result, <<"modify">>, <<"bad-request">>)
  343:       end).
  344: 
  345: denies_slots_over_max_file_size(Config) ->
  346:     escalus:story(
  347:       Config, [{bob, 1}],
  348:       fun(Bob) ->
  349:               ServJID = upload_service(Bob),
  350:               Request = create_slot_request_stanza(ServJID, <<"filename.jpg">>, 54321, undefined),
  351:               Result = escalus:send_and_wait(Bob, Request),
  352:               escalus:assert(is_error, [<<"modify">>, <<"not-acceptable">>], Result),
  353:               <<"1234">> = exml_query:path(Result, [{element, <<"error">>},
  354:                                                     {element, <<"file-too-large">>},
  355:                                                     {element, <<"max-file-size">>},
  356:                                                     cdata])
  357:       end).
  358: 
  359: sends_different_put_and_get_urls(Config) ->
  360:     escalus:story(
  361:      Config, [{bob, 1}],
  362:      fun(Bob) ->
  363:              ServJID = upload_service(Bob),
  364:              Request = create_slot_request_stanza(ServJID, <<"filename.jpg">>, 123, undefined),
  365:              Result = escalus:send_and_wait(Bob, Request),
  366:              escalus:assert(fun urls_not_equal/1, Result)
  367:      end).
  368: 
  369: escapes_urls_once(Config) ->
  370:     escalus:story(
  371:       Config, [{bob, 1}],
  372:       fun(Bob) ->
  373:               ServJID = upload_service(Bob),
  374:               Request = create_slot_request_stanza(ServJID, <<"filename.jpg">>, 123, undefined),
  375:               Result = escalus:send_and_wait(Bob, Request),
  376:               escalus:assert(fun url_contains/3, [<<"put">>, <<"%3Bx-amz-acl">>], Result)
  377:       end).
  378: 
  379: %%--------------------------------------------------------------------
  380: %% Test helpers
  381: %%--------------------------------------------------------------------
  382: create_slot_request_stanza(Server, Filename, Size, ContentType) when is_integer(Size) ->
  383:     create_slot_request_stanza(Server, Filename, integer_to_binary(Size), ContentType);
  384: create_slot_request_stanza(Server, Filename, BinSize, ContentType) ->
  385:     #xmlel{name     = <<"iq">>,
  386:            attrs    = [{<<"type">>, <<"get">>}, {<<"to">>, Server}],
  387:            children = [create_request_element(Filename, BinSize, ContentType)]}.
  388: 
  389: create_request_element(Filename, BinSize, ContentType) ->
  390:     ContentTypeEl = case ContentType of
  391:                         undefined -> [];
  392:                         _ -> [{<<"content-type">>, ContentType}]
  393:                     end,
  394:     #xmlel{name  = <<"request">>,
  395:            attrs = [{<<"xmlns">>, ?NS_HTTP_UPLOAD_030},
  396:                     {<<"filename">>, Filename},
  397:                     {<<"size">>, BinSize}
  398:                     | ContentTypeEl]}.
  399: 
  400: has_upload_namespace(#xmlel{name = <<"iq">>, children = [#xmlel{ name = <<"slot">> } = Slot]}) ->
  401:     ?NS_HTTP_UPLOAD_030 == exml_query:attr(Slot, <<"xmlns">>);
  402: has_upload_namespace(_) ->
  403:     false.
  404: 
  405: has_no_feature(Feature, Stanza) ->
  406:     not escalus_pred:has_feature(Feature, Stanza).
  407: 
  408: has_put_and_get_fields(Elem = #xmlel{name = <<"iq">>}) ->
  409:     PutUrl = extract_url(Elem, <<"put">>),
  410:     GetUrl = extract_url(Elem, <<"get">>),
  411:     is_binary(PutUrl) andalso is_binary(GetUrl)
  412:         andalso byte_size(PutUrl) > 0 andalso byte_size(GetUrl) > 0;
  413: has_put_and_get_fields(_Elem) ->
  414:     false.
  415: 
  416: path_ends_with(UrlType, Filename, Result) ->
  417:     Url = extract_url(Result, UrlType),
  418:     #{ path := Path } = uri_string:parse(Url),
  419:     FilenameSize = byte_size(Filename),
  420:     ReverseFilename = reverse(Filename),
  421:     case reverse(Path) of
  422:         <<ReverseFilename:FilenameSize/binary, _/binary>> -> true;
  423:         _ -> false
  424:     end.
  425: 
  426: url_contains(UrlType, Filename, Result) ->
  427:     Url = extract_url(Result, UrlType),
  428:     binary:match(Url, Filename) =/= nomatch.
  429: 
  430: urls_not_equal(Result) ->
  431:     Get = extract_url(Result, <<"get">>),
  432:     Put = extract_url(Result, <<"put">>),
  433:     Get =/= Put.
  434: 
  435: reverse(List) when is_list(List) ->
  436:     list_to_binary(lists:reverse(List));
  437: reverse(Binary) ->
  438:     reverse(binary_to_list(Binary)).
  439: 
  440: upload_service(Client) ->
  441:     <<"upload.", (escalus_client:server(Client))/binary>>.
  442: 
  443: has_field(Var, Type, Value, Form) ->
  444:     Fields = Form#xmlel.children,
  445:     VarFits = fun(I) -> Var =:= undefined orelse exml_query:attr(I, <<"var">>) =:= Var end,
  446:     TypeFits = fun(I) -> Type =:= undefined orelse exml_query:attr(I, <<"type">>) =:= Type end,
  447:     ValueFits =
  448:         fun(I) ->
  449:                 Value =:= undefined orelse
  450:                     Value =:= exml_query:path(I, [{element, <<"value">>}, cdata])
  451:         end,
  452:     lists:any(fun(Item) -> VarFits(Item) andalso TypeFits(Item) andalso ValueFits(Item) end,
  453:               Fields).
  454: 
  455: extract_url(Result, UrlType) ->
  456:     exml_query:path(Result, [{element, <<"slot">>}, {element, UrlType}, {attr, <<"url">>}]).
  457: 
  458: ns() -> ?NS_HTTP_UPLOAD_030.