diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbe4a1..310f80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All public functions in `TestServer` have been moved to `TestServer.HTTP`. The HTTP server adapters in `TestServer.HTTPServer.*` have been moved to `TestServer.HTTP.Server.*`. +- Fixed bug where `:match` functions that raised errors always matched in `TestServer.HTTP.add/2` and `TestServer.HTTP.websocket_handle/3` +- Fixed UTF-8 response body handling for `TestServer.HTTP.Server.Httpd` +- Fixed invalid host header port parsing in `TestServer.HTTP.Server.Httpd` +- Fixed bracketed IPv6 host header parsing in `TestServer.HTTP.Server.Httpd` +- Fixed query string parsing with extra `?` segments in `TestServer.HTTP.Server.Httpd` + ## v0.1.22 (2026-03-05) Requires Elixir 1.14 or higher. diff --git a/lib/test_server/http/README.md b/lib/test_server/http/README.md index 2314c55..8b12cc1 100644 --- a/lib/test_server/http/README.md +++ b/lib/test_server/http/README.md @@ -108,8 +108,8 @@ test "WebSocketClient" do {:ok, socket} = TestServer.HTTP.websocket_init("/ws") :ok = TestServer.HTTP.websocket_handle(socket) - :ok = TestServer.HTTP.websocket_handle(socket, to: fn {:text, "ping"}, state -> {:reply, {:text, "pong"}, state} end) :ok = TestServer.HTTP.websocket_handle(socket, match: fn {:text, message}, _state -> message == "hi" end) + :ok = TestServer.HTTP.websocket_handle(socket, to: fn {:text, "ping"}, state -> {:reply, {:text, "pong"}, state} end) {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) @@ -119,7 +119,7 @@ test "WebSocketClient" do :ok = WebSocketClient.send(client, "ping") {:ok, "pong"} = WebSocketClient.receive(client) - :ok = WebSocketClient.send("hi") + :ok = WebSocketClient.send(client, "hi") {:ok, "hi"} = WebSocketClient.receive(client) :ok = TestServer.HTTP.websocket_info(socket, fn state -> {:reply, {:text, "ping"}, state} end) diff --git a/lib/test_server/http/instance.ex b/lib/test_server/http/instance.ex index 8fa1dc6..5c31781 100644 --- a/lib/test_server/http/instance.ex +++ b/lib/test_server/http/instance.ex @@ -337,7 +337,7 @@ defmodule TestServer.HTTP.Instance do Do not halt a connection. All requests have to be processed. # #{inspect(plug)} - #{Enum.map_join(stacktrace, "\n ", &Exception.format_stacktrace_entry/1)}")} + #{Enum.map_join(stacktrace, "\n ", &Exception.format_stacktrace_entry/1)} """ end @@ -356,15 +356,18 @@ defmodule TestServer.HTTP.Instance do defp run_routes(conn, state) do state.routes - |> Enum.find_index(fn + |> fetch_match_index([conn], fn %{suspended: true} -> false - %{match: match} -> try_run_match(match, [conn]) + %{suspended: false} -> true end) |> case do - nil -> + {:error, :not_found} -> {{:error, {:not_found, conn}}, state} - index -> + {:error, {error, stacktrace}} -> + {{:error, {error, stacktrace}}, state} + + {:ok, index} -> %{to: plug, stacktrace: stacktrace} = route = Enum.at(state.routes, index) result = @@ -381,8 +384,15 @@ defmodule TestServer.HTTP.Instance do end end - defp try_run_match(match, args) do - apply(match, args) + defp fetch_match_index(items, args, callback) do + items + |> Enum.find_index(fn %{match: match} = item -> + callback.(item) && (is_nil(match) || apply(match, args)) + end) + |> case do + nil -> {:error, :not_found} + index -> {:ok, index} + end rescue error -> {:error, {error, __STACKTRACE__}} end @@ -400,18 +410,19 @@ defmodule TestServer.HTTP.Instance do defp run_websocket_handlers({_instance, route_ref}, frame, websocket_state, state) do state.websocket_handlers - |> Enum.map(&{&1.route_ref == route_ref, &1}) - |> Enum.find_index(fn - {false, _} -> false - {true, %{suspended: true}} -> false - {true, %{match: nil}} -> true - {true, %{match: match}} -> try_run_match(match, [frame, websocket_state]) + |> fetch_match_index([frame, websocket_state], fn + %{route_ref: ^route_ref, suspended: true} -> false + %{route_ref: ^route_ref, suspended: false} -> true + %{route_ref: _other_route_ref, suspended: _any} -> false end) |> case do - nil -> + {:error, :not_found} -> {{:error, :not_found}, state} - index -> + {:error, {error, stacktrace}} -> + {{:error, {error, stacktrace}}, state} + + {:ok, index} -> %{to: handler, stacktrace: stacktrace} = Enum.at(state.websocket_handlers, index) result = try_run_websocket_handler(frame, websocket_state, stacktrace, handler) diff --git a/lib/test_server/http/server/httpd.ex b/lib/test_server/http/server/httpd.ex index 7353333..4452ce6 100644 --- a/lib/test_server/http/server/httpd.ex +++ b/lib/test_server/http/server/httpd.ex @@ -69,7 +69,7 @@ if Code.ensure_loaded?(:httpd) do end defp handle_websocket(%Plug.Conn{adapter: {_adapter, {:websocket, _opts, _data}}}) do - {:proceed, response: {422, ~c"WebSocket is not supported with httpd!"}} + {:proceed, response: {501, ~c"WebSocket is not supported with httpd!"}} end defp handle_websocket(conn) do @@ -119,7 +119,7 @@ if Code.ensure_loaded?(:httpd) do |> String.split("?") |> case do [request_path] -> {request_path, ""} - [request_path, qs] -> {request_path, qs} + [request_path, qs | _] -> {request_path, qs} end end @@ -130,13 +130,26 @@ if Code.ensure_loaded?(:httpd) do |> Kernel.||({~c"host", ~c""}) |> elem(1) |> to_string() - |> :binary.split(":") + |> case do + "[" <> _host = host -> :binary.split(host, "]:") + host -> :binary.split(host, ":") + end |> case do [host, port] -> - {Integer.parse(port), host} + [ + host, + case Integer.parse(port) do + {port, ""} -> port + _ -> nil + end + ] [host] -> - {nil, host} + [host, nil] + end + |> case do + ["[" <> host, port] -> {port, String.trim_trailing(host, "]")} + [host, port] -> {port, host} end end @@ -155,11 +168,11 @@ if Code.ensure_loaded?(:httpd) do @impl Plug.Conn.Adapter def send_resp(_data, status, headers, body) do - body = String.to_charlist(body) + body = IO.iodata_to_binary(body) headers = headers - |> Kernel.++([{"content-length", to_string(length(body))}]) + |> Kernel.++([{"content-length", to_string(byte_size(body))}]) |> Enum.map(&{String.to_charlist(elem(&1, 0)), String.to_charlist(elem(&1, 1))}) |> Kernel.++([{:code, status}]) diff --git a/lib/test_server/http/websocket.ex b/lib/test_server/http/websocket.ex index e57d4fd..d2ee54d 100644 --- a/lib/test_server/http/websocket.ex +++ b/lib/test_server/http/websocket.ex @@ -59,7 +59,7 @@ defmodule TestServer.HTTP.WebSocket do """ #{message} The following websocket handlers have been processed: - #{Instance.format_websocket_handlers(websocket_handlers)}" + #{Instance.format_websocket_handlers(websocket_handlers)} """ end end diff --git a/mix.exs b/mix.exs index 05c3498..ba451b6 100644 --- a/mix.exs +++ b/mix.exs @@ -46,7 +46,7 @@ defmodule TestServer.MixProject do {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:ssl_verify_fun, ">= 0.0.0", only: [:test]}, {:credo, ">= 0.0.0", only: [:dev, :test]}, - {:websockex, "~> 0.4.3", only: [:test]}, + {:websockex, "~> 0.5.1", only: [:test]}, {:finch, ">= 0.0.0", only: [:test]}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] diff --git a/mix.lock b/mix.lock index 0d2026a..fbe55b5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,34 +1,34 @@ %{ - "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, - "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, + "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, "x509": {:hex, :x509, "0.9.2", "a75aa605348abd905990f3d2dc1b155fcde4e030fa2f90c4a91534405dce0f6e", [:mix], [], "hexpm", "4c5ede75697e565d4b0f5be04c3b71bb1fd3a090ea243af4bd7dae144e48cfc7"}, } diff --git a/test/http_server/bandit/adapter_test.exs b/test/http/server/bandit/adapter_test.exs similarity index 98% rename from test/http_server/bandit/adapter_test.exs rename to test/http/server/bandit/adapter_test.exs index 7be8795..48757e9 100644 --- a/test/http_server/bandit/adapter_test.exs +++ b/test/http/server/bandit/adapter_test.exs @@ -1,4 +1,4 @@ -defmodule TestServer.HTTP.Server.Bandit.HTTP2AdapterTest do +defmodule TestServer.HTTP.Server.Bandit.AdapterTest do use ExUnit.Case setup do diff --git a/test/http/server/httpd_test.exs b/test/http/server/httpd_test.exs new file mode 100644 index 0000000..d36a2ef --- /dev/null +++ b/test/http/server/httpd_test.exs @@ -0,0 +1,184 @@ +defmodule TestServer.HTTP.Server.HttpdTest do + use ExUnit.Case + + setup context do + {:ok, _instance} = + TestServer.HTTP.start( + scheme: :http, + http_server: {TestServer.HTTP.Server.Httpd, []}, + ipfamily: context[:ipfamily] || :inet + ) + + :ok + end + + describe "conn/1" do + test "with no host header" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + refute List.keyfind(conn.req_headers, "host", 0) + assert conn.host == "" + refute conn.port + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/"), [], version: ~c"HTTP/1.0") + end + + test "with host header without port" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.host == "localhost" + refute conn.port + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/"), [{"Host", "localhost"}]) + end + + test "with host header with invalid port" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.host == "localhost" + refute conn.port + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/"), [{"Host", "localhost:invalid"}]) + end + + test "with host header with extra colon" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.host == "localhost" + refute conn.port + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/"), [{"Host", "localhost:8080:8081"}]) + end + + @tag ipfamily: :inet6 + test "with IPv6 host header" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.host == "::1" + assert conn.port == 8080 + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/"), [{"Host", "[::1]:8080"}]) + end + + @tag ipfamily: :inet6 + test "with IPv6 host header without port" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.host == "::1" + refute conn.port + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/"), [{"Host", "[::1]"}]) + end + + test "with extra query segment" do + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.query_string == "foo=bar" + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = + http_request(:get, TestServer.HTTP.url("/?foo=bar?foo=baz")) + end + + test "builds conn" do + url = TestServer.HTTP.url("/a//1?foo=bar") + %{port: port} = URI.parse(url) + + assert :ok = + TestServer.HTTP.add("/a/1", + to: fn conn -> + assert conn.host == "localhost" + assert conn.method == "GET" + assert conn.path_info == ["a", "1"] + assert conn.port == port + assert conn.remote_ip == {127, 0, 0, 1} + assert conn.query_string == "foo=bar" + + assert List.keyfind(conn.req_headers, "host", 0) == + {"host", "localhost:#{port}"} + + assert conn.request_path + refute conn.scheme + + Plug.Conn.resp(conn, 200, "OK") + end + ) + + assert {:ok, {200, _headers, "OK"}} = http_request(:get, url) + end + end + + describe "send_resp/4" do + test "with UTF-8 content" do + body = "héllo 👋" + + assert :ok = + TestServer.HTTP.add("/", + to: fn conn -> + Plug.Conn.resp(conn, 200, body) + end + ) + + assert {:ok, {200, headers, ^body}} = http_request(:get, TestServer.HTTP.url("/")) + assert List.keyfind(headers, "content-length", 0) == {"content-length", "11"} + end + end + + defp http_request(method, url, headers \\ [], opts \\ []) do + url = String.to_charlist(url) + + headers = + Enum.map(headers, &{String.to_charlist(elem(&1, 0)), String.to_charlist(elem(&1, 1))}) + + case :httpc.request(method, {url, headers}, opts, []) do + {:ok, {{_, status_code, _}, response_headers, body}} -> + response_headers = + Enum.map(response_headers, &{to_string(elem(&1, 0)), to_string(elem(&1, 1))}) + + {:ok, {status_code, response_headers, IO.iodata_to_binary(body)}} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/test/test_server/http_test.exs b/test/test_server/http_test.exs index b06ce0d..6d6d0dd 100644 --- a/test/test_server/http_test.exs +++ b/test/test_server/http_test.exs @@ -74,7 +74,7 @@ defmodule TestServer.HTTPTest do http1_request(TestServer.HTTP.url("/"), http_opts: http_opts.(valid_cacerts)) end - test "starts in IPv6-only mode`" do + test "starts in IPv6-only mode" do {:ok, instance} = TestServer.HTTP.start(ipfamily: :inet6) options = TestServer.HTTP.Instance.get_options(instance) @@ -269,6 +269,7 @@ defmodule TestServer.HTTPTest do test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) + assert :ok = TestServer.HTTP.add("/") assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/path")) end @@ -283,8 +284,8 @@ defmodule TestServer.HTTPTest do test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.HTTP.add("/", via: :post) + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) end end @@ -298,8 +299,8 @@ defmodule TestServer.HTTPTest do test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.HTTP.add("/") + assert :ok = TestServer.HTTP.add("/") assert {:ok, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/?a=1")) end @@ -323,7 +324,7 @@ defmodule TestServer.HTTPTest do "did not receive a request for these routes before the test ended:" end - test "with callback plug" do + test "with `:to` plug" do defmodule ToPlug do def init(opts), do: opts @@ -334,7 +335,7 @@ defmodule TestServer.HTTPTest do assert http1_request(TestServer.HTTP.url("/")) == {:ok, to_string(ToPlug)} end - test "with callback function raising exception" do + test "when `:to` function raises exception" do defmodule ToFunctionRaiseTest do use ExUnit.Case @@ -351,7 +352,7 @@ defmodule TestServer.HTTPTest do assert io =~ "anonymous fn/1 in TestServer.HTTPTest.ToFunctionRaiseTest" end - test "with callback function halts" do + test "when `:to` function halts conn" do defmodule ToFunctionHaltsTest do use ExUnit.Case @@ -367,7 +368,7 @@ defmodule TestServer.HTTPTest do "Do not halt a connection. All requests have to be processed." end - test "with callback function" do + test "with `:to` function" do assert :ok = TestServer.HTTP.add("/", to: fn conn -> Plug.Conn.resp(conn, 200, "function called") end @@ -376,7 +377,28 @@ defmodule TestServer.HTTPTest do assert http1_request(TestServer.HTTP.url("/")) == {:ok, "function called"} end - test "with match function" do + test "when `:match` function raises exception" do + defmodule MatchFunctionRaiseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) + + assert :ok = + TestServer.HTTP.add("/", + match: fn _conn -> raise "boom" end + ) + + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "(RuntimeError) boom" + assert io =~ "anonymous fn/1 in TestServer.HTTPTest.MatchFunctionRaiseTest" + end + + test "with `:match` function" do assert :ok = TestServer.HTTP.add("/", match: fn @@ -388,7 +410,7 @@ defmodule TestServer.HTTPTest do assert {:ok, _} = http1_request(TestServer.HTTP.url("/ignore") <> "?a=1") end - test "with :via method" do + test "with `:via` option" do assert :ok = TestServer.HTTP.add("/", via: :get) assert :ok = TestServer.HTTP.add("/", via: :post) assert {:ok, _} = http1_request(TestServer.HTTP.url("/")) @@ -404,7 +426,7 @@ defmodule TestServer.HTTPTest do assert {:ok, "HTTP/2"} = http2_request(TestServer.HTTP.url()) end - test "with HTTP/2 with plug function" do + test "with HTTP/2 with `:to` function" do {:ok, _instance} = TestServer.HTTP.start(scheme: :https) assert :ok = @@ -454,7 +476,7 @@ defmodule TestServer.HTTPTest do assert http1_request(TestServer.HTTP.url("/")) == {:ok, to_string(ModulePlug)} end - test "when plug errors" do + test "when plug function raises exception" do defmodule PlugFunctionRaiseTest do use ExUnit.Case @@ -471,7 +493,7 @@ defmodule TestServer.HTTPTest do assert io =~ "anonymous fn/1 in TestServer.HTTPTest.PlugFunctionRaiseTest" end - test "when plug function halts" do + test "when plug function halts conn" do defmodule PlugFunctionHaltsTest do use ExUnit.Case @@ -536,7 +558,44 @@ defmodule TestServer.HTTPTest do end # Httpd adapter has no WebSocket support - unless System.get_env("HTTP_SERVER") == "Httpd" do + if System.get_env("HTTP_SERVER") == "Httpd" do + describe "websocket_init/3" do + test "returns 501" do + # Test with WebSockex client + assert {:ok, _socket} = TestServer.HTTP.websocket_init("/ws") + + assert {:error, %WebSockex.RequestError{code: 501}} = + WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + + request = + { + String.to_charlist(TestServer.HTTP.url("/ws")), + [ + {~c"connection", ~c"Upgrade"}, + {~c"upgrade", ~c"websocket"}, + {~c"sec-websocket-version", ~c"13"}, + {~c"sec-websocket-key", + :crypto.strong_rand_bytes(16) |> Base.encode64() |> String.to_charlist()} + ] + } + + # Test the body response + assert {:ok, _socket} = TestServer.HTTP.websocket_init("/ws") + assert {:ok, {{_, 501, _}, _headers, body}} = :httpc.request(:get, request, [], []) + assert to_string(body) == "WebSocket is not supported with httpd!" + end + end + + describe "websocket_handle/3" do + # No tests for `websocket_handle/3` as `websocket_init/3` always returns + # a 501 response, so no socket is ever initialized to be handled. + end + + describe "websocket_info/2" do + # No tests for `websocket_info/2` as `websocket_init/3` always returns + # a 501 response, so no socket is ever initialized to be handled. + end + else describe "websocket_init/3" do test "when instance not running" do {:ok, instance} = TestServer.HTTP.start() @@ -633,6 +692,7 @@ defmodule TestServer.HTTPTest do test "fails" do assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert {:ok, _client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = TestServer.HTTP.websocket_handle(socket) end @@ -648,10 +708,9 @@ defmodule TestServer.HTTPTest do test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert {:ok, _socket} = TestServer.HTTP.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert {:ok, msg} = WebSocketClient.send_message(client, "ping") assert msg =~ "received an unexpected WebSocket frame" end @@ -668,13 +727,11 @@ defmodule TestServer.HTTPTest do test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = TestServer.HTTP.websocket_handle(socket) + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert WebSocketClient.send_message(client, "ping") == {:ok, "ping"} - assert {:ok, msg} = WebSocketClient.send_message(client, "ping") assert msg =~ "received an unexpected WebSocket frame" assert msg =~ "The following websocket handlers have been processed:" @@ -686,20 +743,20 @@ defmodule TestServer.HTTPTest do assert io =~ "The following websocket handlers have been processed:" end - test "with callback function raising exception" do + test "when `:to` function raises exception" do defmodule WebSocketHandleToFunctionRaiseTest do use ExUnit.Case test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = TestServer.HTTP.websocket_handle(socket, to: fn _frame, _state -> raise "boom" end ) + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert {:ok, msg} = WebSocketClient.send_message(client, "ping") assert msg =~ "(RuntimeError) boom" end @@ -710,21 +767,20 @@ defmodule TestServer.HTTPTest do assert io =~ "anonymous fn/2 in TestServer.HTTPTest.WebSocketHandleToFunctionRaiseTest" end - test "with callback function with invalid response" do + test "when `:to` function returns invalid response" do defmodule WebSocketHandleToFunctionInvalidResponseTest do use ExUnit.Case test "fails" do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = TestServer.HTTP.websocket_handle(socket, to: fn _frame, _state -> :invalid end ) + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert {:ok, msg} = WebSocketClient.send_message(client, "ping") assert msg =~ "(RuntimeError) Invalid callback response, got: :invalid." end @@ -734,24 +790,22 @@ defmodule TestServer.HTTPTest do assert io =~ " (RuntimeError) Invalid callback response, got: :invalid." end - test "with callback function" do + test "with `:to` function" do assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = TestServer.HTTP.websocket_handle(socket, to: fn {:text, _any}, state -> {:reply, {:text, "function called"}, state} end ) + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert WebSocketClient.send_message(client, "ping") == {:ok, "function called"} end - test "with match function" do + test "with `:match` function" do assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws", init_state: %{custom: true}) - assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) - assert :ok = TestServer.HTTP.websocket_handle(socket, match: fn _frame, %{custom: true} -> @@ -759,8 +813,33 @@ defmodule TestServer.HTTPTest do end ) + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert WebSocketClient.send_message(client, "hello") == {:ok, "hello"} end + + test "when `:match` function raises exception" do + defmodule WebSocketHandleMatchFunctionRaiseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + + assert :ok = + TestServer.HTTP.websocket_handle(socket, + match: fn _frame, _state -> raise "boom" end + ) + + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + assert {:ok, msg} = WebSocketClient.send_message(client, "ping") + assert msg =~ "(RuntimeError) boom" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "(RuntimeError) boom" + assert io =~ "anonymous fn/2 in TestServer.HTTPTest.WebSocketHandleMatchFunctionRaiseTest" + end end describe "websocket_info/2" do @@ -784,8 +863,8 @@ defmodule TestServer.HTTPTest do {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) - assert :ok = TestServer.HTTP.websocket_info(socket, fn _state -> :invalid end) + assert :ok = TestServer.HTTP.websocket_info(socket, fn _state -> :invalid end) assert {:ok, message} = WebSocketClient.receive_message(client) assert message =~ "(RuntimeError) Invalid callback response, got: :invalid." end @@ -854,8 +933,8 @@ defmodule TestServer.HTTPTest do :httpc.request(:get, {url, []}, httpc_http_opts, httpc_opts) end |> case do - {:ok, {{_, 200, _}, _headers, body}} -> {:ok, to_string(body)} - {:ok, {{_, _, _}, _headers, body}} -> {:error, to_string(body)} + {:ok, {{_, 200, _}, _headers, body}} -> {:ok, IO.iodata_to_binary(body)} + {:ok, {{_, _, _}, _headers, body}} -> {:error, IO.iodata_to_binary(body)} {:error, error} -> {:error, error} end end