static_handler_SUITE.erl 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940
  1. %% Copyright (c) 2016-2017, Loïc Hoguin <essen@ninenines.eu>
  2. %%
  3. %% Permission to use, copy, modify, and/or distribute this software for any
  4. %% purpose with or without fee is hereby granted, provided that the above
  5. %% copyright notice and this permission notice appear in all copies.
  6. %%
  7. %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
  8. %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
  9. %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
  10. %% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
  11. %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  12. %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  13. %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  14. -module(static_handler_SUITE).
  15. -compile(export_all).
  16. -compile(nowarn_export_all).
  17. -import(ct_helper, [config/2]).
  18. -import(ct_helper, [doc/1]).
  19. -import(cowboy_test, [gun_open/1]).
  20. %% ct.
  21. all() ->
  22. cowboy_test:common_all() ++ [
  23. {group, http_no_sendfile},
  24. {group, h2c_no_sendfile}
  25. ].
  26. groups() ->
  27. AllTests = ct_helper:all(?MODULE),
  28. %% The directory tests are shared between dir and priv_dir options.
  29. DirTests = lists:usort([F || {F, 1} <- ?MODULE:module_info(exports),
  30. string:substr(atom_to_list(F), 1, 4) =:= "dir_"
  31. ]),
  32. OtherTests = AllTests -- DirTests,
  33. GroupTests = OtherTests ++ [
  34. {dir, [parallel], DirTests},
  35. {priv_dir, [parallel], DirTests}
  36. ],
  37. [
  38. {http, [parallel], GroupTests},
  39. {https, [parallel], GroupTests},
  40. {h2, [parallel], GroupTests},
  41. {h2c, [parallel], GroupTests},
  42. {http_compress, [parallel], GroupTests},
  43. {https_compress, [parallel], GroupTests},
  44. {h2_compress, [parallel], GroupTests},
  45. {h2c_compress, [parallel], GroupTests},
  46. %% No real need to test sendfile disabled against https or h2.
  47. {http_no_sendfile, [parallel], GroupTests},
  48. {h2c_no_sendfile, [parallel], GroupTests}
  49. ].
  50. init_per_suite(Config) ->
  51. %% Two static folders are created: one in ct_helper's private directory,
  52. %% and one in the test run private directory.
  53. PrivDir = code:priv_dir(ct_helper) ++ "/static",
  54. StaticDir = config(priv_dir, Config) ++ "/static",
  55. ct_helper:create_static_dir(PrivDir),
  56. ct_helper:create_static_dir(StaticDir),
  57. init_large_file(PrivDir ++ "/large.bin"),
  58. init_large_file(StaticDir ++ "/large.bin"),
  59. %% Add a simple Erlang application archive containing one file
  60. %% in its priv directory.
  61. true = code:add_pathz(filename:join(
  62. [config(data_dir, Config), "static_files_app", "ebin"])),
  63. ok = application:load(static_files_app),
  64. %% A special folder contains files of 1 character from 0 to 127.
  65. CharDir = config(priv_dir, Config) ++ "/char",
  66. ok = filelib:ensure_dir(CharDir ++ "/file"),
  67. Chars0 = lists:flatten([case file:write_file(CharDir ++ [$/, C], [C]) of
  68. ok -> C;
  69. {error, _} -> []
  70. end || C <- lists:seq(0, 127)]),
  71. %% Determine whether we are on a case insensitive filesystem and
  72. %% remove uppercase characters in that case. On case insensitive
  73. %% filesystems we end up overwriting the "A" file with the "a" contents.
  74. {CaseSensitive, Chars} = case file:read_file(CharDir ++ "/A") of
  75. {ok, <<"A">>} -> {true, Chars0};
  76. {ok, <<"a">>} -> {false, Chars0 -- "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}
  77. end,
  78. [{static_dir, StaticDir}, {char_dir, CharDir},
  79. {chars, Chars}, {case_sensitive, CaseSensitive}|Config].
  80. end_per_suite(Config) ->
  81. %% Special directory.
  82. CharDir = config(char_dir, Config),
  83. _ = [file:delete(CharDir ++ [$/, C]) || C <- lists:seq(0, 127)],
  84. _ = file:del_dir(CharDir),
  85. %% Static directories.
  86. StaticDir = config(static_dir, Config),
  87. PrivDir = code:priv_dir(ct_helper) ++ "/static",
  88. %% This file is not created on Windows.
  89. _ = file:delete(StaticDir ++ "/large.bin"),
  90. _ = file:delete(PrivDir ++ "/large.bin"),
  91. ct_helper:delete_static_dir(StaticDir),
  92. ct_helper:delete_static_dir(PrivDir).
  93. init_per_group(dir, Config) ->
  94. [{prefix, "/dir"}|Config];
  95. init_per_group(priv_dir, Config) ->
  96. [{prefix, "/priv_dir"}|Config];
  97. init_per_group(Name=http_no_sendfile, Config) ->
  98. cowboy_test:init_http(Name, #{
  99. env => #{dispatch => init_dispatch(Config)},
  100. middlewares => [?MODULE, cowboy_router, cowboy_handler],
  101. sendfile => false
  102. }, [{flavor, vanilla}|Config]);
  103. init_per_group(Name=h2c_no_sendfile, Config) ->
  104. Config1 = cowboy_test:init_http(Name, #{
  105. env => #{dispatch => init_dispatch(Config)},
  106. middlewares => [?MODULE, cowboy_router, cowboy_handler],
  107. sendfile => false
  108. }, [{flavor, vanilla}|Config]),
  109. lists:keyreplace(protocol, 1, Config1, {protocol, http2});
  110. init_per_group(Name, Config) ->
  111. Config1 = cowboy_test:init_common_groups(Name, Config, ?MODULE),
  112. Opts = ranch:get_protocol_options(Name),
  113. ok = ranch:set_protocol_options(Name, Opts#{
  114. middlewares => [?MODULE, cowboy_router, cowboy_handler]
  115. }),
  116. Config1.
  117. end_per_group(Name, _) ->
  118. cowboy:stop_listener(Name).
  119. %% Large file.
  120. init_large_file(Filename) ->
  121. case os:type() of
  122. {unix, _} ->
  123. "" = os:cmd("truncate -s 32M " ++ Filename),
  124. ok;
  125. {win32, _} ->
  126. ok
  127. end.
  128. %% Routes.
  129. init_dispatch(Config) ->
  130. cowboy_router:compile([{'_', [
  131. {"/priv_dir/[...]", cowboy_static, {priv_dir, ct_helper, "static"}},
  132. {"/dir/[...]", cowboy_static, {dir, config(static_dir, Config)}},
  133. {"/priv_file/style.css", cowboy_static, {priv_file, ct_helper, "static/style.css"}},
  134. {"/file/style.css", cowboy_static, {file, config(static_dir, Config) ++ "/style.css"}},
  135. {"/index", cowboy_static, {file, config(static_dir, Config) ++ "/index.html"}},
  136. {"/mime/all/[...]", cowboy_static, {priv_dir, ct_helper, "static",
  137. [{mimetypes, cow_mimetypes, all}]}},
  138. {"/mime/custom/[...]", cowboy_static, {priv_dir, ct_helper, "static",
  139. [{mimetypes, ?MODULE, do_mime_custom}]}},
  140. {"/mime/crash/[...]", cowboy_static, {priv_dir, ct_helper, "static",
  141. [{mimetypes, ?MODULE, do_mime_crash}]}},
  142. {"/mime/hardcode/binary-form", cowboy_static, {priv_file, ct_helper, "static/file.cowboy",
  143. [{mimetypes, <<"application/vnd.ninenines.cowboy+xml;v=1">>}]}},
  144. {"/mime/hardcode/tuple-form", cowboy_static, {priv_file, ct_helper, "static/file.cowboy",
  145. [{mimetypes, {<<"application">>, <<"vnd.ninenines.cowboy+xml">>, [{<<"v">>, <<"1">>}]}}]}},
  146. {"/charset/custom/[...]", cowboy_static, {priv_dir, ct_helper, "static",
  147. [{charset, ?MODULE, do_charset_custom}]}},
  148. {"/charset/crash/[...]", cowboy_static, {priv_dir, ct_helper, "static",
  149. [{charset, ?MODULE, do_charset_crash}]}},
  150. {"/charset/hardcode/[...]", cowboy_static, {priv_file, ct_helper, "static/index.html",
  151. [{charset, <<"utf-8">>}]}},
  152. {"/etag/custom", cowboy_static, {file, config(static_dir, Config) ++ "/style.css",
  153. [{etag, ?MODULE, do_etag_custom}]}},
  154. {"/etag/crash", cowboy_static, {file, config(static_dir, Config) ++ "/style.css",
  155. [{etag, ?MODULE, do_etag_crash}]}},
  156. {"/etag/disable", cowboy_static, {file, config(static_dir, Config) ++ "/style.css",
  157. [{etag, false}]}},
  158. {"/bad", cowboy_static, bad},
  159. {"/bad/priv_dir/app/[...]", cowboy_static, {priv_dir, bad_app, "static"}},
  160. {"/bad/priv_dir/no-priv/[...]", cowboy_static, {priv_dir, cowboy, "static"}},
  161. {"/bad/priv_dir/path/[...]", cowboy_static, {priv_dir, ct_helper, "bad"}},
  162. {"/bad/priv_dir/route", cowboy_static, {priv_dir, ct_helper, "static"}},
  163. {"/bad/dir/path/[...]", cowboy_static, {dir, "/bad/path"}},
  164. {"/bad/dir/route", cowboy_static, {dir, config(static_dir, Config)}},
  165. {"/bad/priv_file/app", cowboy_static, {priv_file, bad_app, "static/style.css"}},
  166. {"/bad/priv_file/no-priv", cowboy_static, {priv_file, cowboy, "static/style.css"}},
  167. {"/bad/priv_file/path", cowboy_static, {priv_file, ct_helper, "bad/style.css"}},
  168. {"/bad/file/path", cowboy_static, {file, "/bad/path/style.css"}},
  169. {"/bad/options", cowboy_static, {priv_file, ct_helper, "static/style.css", bad}},
  170. {"/bad/options/mime", cowboy_static, {priv_file, ct_helper, "static/style.css", [{mimetypes, bad}]}},
  171. {"/bad/options/charset", cowboy_static, {priv_file, ct_helper, "static/style.css", [{charset, bad}]}},
  172. {"/bad/options/etag", cowboy_static, {priv_file, ct_helper, "static/style.css", [{etag, true}]}},
  173. {"/unknown/option", cowboy_static, {priv_file, ct_helper, "static/style.css", [{bad, option}]}},
  174. {"/char/[...]", cowboy_static, {dir, config(char_dir, Config)}},
  175. {"/ez_priv_file/index.html", cowboy_static, {priv_file, static_files_app, "www/index.html"}},
  176. {"/bad/ez_priv_file/index.php", cowboy_static, {priv_file, static_files_app, "www/index.php"}},
  177. {"/ez_priv_dir/[...]", cowboy_static, {priv_dir, static_files_app, "www"}},
  178. {"/bad/ez_priv_dir/[...]", cowboy_static, {priv_dir, static_files_app, "cgi-bin"}}
  179. ]}]).
  180. %% Middleware interface to silence expected errors.
  181. execute(Req=#{path := Path}, Env) ->
  182. case Path of
  183. <<"/bad/priv_dir/app/", _/bits>> -> ct_helper:ignore(cowboy_static, priv_path, 2);
  184. <<"/bad/priv_file/app">> -> ct_helper:ignore(cowboy_static, priv_path, 2);
  185. <<"/bad/priv_dir/route">> -> ct_helper:ignore(cowboy_static, escape_reserved, 1);
  186. <<"/bad/dir/route">> -> ct_helper:ignore(cowboy_static, escape_reserved, 1);
  187. <<"/bad">> -> ct_helper:ignore(cowboy_static, init_opts, 2);
  188. <<"/bad/options">> -> ct_helper:ignore(cowboy_static, content_types_provided, 2);
  189. <<"/bad/options/mime">> -> ct_helper:ignore(cowboy_rest, set_content_type, 2);
  190. <<"/bad/options/etag">> -> ct_helper:ignore(cowboy_static, generate_etag, 2);
  191. <<"/bad/options/charset">> -> ct_helper:ignore(cowboy_static, charsets_provided, 2);
  192. _ -> ok
  193. end,
  194. {ok, Req, Env}.
  195. %% Internal functions.
  196. -spec do_charset_crash(_) -> no_return().
  197. do_charset_crash(_) ->
  198. ct_helper_error_h:ignore(?MODULE, do_charset_crash, 1),
  199. exit(crash).
  200. do_charset_custom(Path) ->
  201. case filename:extension(Path) of
  202. <<".cowboy">> -> <<"utf-32">>;
  203. <<".html">> -> <<"utf-16">>;
  204. _ -> <<"utf-8">>
  205. end.
  206. -spec do_etag_crash(_, _, _) -> no_return().
  207. do_etag_crash(_, _, _) ->
  208. ct_helper_error_h:ignore(?MODULE, do_etag_crash, 3),
  209. exit(crash).
  210. do_etag_custom(_, _, _) ->
  211. {strong, <<"etag">>}.
  212. -spec do_mime_crash(_) -> no_return().
  213. do_mime_crash(_) ->
  214. ct_helper_error_h:ignore(?MODULE, do_mime_crash, 1),
  215. exit(crash).
  216. do_mime_custom(Path) ->
  217. case filename:extension(Path) of
  218. <<".cowboy">> -> <<"application/vnd.ninenines.cowboy+xml;v=1">>;
  219. <<".txt">> -> <<"text/plain">>;
  220. _ -> {<<"application">>, <<"octet-stream">>, []}
  221. end.
  222. do_get(Path, Config) ->
  223. do_get(Path, [], Config).
  224. do_get(Path, ReqHeaders, Config) ->
  225. ConnPid = gun_open(Config),
  226. Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|ReqHeaders]),
  227. {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref),
  228. {ok, Body} = case IsFin of
  229. nofin -> gun:await_body(ConnPid, Ref);
  230. fin -> {ok, <<>>}
  231. end,
  232. gun:close(ConnPid),
  233. {Status, RespHeaders, Body}.
  234. %% Tests.
  235. bad(Config) ->
  236. doc("Bad cowboy_static options: not a tuple."),
  237. {500, _, _} = do_get("/bad", Config),
  238. ok.
  239. bad_dir_path(Config) ->
  240. doc("Bad cowboy_static options: wrong path."),
  241. {404, _, _} = do_get("/bad/dir/path/style.css", Config),
  242. ok.
  243. bad_dir_route(Config) ->
  244. doc("Bad cowboy_static options: missing [...] in route."),
  245. {500, _, _} = do_get("/bad/dir/route", Config),
  246. ok.
  247. bad_file_in_priv_dir_in_ez_archive(Config) ->
  248. doc("Get a missing file from a priv_dir stored in Erlang application .ez archive."),
  249. {404, _, _} = do_get("/ez_priv_dir/index.php", Config),
  250. ok.
  251. bad_file_path(Config) ->
  252. doc("Bad cowboy_static options: wrong path."),
  253. {404, _, _} = do_get("/bad/file/path", Config),
  254. ok.
  255. bad_options(Config) ->
  256. doc("Bad cowboy_static extra options: not a list."),
  257. {500, _, _} = do_get("/bad/options", Config),
  258. ok.
  259. bad_options_charset(Config) ->
  260. doc("Bad cowboy_static extra options: invalid charset option."),
  261. {500, _, _} = do_get("/bad/options/charset", Config),
  262. ok.
  263. bad_options_etag(Config) ->
  264. doc("Bad cowboy_static extra options: invalid etag option."),
  265. {500, _, _} = do_get("/bad/options/etag", Config),
  266. ok.
  267. bad_options_mime(Config) ->
  268. doc("Bad cowboy_static extra options: invalid mimetypes option."),
  269. {500, _, _} = do_get("/bad/options/mime", Config),
  270. ok.
  271. bad_priv_dir_app(Config) ->
  272. doc("Bad cowboy_static options: wrong application name."),
  273. {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config),
  274. ok.
  275. bad_priv_dir_in_ez_archive(Config) ->
  276. doc("Bad cowboy_static options: priv_dir path missing from Erlang application .ez archive."),
  277. {404, _, _} = do_get("/bad/ez_priv_dir/index.html", Config),
  278. ok.
  279. bad_priv_dir_no_priv(Config) ->
  280. doc("Bad cowboy_static options: application has no priv directory."),
  281. {404, _, _} = do_get("/bad/priv_dir/no-priv/style.css", Config),
  282. ok.
  283. bad_priv_dir_path(Config) ->
  284. doc("Bad cowboy_static options: wrong path."),
  285. {404, _, _} = do_get("/bad/priv_dir/path/style.css", Config),
  286. ok.
  287. bad_priv_dir_route(Config) ->
  288. doc("Bad cowboy_static options: missing [...] in route."),
  289. {500, _, _} = do_get("/bad/priv_dir/route", Config),
  290. ok.
  291. bad_priv_file_app(Config) ->
  292. doc("Bad cowboy_static options: wrong application name."),
  293. {500, _, _} = do_get("/bad/priv_file/app", Config),
  294. ok.
  295. bad_priv_file_in_ez_archive(Config) ->
  296. doc("Bad cowboy_static options: priv_file path missing from Erlang application .ez archive."),
  297. {404, _, _} = do_get("/bad/ez_priv_file/index.php", Config),
  298. ok.
  299. bad_priv_file_no_priv(Config) ->
  300. doc("Bad cowboy_static options: application has no priv directory."),
  301. {404, _, _} = do_get("/bad/priv_file/no-priv", Config),
  302. ok.
  303. bad_priv_file_path(Config) ->
  304. doc("Bad cowboy_static options: wrong path."),
  305. {404, _, _} = do_get("/bad/priv_file/path", Config),
  306. ok.
  307. dir_cowboy(Config) ->
  308. doc("Get a .cowboy file."),
  309. {200, Headers, <<"File with custom extension.\n">>}
  310. = do_get(config(prefix, Config) ++ "/file.cowboy", Config),
  311. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  312. ok.
  313. dir_css(Config) ->
  314. doc("Get a .css file."),
  315. {200, Headers, <<"body{color:red}\n">>}
  316. = do_get(config(prefix, Config) ++ "/style.css", Config),
  317. {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  318. ok.
  319. dir_css_urlencoded(Config) ->
  320. doc("Get a .css file with the extension dot urlencoded."),
  321. {200, Headers, <<"body{color:red}\n">>}
  322. = do_get(config(prefix, Config) ++ "/style%2ecss", Config),
  323. {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  324. ok.
  325. dir_dot_file(Config) ->
  326. doc("Get a file with extra dot segments in the path."),
  327. %% All these are equivalent.
  328. {200, _, _} = do_get(config(prefix, Config) ++ "/./style.css", Config),
  329. {200, _, _} = do_get(config(prefix, Config) ++ "/././style.css", Config),
  330. {200, _, _} = do_get(config(prefix, Config) ++ "/./././style.css", Config),
  331. {200, _, _} = do_get("/./priv_dir/style.css", Config),
  332. {200, _, _} = do_get("/././priv_dir/style.css", Config),
  333. {200, _, _} = do_get("/./././priv_dir/style.css", Config),
  334. ok.
  335. dir_dotdot_file(Config) ->
  336. doc("Get a file with extra dotdot segments in the path."),
  337. %% All these are equivalent.
  338. {200, _, _} = do_get("/../priv_dir/style.css", Config),
  339. {200, _, _} = do_get("/../../priv_dir/style.css", Config),
  340. {200, _, _} = do_get("/../../../priv_dir/style.css", Config),
  341. {200, _, _} = do_get(config(prefix, Config) ++ "/../priv_dir/style.css", Config),
  342. {200, _, _} = do_get(config(prefix, Config) ++ "/../../priv_dir/style.css", Config),
  343. {200, _, _} = do_get(config(prefix, Config) ++ "/../../../priv_dir/style.css", Config),
  344. {200, _, _} = do_get("/../priv_dir/../priv_dir/style.css", Config),
  345. {200, _, _} = do_get("/../../priv_dir/../../priv_dir/style.css", Config),
  346. {200, _, _} = do_get("/../../../priv_dir/../../../priv_dir/style.css", Config),
  347. %% Try with non-existing segments, which may correspond to real folders.
  348. {200, _, _} = do_get("/anything/../priv_dir/style.css", Config),
  349. {200, _, _} = do_get(config(prefix, Config) ++ "/anything/../style.css", Config),
  350. {200, _, _} = do_get(config(prefix, Config) ++ "/directory/../style.css", Config),
  351. {200, _, _} = do_get(config(prefix, Config) ++ "/static/../style.css", Config),
  352. %% Try with segments corresponding to real files. It works because
  353. %% URI normalization happens before looking at the filesystem.
  354. {200, _, _} = do_get(config(prefix, Config) ++ "/style.css/../style.css", Config),
  355. {200, _, _} = do_get(config(prefix, Config) ++ "/style.css/../../priv_dir/style.css", Config),
  356. %% Try to fool the server to accept segments corresponding to real folders.
  357. {404, _, _} = do_get(config(prefix, Config) ++ "/../static/style.css", Config),
  358. {404, _, _} = do_get(config(prefix, Config) ++ "/directory/../../static/style.css", Config),
  359. ok.
  360. dir_empty_file(Config) ->
  361. doc("Get an empty .txt file."),
  362. {200, _, <<>>} = do_get(config(prefix, Config) ++ "/empty.txt", Config),
  363. ok.
  364. dir_error_directory(Config) ->
  365. doc("Try to get a directory."),
  366. {403, _, _} = do_get(config(prefix, Config) ++ "/directory", Config),
  367. ok.
  368. dir_error_directory_slash(Config) ->
  369. doc("Try to get a directory with an extra slash in the path."),
  370. {403, _, _} = do_get(config(prefix, Config) ++ "/directory/", Config),
  371. ok.
  372. dir_error_doesnt_exist(Config) ->
  373. doc("Try to get a file that does not exist."),
  374. {404, Headers, _} = do_get(config(prefix, Config) ++ "/not.found", Config),
  375. false = lists:keyfind(<<"content-type">>, 1, Headers),
  376. ok.
  377. dir_error_dot(Config) ->
  378. doc("Try to get a file named '.'."),
  379. {403, _, _} = do_get(config(prefix, Config) ++ "/.", Config),
  380. ok.
  381. dir_error_dot_urlencoded(Config) ->
  382. doc("Try to get a file named '.' percent encoded."),
  383. {403, _, _} = do_get(config(prefix, Config) ++ "/%2e", Config),
  384. ok.
  385. dir_error_dotdot(Config) ->
  386. doc("Try to get a file named '..'."),
  387. {404, _, _} = do_get(config(prefix, Config) ++ "/..", Config),
  388. ok.
  389. dir_error_dotdot_urlencoded(Config) ->
  390. doc("Try to get a file named '..' percent encoded."),
  391. {404, _, _} = do_get(config(prefix, Config) ++ "/%2e%2e", Config),
  392. ok.
  393. dir_error_empty(Config) ->
  394. doc("Try to get the configured directory."),
  395. {403, _, _} = do_get(config(prefix, Config) ++ "", Config),
  396. ok.
  397. dir_error_slash(Config) ->
  398. %% I know the description isn't that good considering / has a meaning in URIs.
  399. doc("Try to get a file named '/'."),
  400. {403, _, _} = do_get(config(prefix, Config) ++ "//", Config),
  401. ok.
  402. dir_error_slash_urlencoded(Config) ->
  403. doc("Try to get a file named '/' percent encoded."),
  404. {404, _, _} = do_get(config(prefix, Config) ++ "/%2f", Config),
  405. ok.
  406. dir_error_slash_urlencoded_dotdot_file(Config) ->
  407. doc("Try to use a percent encoded slash to access an existing file."),
  408. {200, _, _} = do_get(config(prefix, Config) ++ "/directory/../style.css", Config),
  409. {404, _, _} = do_get(config(prefix, Config) ++ "/directory%2f../style.css", Config),
  410. ok.
  411. dir_error_unreadable(Config) ->
  412. doc("Try to get a file that can't be read."),
  413. {403, _, _} = do_get(config(prefix, Config) ++ "/unreadable", Config),
  414. ok.
  415. dir_html(Config) ->
  416. doc("Get a .html file."),
  417. {200, Headers, <<"<html><body>Hello!</body></html>\n">>}
  418. = do_get(config(prefix, Config) ++ "/index.html", Config),
  419. {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  420. ok.
  421. dir_large_file(Config) ->
  422. doc("Get a large file."),
  423. ConnPid = gun_open(Config),
  424. Ref = gun:get(ConnPid, config(prefix, Config) ++ "/large.bin",
  425. [{<<"accept-encoding">>, <<"gzip">>}]),
  426. {response, nofin, 200, RespHeaders} = gun:await(ConnPid, Ref),
  427. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, RespHeaders),
  428. Size = 32*1024*1024,
  429. {ok, Size} = do_dir_large_file(ConnPid, Ref, 0),
  430. ok.
  431. do_dir_large_file(ConnPid, Ref, N) ->
  432. receive
  433. {gun_data, ConnPid, Ref, nofin, Data} ->
  434. do_dir_large_file(ConnPid, Ref, N + byte_size(Data));
  435. {gun_data, ConnPid, Ref, fin, Data} ->
  436. {ok, N + byte_size(Data)};
  437. {gun_error, ConnPid, Ref, Reason} ->
  438. {error, Reason};
  439. {gun_error, ConnPid, Reason} ->
  440. {error, Reason}
  441. after 5000 ->
  442. {error, timeout}
  443. end.
  444. dir_text(Config) ->
  445. doc("Get a .txt file. The extension is unknown by default."),
  446. {200, Headers, <<"Timeless space.\n">>}
  447. = do_get(config(prefix, Config) ++ "/plain.txt", Config),
  448. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  449. ok.
  450. dir_unknown(Config) ->
  451. doc("Get a file with no extension."),
  452. {200, Headers, <<"File with no extension.\n">>}
  453. = do_get(config(prefix, Config) ++ "/unknown", Config),
  454. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  455. ok.
  456. etag_crash(Config) ->
  457. doc("Get a file with a crashing etag function."),
  458. {500, _, _} = do_get("/etag/crash", Config),
  459. ok.
  460. etag_custom(Config) ->
  461. doc("Get a file with custom Etag function and make sure it is used."),
  462. {200, Headers, _} = do_get("/etag/custom", Config),
  463. {_, <<"\"etag\"">>} = lists:keyfind(<<"etag">>, 1, Headers),
  464. ok.
  465. etag_default(Config) ->
  466. doc("Get a file twice and make sure the Etag matches."),
  467. {200, Headers1, _} = do_get("/dir/style.css", Config),
  468. {200, Headers2, _} = do_get("/dir/style.css", Config),
  469. {_, Etag} = lists:keyfind(<<"etag">>, 1, Headers1),
  470. {_, Etag} = lists:keyfind(<<"etag">>, 1, Headers2),
  471. ok.
  472. etag_default_change(Config) ->
  473. doc("Get a file, modify it, get it again and make sure the Etag doesn't match."),
  474. {200, Headers1, _} = do_get("/dir/index.html", Config),
  475. {_, Etag1} = lists:keyfind(<<"etag">>, 1, Headers1),
  476. ok = file:change_time(config(static_dir, Config) ++ "/index.html",
  477. {{config(port, Config), 1, 1}, {1, 1, 1}}),
  478. {200, Headers2, _} = do_get("/dir/index.html", Config),
  479. {_, Etag2} = lists:keyfind(<<"etag">>, 1, Headers2),
  480. true = Etag1 =/= Etag2,
  481. ok.
  482. etag_disable(Config) ->
  483. doc("Get a file with disabled Etag and make sure no Etag is provided."),
  484. {200, Headers, _} = do_get("/etag/disable", Config),
  485. false = lists:keyfind(<<"etag">>, 1, Headers),
  486. ok.
  487. file(Config) ->
  488. doc("Get a file with hardcoded route."),
  489. {200, Headers, <<"body{color:red}\n">>} = do_get("/file/style.css", Config),
  490. {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  491. ok.
  492. if_match(Config) ->
  493. doc("Get a file with If-Match matching."),
  494. {200, _, _} = do_get("/etag/custom", [
  495. {<<"if-match">>, <<"\"etag\"">>}
  496. ], Config),
  497. ok.
  498. if_match_fail(Config) ->
  499. doc("Get a file with If-Match not matching."),
  500. {412, _, _} = do_get("/etag/custom", [
  501. {<<"if-match">>, <<"\"invalid\"">>}
  502. ], Config),
  503. ok.
  504. if_match_invalid(Config) ->
  505. doc("Try to get a file with an invalid If-Match header."),
  506. {400, _, _} = do_get("/etag/custom", [
  507. {<<"if-match">>, <<"bad input">>}
  508. ], Config),
  509. ok.
  510. if_match_list(Config) ->
  511. doc("Get a file with If-Match matching."),
  512. {200, _, _} = do_get("/etag/custom", [
  513. {<<"if-match">>, <<"\"invalid\", \"etag\", \"cowboy\"">>}
  514. ], Config),
  515. ok.
  516. if_match_list_fail(Config) ->
  517. doc("Get a file with If-Match not matching."),
  518. {412, _, _} = do_get("/etag/custom", [
  519. {<<"if-match">>, <<"\"invalid\", W/\"etag\", \"cowboy\"">>}
  520. ], Config),
  521. ok.
  522. if_match_weak(Config) ->
  523. doc("Try to get a file with a weak If-Match header."),
  524. {412, _, _} = do_get("/etag/custom", [
  525. {<<"if-match">>, <<"W/\"etag\"">>}
  526. ], Config),
  527. ok.
  528. if_match_wildcard(Config) ->
  529. doc("Get a file with a wildcard If-Match."),
  530. {200, _, _} = do_get("/etag/custom", [
  531. {<<"if-match">>, <<"*">>}
  532. ], Config),
  533. ok.
  534. if_modified_since(Config) ->
  535. doc("Get a file with If-Modified-Since in the past."),
  536. {200, _, _} = do_get("/etag/custom", [
  537. {<<"if-modified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>}
  538. ], Config),
  539. ok.
  540. if_modified_since_fail(Config) ->
  541. doc("Get a file with If-Modified-Since equal to file modification time."),
  542. LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"),
  543. {304, _, _} = do_get("/etag/custom", [
  544. {<<"if-modified-since">>, httpd_util:rfc1123_date(LastModified)}
  545. ], Config),
  546. ok.
  547. if_modified_since_future(Config) ->
  548. doc("Get a file with If-Modified-Since in the future."),
  549. {{Year, _, _}, {_, _, _}} = calendar:universal_time(),
  550. {200, _, _} = do_get("/etag/custom", [
  551. {<<"if-modified-since">>, [
  552. <<"Sat, 29 Oct ">>,
  553. integer_to_binary(Year + 1),
  554. <<" 19:43:31 GMT">>]}
  555. ], Config),
  556. ok.
  557. if_modified_since_if_none_match(Config) ->
  558. doc("Get a file with both If-Modified-Since and If-None-Match headers."
  559. "If-None-Match takes precedence and If-Modified-Since is ignored. (RFC7232 3.3)"),
  560. LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"),
  561. {200, _, _} = do_get("/etag/custom", [
  562. {<<"if-modified-since">>, httpd_util:rfc1123_date(LastModified)},
  563. {<<"if-none-match">>, <<"\"not-etag\"">>}
  564. ], Config),
  565. ok.
  566. if_modified_since_invalid(Config) ->
  567. doc("Get a file with an invalid If-Modified-Since header."),
  568. {200, _, _} = do_get("/etag/custom", [
  569. {<<"if-modified-since">>, <<"\"not a date\"">>}
  570. ], Config),
  571. ok.
  572. if_none_match(Config) ->
  573. doc("Get a file with If-None-Match not matching."),
  574. {200, _, _} = do_get("/etag/custom", [
  575. {<<"if-none-match">>, <<"\"not-etag\"">>}
  576. ], Config),
  577. ok.
  578. if_none_match_fail(Config) ->
  579. doc("Get a file with If-None-Match matching."),
  580. {304, _, _} = do_get("/etag/custom", [
  581. {<<"if-none-match">>, <<"\"etag\"">>}
  582. ], Config),
  583. ok.
  584. if_none_match_invalid(Config) ->
  585. doc("Try to get a file with an invalid If-None-Match header."),
  586. {400, _, _} = do_get("/etag/custom", [
  587. {<<"if-none-match">>, <<"bad input">>}
  588. ], Config),
  589. ok.
  590. if_none_match_list(Config) ->
  591. doc("Get a file with If-None-Match not matching."),
  592. {200, _, _} = do_get("/etag/custom", [
  593. {<<"if-none-match">>, <<"\"invalid\", W/\"not-etag\", \"cowboy\"">>}
  594. ], Config),
  595. ok.
  596. if_none_match_list_fail(Config) ->
  597. doc("Get a file with If-None-Match matching."),
  598. {304, _, _} = do_get("/etag/custom", [
  599. {<<"if-none-match">>, <<"\"invalid\", \"etag\", \"cowboy\"">>}
  600. ], Config),
  601. ok.
  602. if_none_match_weak(Config) ->
  603. doc("Try to get a file with a weak If-None-Match header matching."),
  604. {304, _, _} = do_get("/etag/custom", [
  605. {<<"if-none-match">>, <<"W/\"etag\"">>}
  606. ], Config),
  607. ok.
  608. if_none_match_wildcard(Config) ->
  609. doc("Try to get a file with a wildcard If-None-Match."),
  610. {304, _, _} = do_get("/etag/custom", [
  611. {<<"if-none-match">>, <<"*">>}
  612. ], Config),
  613. ok.
  614. if_unmodified_since(Config) ->
  615. doc("Get a file with If-Unmodified-Since equal to file modification time."),
  616. LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"),
  617. {200, _, _} = do_get("/etag/custom", [
  618. {<<"if-unmodified-since">>, httpd_util:rfc1123_date(LastModified)}
  619. ], Config),
  620. ok.
  621. if_unmodified_since_fail(Config) ->
  622. doc("Get a file with If-Unmodified-Since in the past."),
  623. {412, _, _} = do_get("/etag/custom", [
  624. {<<"if-unmodified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>}
  625. ], Config),
  626. ok.
  627. if_unmodified_since_future(Config) ->
  628. doc("Get a file with If-Unmodified-Since in the future."),
  629. {{Year, _, _}, {_, _, _}} = calendar:universal_time(),
  630. {200, _, _} = do_get("/etag/custom", [
  631. {<<"if-unmodified-since">>, [
  632. <<"Sat, 29 Oct ">>,
  633. integer_to_binary(Year + 1),
  634. <<" 19:43:31 GMT">>]}
  635. ], Config),
  636. ok.
  637. if_unmodified_since_if_match(Config) ->
  638. doc("Get a file with both If-Unmodified-Since and If-Match headers."
  639. "If-Match takes precedence and If-Unmodified-Since is ignored. (RFC7232 3.4)"),
  640. {200, _, _} = do_get("/etag/custom", [
  641. {<<"if-unmodified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>},
  642. {<<"if-match">>, <<"\"etag\"">>}
  643. ], Config),
  644. ok.
  645. if_unmodified_since_invalid(Config) ->
  646. doc("Get a file with an invalid If-Unmodified-Since header."),
  647. {200, _, _} = do_get("/etag/custom", [
  648. {<<"if-unmodified-since">>, <<"\"not a date\"">>}
  649. ], Config),
  650. ok.
  651. index_file(Config) ->
  652. doc("Get an index file."),
  653. {200, Headers, <<"<html><body>Hello!</body></html>\n">>} = do_get("/index", Config),
  654. {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  655. ok.
  656. index_file_slash(Config) ->
  657. doc("Get an index file with extra slash."),
  658. {200, Headers, <<"<html><body>Hello!</body></html>\n">>} = do_get("/index/", Config),
  659. {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  660. ok.
  661. last_modified(Config) ->
  662. doc("Get a file, modify it, get it again and make sure Last-Modified changes."),
  663. {200, Headers1, _} = do_get("/dir/file.cowboy", Config),
  664. {_, LastModified1} = lists:keyfind(<<"last-modified">>, 1, Headers1),
  665. ok = file:change_time(config(static_dir, Config) ++ "/file.cowboy",
  666. {{config(port, Config), 1, 1}, {1, 1, 1}}),
  667. {200, Headers2, _} = do_get("/dir/file.cowboy", Config),
  668. {_, LastModified2} = lists:keyfind(<<"last-modified">>, 1, Headers2),
  669. true = LastModified1 =/= LastModified2,
  670. ok.
  671. mime_all_cowboy(Config) ->
  672. doc("Get a .cowboy file. The extension is unknown."),
  673. {200, Headers, _} = do_get("/mime/all/file.cowboy", Config),
  674. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  675. ok.
  676. mime_all_css(Config) ->
  677. doc("Get a .css file."),
  678. {200, Headers, _} = do_get("/mime/all/style.css", Config),
  679. {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  680. ok.
  681. mime_all_txt(Config) ->
  682. doc("Get a .txt file."),
  683. {200, Headers, _} = do_get("/mime/all/plain.txt", Config),
  684. {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  685. ok.
  686. mime_all_uppercase(Config) ->
  687. doc("Get an uppercase .TXT file."),
  688. {200, Headers, _} = do_get("/mime/all/UPPER.TXT", Config),
  689. {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  690. ok.
  691. mime_crash(Config) ->
  692. doc("Get a file with a crashing mimetype function."),
  693. {500, _, _} = do_get("/mime/crash/style.css", Config),
  694. ok.
  695. mime_custom_cowboy(Config) ->
  696. doc("Get a .cowboy file."),
  697. {200, Headers, _} = do_get("/mime/custom/file.cowboy", Config),
  698. {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  699. ok.
  700. mime_custom_css(Config) ->
  701. doc("Get a .css file. The extension is unknown."),
  702. {200, Headers, _} = do_get("/mime/custom/style.css", Config),
  703. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  704. ok.
  705. mime_custom_txt(Config) ->
  706. doc("Get a .txt file."),
  707. {200, Headers, _} = do_get("/mime/custom/plain.txt", Config),
  708. {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  709. ok.
  710. mime_hardcode_binary(Config) ->
  711. doc("Get a .cowboy file with hardcoded route and media type in binary form."),
  712. {200, Headers, _} = do_get("/mime/hardcode/binary-form", Config),
  713. {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  714. ok.
  715. mime_hardcode_tuple(Config) ->
  716. doc("Get a .cowboy file with hardcoded route and media type in tuple form."),
  717. {200, Headers, _} = do_get("/mime/hardcode/tuple-form", Config),
  718. {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  719. ok.
  720. charset_crash(Config) ->
  721. doc("Get a file with a crashing charset function."),
  722. {500, _, _} = do_get("/charset/crash/style.css", Config),
  723. ok.
  724. charset_custom_cowboy(Config) ->
  725. doc("Get a .cowboy file."),
  726. {200, Headers, _} = do_get("/charset/custom/file.cowboy", Config),
  727. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  728. ok.
  729. charset_custom_css(Config) ->
  730. doc("Get a .css file."),
  731. {200, Headers, _} = do_get("/charset/custom/style.css", Config),
  732. {_, <<"text/css; charset=utf-8">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  733. ok.
  734. charset_custom_html(Config) ->
  735. doc("Get a .html file."),
  736. {200, Headers, _} = do_get("/charset/custom/index.html", Config),
  737. {_, <<"text/html; charset=utf-16">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  738. ok.
  739. charset_hardcode_binary(Config) ->
  740. doc("Get a .html file with hardcoded route and charset."),
  741. {200, Headers, _} = do_get("/charset/hardcode", Config),
  742. {_, <<"text/html; charset=utf-8">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  743. ok.
  744. priv_dir_in_ez_archive(Config) ->
  745. doc("Get a file from a priv_dir stored in Erlang application .ez archive."),
  746. {200, Headers, <<"<h1>It works!</h1>\n">>} = do_get("/ez_priv_dir/index.html", Config),
  747. {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  748. ok.
  749. priv_file(Config) ->
  750. doc("Get a file with hardcoded route."),
  751. {200, Headers, <<"body{color:red}\n">>} = do_get("/priv_file/style.css", Config),
  752. {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  753. ok.
  754. priv_file_in_ez_archive(Config) ->
  755. doc("Get a file stored in Erlang application .ez archive."),
  756. {200, Headers, <<"<h1>It works!</h1>\n">>} = do_get("/ez_priv_file/index.html", Config),
  757. {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  758. ok.
  759. range_request(Config) ->
  760. doc("Confirm that range requests are enabled."),
  761. {206, Headers, <<"less space.\n">>} = do_get("/dir/plain.txt",
  762. [{<<"range">>, <<"bytes=4-">>}], Config),
  763. {_, <<"bytes">>} = lists:keyfind(<<"accept-ranges">>, 1, Headers),
  764. {_, <<"bytes 4-15/16">>} = lists:keyfind(<<"content-range">>, 1, Headers),
  765. {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  766. ok.
  767. unicode_basic_latin(Config) ->
  768. doc("Get a file with non-urlencoded characters from Unicode Basic Latin block."),
  769. %% Excluding the dot which has a special meaning in URLs
  770. %% when they are the only content in a path segment,
  771. %% and is tested as part of filenames in other test cases.
  772. Chars0 =
  773. "abcdefghijklmnopqrstuvwxyz"
  774. "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
  775. "0123456789"
  776. ":@-_~!$&'()*+,;=",
  777. Chars = case config(case_sensitive, Config) of
  778. false -> Chars0 -- "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  779. true -> Chars0
  780. end,
  781. _ = [case do_get("/char/" ++ [C], Config) of
  782. {200, _, << C >>} -> ok;
  783. Error -> exit({error, C, Error})
  784. end || C <- Chars],
  785. ok.
  786. unicode_basic_error(Config) ->
  787. doc("Try to get a file with invalid non-urlencoded characters from Unicode Basic Latin block."),
  788. Exclude = case config(protocol, Config) of
  789. %% Some characters trigger different errors in HTTP/1.1
  790. %% because they are used for the protocol.
  791. %%
  792. %% # and ? indicate fragment and query components
  793. %% and are therefore not part of the path.
  794. http -> "\r\s#?";
  795. http2 -> "#?"
  796. end,
  797. _ = [case do_get("/char/" ++ [C], Config) of
  798. {400, _, _} -> ok;
  799. Error -> exit({error, C, Error})
  800. end || C <- (config(chars, Config) -- Exclude) --
  801. "abcdefghijklmnopqrstuvwxyz"
  802. "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
  803. "0123456789"
  804. ":@-_~!$&'()*+,;="
  805. ],
  806. ok.
  807. unicode_basic_latin_urlencoded(Config) ->
  808. doc("Get a file with urlencoded characters from Unicode Basic Latin block."),
  809. _ = [case do_get(lists:flatten(["/char/%", io_lib:format("~2.16.0b", [C])]), Config) of
  810. {200, _, << C >>} -> ok;
  811. Error -> exit({error, C, Error})
  812. end || C <- config(chars, Config)],
  813. ok.
  814. unknown_option(Config) ->
  815. doc("Get a file configured with unknown extra options."),
  816. {200, Headers, <<"body{color:red}\n">>} = do_get("/unknown/option", Config),
  817. {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
  818. ok.