From 634d3540889b6bfcc5f4cb5ea9d1d83e8e4c542b Mon Sep 17 00:00:00 2001 From: Richard Ash Date: Wed, 4 Mar 2026 17:37:35 -0800 Subject: [PATCH 1/3] Refactor TestServer to TestServer.HTTP --- CHANGELOG.md | 4 + README.md | 66 +- lib/test_server.ex | 627 ------------------ lib/test_server/http.ex | 579 ++++++++++++++++ lib/test_server/{ => http}/instance.ex | 43 +- lib/test_server/{ => http}/plug.ex | 4 +- .../{http_server.ex => http/server.ex} | 22 +- .../{http_server => http/server}/bandit.ex | 20 +- .../server}/bandit/adapter.ex | 2 +- .../server}/bandit/plug.ex | 10 +- .../{http_server => http/server}/httpd.ex | 16 +- .../server}/plug_cowboy.ex | 24 +- lib/test_server/{ => http}/websocket.ex | 4 +- lib/test_server/instance_manager.ex | 78 ++- mix.exs | 16 +- ...ttp2_adapter_test.exs => adapter_test.exs} | 31 +- test/test_helper.exs | 2 +- .../http_test.exs} | 458 +++++++------ 18 files changed, 1033 insertions(+), 973 deletions(-) create mode 100644 lib/test_server/http.ex rename lib/test_server/{ => http}/instance.ex (92%) rename lib/test_server/{ => http}/plug.ex (97%) rename lib/test_server/{http_server.ex => http/server.ex} (89%) rename lib/test_server/{http_server => http/server}/bandit.ex (81%) rename lib/test_server/{http_server => http/server}/bandit/adapter.ex (98%) rename lib/test_server/{http_server => http/server}/bandit/plug.ex (67%) rename lib/test_server/{http_server => http/server}/httpd.ex (93%) rename lib/test_server/{http_server => http/server}/plug_cowboy.ex (83%) rename lib/test_server/{ => http}/websocket.ex (97%) rename test/http_server/bandit/{http2_adapter_test.exs => adapter_test.exs} (71%) rename test/{test_server_test.exs => test_server/http_test.exs} (57%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d9b57..a276cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- BREAKING CHANGE: Moved HTTP and WebSocket API to `TestServer.HTTP` and server adapters to `TestServer.HTTP.Server.*` + ## v0.1.22 (2026-03-05) Requires Elixir 1.14 or higher. diff --git a/README.md b/README.md index 9b38aeb..b332994 100644 --- a/README.md +++ b/README.md @@ -17,40 +17,42 @@ Features: ## Usage -Add route request expectations with `TestServer.add/2`: +### HTTP + +Add route request expectations with `TestServer.HTTP.add/2`: ```elixir test "fetch_url/0" do # The test server will autostart the current test server, if not already running - TestServer.add("/", via: :get) + TestServer.HTTP.add("/", via: :get) # The URL is derived from the current test server instance - Application.put_env(:my_app, :fetch_url, TestServer.url()) + Application.put_env(:my_app, :fetch_url, TestServer.HTTP.url()) {:ok, "HTTP"} = MyModule.fetch_url() end ``` -`TestServer.add/2` can route a request to an anonymous function or plug with `:to` option. +`TestServer.HTTP.add/2` can route a request to an anonymous function or plug with `:to` option. ```elixir -TestServer.add("/", to: fn conn -> +TestServer.HTTP.add("/", to: fn conn -> Plug.Conn.send_resp(conn, 200, "OK") end) -TestServer.add("/", to: MyPlug) +TestServer.HTTP.add("/", to: MyPlug) ``` The method listened to can be defined with `:via` option. By default any method is matched. ```elixir -TestServer.add("/", via: :post) +TestServer.HTTP.add("/", via: :post) ``` A custom match function can be set with `:match` option: ```elixir -TestServer.add("/", match: fn +TestServer.HTTP.add("/", match: fn %{params: %{"a" => "1"}} = _conn -> true _conn -> false end) @@ -59,68 +61,68 @@ end) When a route is matched it'll be removed from active routes list. The route will be triggered in the order they were added: ```elixir -TestServer.add("/", via: :get, to: &Plug.Conn.send_resp(&1, 200, "first")) -TestServer.add("/", via: :get, to: &Plug.Conn.send_resp(&1, 200, "second")) +TestServer.HTTP.add("/", via: :get, to: &Plug.Conn.send_resp(&1, 200, "first")) +TestServer.HTTP.add("/", via: :get, to: &Plug.Conn.send_resp(&1, 200, "second")) {:ok, "first"} = fetch_request() {:ok, "second"} = fetch_request() ``` -Plugs can be added to the pipeline with `TestServer.plug/1`. All plugs will run before any routes are matched. `Plug.Conn.fetch_query_params/1` is used if no plugs are set. +Plugs can be added to the pipeline with `TestServer.HTTP.plug/1`. All plugs will run before any routes are matched. `Plug.Conn.fetch_query_params/1` is used if no plugs are set. ```elixir -TestServer.plug(fn conn -> +TestServer.HTTP.plug(fn conn -> Plug.Conn.fetch_query_params(conn) end) -TestServer.plug(fn conn -> +TestServer.HTTP.plug(fn conn -> {:ok, body, _conn} = Plug.Conn.read_body(conn, []) %{conn | body_params: Jason.decode!(body)} end) -TestServer.plug(MyPlug) +TestServer.HTTP.plug(MyPlug) ``` ### HTTPS -By default the test server is set up to serve plain HTTP. HTTPS can be enabled with the `:scheme` option when calling `TestServer.start/1`. +By default the test server is set up to serve plain HTTP. HTTPS can be enabled with the `:scheme` option when calling `TestServer.HTTP.start/1`. Custom SSL certificates can also be used by defining the `:tls` option: ```elixir -TestServer.start(scheme: :https, tls: [keyfile: key, certfile: cert]) +TestServer.HTTP.start(scheme: :https, tls: [keyfile: key, certfile: cert]) ``` A self-signed certificate suite is automatically generated if you don't set the `:tls` options: ```elixir -TestServer.start(scheme: :https) +TestServer.HTTP.start(scheme: :https) req_opts = [ connect_options: [ - transport_opts: [cacerts: TestServer.x509_suite().cacerts], + transport_opts: [cacerts: TestServer.HTTP.x509_suite().cacerts], protocols: [:http2] ] ] assert {:ok, %Req.Response{status: 200, body: "HTTP/2"}} = - Req.get(TestServer.url(), req_opts) + Req.get(TestServer.HTTP.url(), req_opts) ``` ### WebSocket -WebSocket endpoint can be set up by calling `TestServer.websocket_init/2`. By default, `TestServer.websocket_handle/2` will echo the message received. Messages can be send from the test server with `TestServer.websocket_info/2`. +WebSocket endpoint can be set up by calling `TestServer.HTTP.websocket_init/2`. By default, `TestServer.HTTP.websocket_handle/2` will echo the message received. Messages can be send from the test server with `TestServer.HTTP.websocket_info/2`. ```elixir test "WebSocketClient" do - {:ok, socket} = TestServer.websocket_init("/ws") + {:ok, socket} = TestServer.HTTP.websocket_init("/ws") - :ok = TestServer.websocket_handle(socket) - :ok = TestServer.websocket_handle(socket, to: fn {:text, "ping"}, state -> {:reply, {:text, "pong"}, state} end) - :ok = TestServer.websocket_handle(socket, match: fn {:text, message}, _state -> message == "hi" end) + :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, client} = WebSocketClient.start_link(TestServer.url("/ws")) + {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) :ok = WebSocketClient.send(client, "hello") {:ok, "hello"} = WebSocketClient.receive(client) @@ -131,7 +133,7 @@ test "WebSocketClient" do :ok = WebSocketClient.send("hi") {:ok, "hi"} = WebSocketClient.receive(client) - :ok = TestServer.websocket_info(socket, fn state -> {:reply, {:text, "ping"}, state} end) + :ok = TestServer.HTTP.websocket_info(socket, fn state -> {:reply, {:text, "ping"}, state} end) {:ok, "ping"} = WebSocketClient.receive(client) end ``` @@ -140,23 +142,23 @@ end ### HTTP Server Adapter -TestServer supports `Bandit`, `Plug.Cowboy`, and `:httpd` out of the box. The HTTP adapter will be selected in this order depending which is available in the dependencies. You can also explicitly set the http server in the configuration when calling `TestServer.start/1`: +TestServer supports `Bandit`, `Plug.Cowboy`, and `:httpd` out of the box. The HTTP adapter will be selected in this order depending which is available in the dependencies. You can also explicitly set the http server in the configuration when calling `TestServer.HTTP.start/1`: ```elixir -TestServer.start(http_server: {TestServer.HTTPServer.Bandit, []}) +TestServer.HTTP.start(http_server: {TestServer.HTTPServer.Bandit, []}) ``` You can create your own plug based HTTP Server Adapter by using the `TestServer.HTTPServer` behaviour. ### IPv6 -Use the `:ipfamily` option to test with IPv6 when starting the test server with `TestServer.start/1`: +Use the `:ipfamily` option to test with IPv6 when starting the test server with `TestServer.HTTP.start/1`: ```elixir -TestServer.start(ipfamily: :inet6) +TestServer.HTTP.start(ipfamily: :inet6) assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1} @@ -174,7 +176,7 @@ Add `test_server` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:test_server, "~> 0.1.22", only: [:test]} + {:test_server, "~> 0.1", only: [:test]} ] end ``` diff --git a/lib/test_server.ex b/lib/test_server.ex index b37bfd1..16bdf71 100644 --- a/lib/test_server.ex +++ b/lib/test_server.ex @@ -5,633 +5,6 @@ defmodule TestServer do |> String.split("") |> Enum.fetch!(1) - alias Plug.Conn - alias TestServer.{Instance, InstanceManager} - @type instance :: pid() - @type route :: reference() @type stacktrace :: list() - @type websocket_socket :: {instance(), route()} - @type websocket_frame :: {atom(), any()} - @type websocket_state :: any() - @type websocket_reply :: - {:reply, websocket_frame(), websocket_state()} | {:ok, websocket_state()} - - @doc """ - Start a test server instance. - - The instance will be terminated when the test case finishes. - - ## Options - - * `:port` - integer of port number, defaults to random port - that can be opened; - * `:scheme` - an atom for the http scheme. Defaults to `:http`; - * `:http_server` - HTTP server configuration. Defaults to - `{TestServer.HTTPServer.Bandit, []}`, - `{TestServer.HTTPServer.Plug.Cowboy, []}`, or - `{TestServer.HTTPServer.Httpd, []}` depending on which web server is - available in the project dependencies; - * `:tls` - Passthru options for TLS configuration handled by - the webserver; - * `:ipfamily` - The IP address type to use, either `:inet` or - `:inet6`. Defaults to `:inet`; - - ## Examples - - TestServer.start( - scheme: :https, - ipfamily: :inet6, - http_server: {TestServer.HTTPServer.Bandit, [ip: :any]} - ) - - TestServer.add("/", - to: fn conn -> - assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1} - - Plug.Conn.resp(conn, 200, to_string(Plug.Conn.get_http_protocol(conn))) - end - ) - - req_opts = [ - connect_options: [ - transport_opts: [cacerts: TestServer.x509_suite().cacerts], - protocols: [:http2] - ] - ] - - assert {:ok, %Req.Response{status: 200, body: "HTTP/2"}} = - Req.get(TestServer.url(), req_opts) - """ - @spec start(keyword()) :: {:ok, pid()} - def start(options \\ []) do - case ExUnit.fetch_test_supervisor() do - {:ok, sup} -> - start_with_ex_unit(options, sup) - - :error -> - raise ArgumentError, "can only be called in a test process" - end - end - - defp start_with_ex_unit(options, _sup) do - [_first_module_entry | stacktrace] = get_stacktrace() - - case InstanceManager.start_instance(self(), stacktrace, options) do - {:ok, instance} -> - put_ex_unit_on_exit_callback(instance) - - {:ok, instance} - - {:error, error} -> - raise_start_failure({:error, error}) - end - end - - defp put_ex_unit_on_exit_callback(instance) do - ExUnit.Callbacks.on_exit(fn -> - case Process.alive?(instance) do - true -> - verify_routes!(instance) - verify_websocket_handlers!(instance) - stop(instance) - - false -> - :ok - end - end) - end - - defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do - raise_start_failure({:error, reason}) - end - - defp raise_start_failure({:error, error}) do - raise """ - EXIT when starting #{inspect(__MODULE__.Instance)}: - - #{Exception.format_exit(error)} - """ - end - - defp verify_routes!(instance) do - instance - |> Instance.routes() - |> Enum.reject(& &1.suspended) - |> case do - [] -> - :ok - - active_routes -> - raise """ - #{Instance.format_instance(instance)} did not receive a request for these routes before the test ended: - - #{Instance.format_routes(active_routes)} - """ - end - end - - defp verify_websocket_handlers!(instance) do - instance - |> Instance.websocket_handlers() - |> Enum.reject(& &1.suspended) - |> case do - [] -> - :ok - - active_websocket_handlers -> - raise """ - #{Instance.format_instance(instance)} did not receive a frame for these websocket handlers before the test ended: - - #{Instance.format_websocket_handlers(active_websocket_handlers)} - """ - end - end - - @doc """ - Shuts down the current test server. - - ## Examples - - TestServer.start() - url = TestServer.url() - TestServer.stop() - - assert {:error, %Req.TransportError{}} = Req.get(url, retry: false) - """ - @spec stop() :: :ok | {:error, term()} - def stop, do: stop(fetch_instance!()) - - @doc """ - Shuts down a test server instance. - """ - @spec stop(pid()) :: :ok | {:error, term()} - def stop(instance) do - instance_alive!(instance) - - InstanceManager.stop_instance(instance) - end - - defp instance_alive!(instance) do - case Process.alive?(instance) do - true -> :ok - false -> raise "#{Instance.format_instance(instance)} is not running" - end - end - - @doc """ - Gets current test server instance if running. - - ## Examples - - refute TestServer.get_instance() - - {:ok, instance} = TestServer.start() - - assert TestServer.get_instance() == instance - """ - @spec get_instance() :: pid() | nil - def get_instance do - case fetch_instance(false) do - {:ok, instance} -> instance - :error -> nil - end - end - - @spec url() :: binary() - def url, do: url("") - - @spec url(binary() | keyword() | pid()) :: binary() - def url(uri) when is_binary(uri), do: url(uri, []) - def url(opts) when is_list(opts), do: url("", opts) - def url(instance) when is_pid(instance), do: url(instance, "", []) - - @doc """ - Produces a URL for current test server. - - ## Options - * `:host` - binary host value, it'll be added to inet for IP `127.0.0.1` and `::1`, defaults to `"localhost"`; - - ## Examples - - TestServer.start(port: 4444) - - assert TestServer.url() == "http://localhost:4444" - assert TestServer.url("/test") == "http://localhost:4444/test" - assert TestServer.url(host: "example.com") == "http://example.com:4444" - """ - @spec url(binary(), keyword()) :: binary() - def url(uri, opts) when is_binary(uri), do: url(fetch_instance!(), uri, opts) - - @spec url(pid(), binary()) :: binary() - def url(instance, uri) when is_pid(instance), do: url(instance, uri, []) - - @doc """ - Produces a URL for a test server instance. - - See `url/2` for options. - """ - @spec url(pid(), binary(), keyword()) :: binary() - def url(instance, uri, opts) do - instance_alive!(instance) - - unless is_nil(opts[:host]) or is_binary(opts[:host]), - do: raise("Invalid host, got: #{inspect(opts[:host])}") - - domain = maybe_enable_host(opts[:host]) - options = Instance.get_options(instance) - - "#{Keyword.fetch!(options, :scheme)}://#{domain}:#{Keyword.fetch!(options, :port)}#{uri}" - end - - defp fetch_instance! do - case fetch_instance() do - :error -> raise "No current #{inspect(Instance)} running" - {:ok, instance} -> instance - end - end - - defp fetch_instance(function_accepts_instance_arg \\ true) do - case InstanceManager.get_by_caller(self()) do - nil -> - :error - - [instance] -> - {:ok, instance} - - [_instance | _rest] = instances -> - [{m, f, a, _} | _stacktrace] = get_stacktrace() - - message = - case function_accepts_instance_arg do - true -> - "Multiple #{inspect(Instance)}'s running, please pass instance to `#{inspect(m)}.#{f}/#{a}`." - - false -> - "Multiple #{inspect(Instance)}'s running." - end - - formatted_instances = - instances - |> Enum.map(&{&1, Instance.get_options(&1)}) - |> Enum.with_index() - |> Enum.map_join("\n\n", fn {{instance, options}, index} -> - """ - ##{index + 1}: #{Instance.format_instance(instance)} - #{Enum.map_join(options[:stacktrace], "\n ", &Exception.format_stacktrace_entry/1)}")} - """ - end) - - raise """ - #{message} - - #{formatted_instances} - """ - end - end - - defp maybe_enable_host(nil), do: "localhost" - - defp maybe_enable_host(host) do - :inet_db.set_lookup([:file, :dns]) - :inet_db.add_host({127, 0, 0, 1}, [String.to_charlist(host)]) - :inet_db.add_host({0, 0, 0, 0, 0, 0, 0, 1}, [String.to_charlist(host)]) - - host - end - - @spec add(binary()) :: :ok - def add(uri), do: add(uri, []) - - @doc """ - Adds a route to the current test server. - - Matching routes are handled FIFO (first in, first out). Any requests to - routes not added to the TestServer and any routes that isn't matched will - raise an error in the test case. - - ## Options - - * `:via` - matches the route against some specific HTTP method(s) - specified as an atom, like `:get` or `:put`, or a list, like `[:get, :post]`. - * `:match` - an anonymous function that will be called to see if a - route matches, defaults to matching with arguments of uri and `:via` option. - * `:to` - a Plug or anonymous function that will be called when the - route matches, defaults to return the http scheme. - - ## Examples - - TestServer.add("/", - match: fn conn -> - conn.query_params["a"] == "1" - end, - to: fn conn -> - Plug.Conn.resp(conn, 200, "a = 1") - end) - - TestServer.add("/", to: &Plug.Conn.resp(&1, 200, "PONG")) - TestServer.add("/") - - assert {:ok, %Req.Response{status: 200, body: "PONG"}} = Req.get(TestServer.url("/")) - assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} = Req.post(TestServer.url("/")) - assert {:ok, %Req.Response{status: 200, body: "a = 1"}} = Req.get(TestServer.url("/?a=1")) - """ - @spec add(binary(), keyword()) :: :ok - def add(uri, options) when is_binary(uri) do - {:ok, instance} = autostart() - - add(instance, uri, options) - end - - @spec add(pid(), binary()) :: :ok - def add(instance, uri) when is_pid(instance) and is_binary(uri), do: add(instance, uri, []) - - @doc """ - Adds a route to a test server instance. - - See `add/2` for options. - """ - @spec add(pid(), binary(), keyword()) :: :ok - def add(instance, uri, options) when is_pid(instance) and is_binary(uri) and is_list(options) do - options = Keyword.put_new(options, :to, &default_response_handler/1) - - {:ok, _route} = register_route(instance, uri, options) - - :ok - end - - defp register_route(instance, uri, options) do - instance_alive!(instance) - - [_register_route, _first_module_entry | stacktrace] = get_stacktrace() - - Instance.register(instance, {:plug_router_to, {uri, options, stacktrace}}) - end - - defp get_stacktrace do - {:current_stacktrace, [{Process, :info, _, _} | stacktrace]} = - Process.info(self(), :current_stacktrace) - - first_module_entry = - stacktrace - |> Enum.reverse() - |> Enum.find(fn {mod, _, _, _} -> mod == __MODULE__ end) - - [first_module_entry] ++ prune_stacktrace(stacktrace) - end - - # Remove TestServer - defp prune_stacktrace([{__MODULE__, _, _, _} | t]), do: prune_stacktrace(t) - - # Assertions can pop-up in the middle of the stack - defp prune_stacktrace([{ExUnit.Assertions, _, _, _} | t]), do: prune_stacktrace(t) - - # As soon as we see a Runner, it is time to ignore the stacktrace - defp prune_stacktrace([{ExUnit.Runner, _, _, _} | _]), do: [] - - # All other cases - defp prune_stacktrace([h | t]), do: [h | prune_stacktrace(t)] - defp prune_stacktrace([]), do: [] - - defp autostart do - case fetch_instance() do - :error -> start() - {:ok, instance} -> {:ok, instance} - end - end - - defp default_response_handler(conn) do - Conn.resp(conn, 200, to_string(Conn.get_http_protocol(conn))) - end - - @doc """ - Adds a plug to the current test server. - - This plug will be called for all requests before route is matched. - - ## Examples - - TestServer.plug(MyPlug) - - TestServer.plug(fn conn -> - {:ok, body, _conn} = Plug.Conn.read_body(conn, []) - - %{conn | body_params: Jason.decode!(body)} - end) - """ - @spec plug(module() | function()) :: :ok - def plug(plug) do - {:ok, instance} = autostart() - - plug(instance, plug) - end - - @doc """ - Adds a route to a test server instance. - - See `plug/1` for more. - """ - @spec plug(pid(), module() | function()) :: :ok - def plug(instance, plug) do - [_first_module_entry | stacktrace] = get_stacktrace() - - {:ok, _plug} = Instance.register(instance, {:plug, {plug, stacktrace}}) - - :ok - end - - @doc """ - Fetches the generated x509 suite for the current test server. - - ## Examples - - TestServer.start(scheme: :https) - TestServer.add("/") - - cacerts = TestServer.x509_suite().cacerts - req_opts = [connect_options: [transport_opts: [cacerts: cacerts]]] - - assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} = - Req.get(TestServer.url(), req_opts) - """ - @spec x509_suite() :: term() - def x509_suite, do: x509_suite(fetch_instance!()) - - @doc """ - Fetches the generated x509 suite for a test server instance. - - See `x509_suite/0` for more. - """ - @spec x509_suite(pid()) :: term() - def x509_suite(instance) do - instance_alive!(instance) - - options = Instance.get_options(instance) - - cond do - not (options[:scheme] == :https) -> - raise "#{Instance.format_instance(instance)} is not running with `[scheme: :https]` option" - - not Keyword.has_key?(options, :x509_suite) -> - raise "#{Instance.format_instance(instance)} is running with custom SSL" - - true -> - options[:x509_suite] - end - end - - @spec websocket_init(binary()) :: {:ok, websocket_socket()} | {:error, term()} - def websocket_init(uri) when is_binary(uri), do: websocket_init(uri, []) - - @doc """ - Adds a websocket route to current test server. - - The `:to` option can be overridden the same way as for `add/2`, and will be - called during the HTTP handshake. If the `conn.state` is `:unset` the - websocket will be initiated otherwise response is returned as-is. - - ## Options - - Takes the same options as `add/2`, except `:to`. - - ## Examples - - {:ok, socket} = TestServer.websocket_init("/ws") - TestServer.websocket_handle(socket) - - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) - assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"} - - `:via` and `:match` are called during the HTTP handshake: - - TestServer.websocket_init("/ws", via: :get, match: fn conn -> - conn.params["token"] == "secret" - end) - - assert {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws?token=secret")) - - `:to` option is also called during the HTTP handshake: - - TestServer.websocket_init("/ws", - to: fn conn -> - Plug.Conn.send_resp(conn, 403, "Forbidden") - end - ) - - assert {:error, %WebSockex.RequestError{code: 403}} = - WebSocketClient.start_link(TestServer.url("/ws")) - """ - @spec websocket_init(binary(), keyword()) :: {:ok, websocket_socket()} - def websocket_init(uri, options) when is_binary(uri) do - {:ok, instance} = autostart() - - websocket_init(instance, uri, options) - end - - @spec websocket_init(pid(), binary()) :: {:ok, websocket_socket()} - def websocket_init(instance, uri) when is_pid(instance) and is_binary(uri) do - websocket_init(instance, uri, []) - end - - @doc """ - Adds a websocket route to a test server. - - See `websocket_init/2` for options. - """ - @spec websocket_init(pid(), binary(), keyword()) :: {:ok, websocket_socket()} - def websocket_init(instance, uri, options) do - options = - options - |> Keyword.put(:websocket, true) - |> Keyword.put_new(:to, & &1) - - {:ok, %{ref: ref}} = register_route(instance, uri, options) - - {:ok, {instance, ref}} - end - - @spec websocket_handle(websocket_socket()) :: :ok | {:error, term()} - def websocket_handle(socket), do: websocket_handle(socket, []) - - @doc """ - Adds a message handler to a websocket instance. - - Messages are matched FIFO (first in, first out). Any messages not expected by - TestServer or any message expectations not receiving a message will raise an - error in the test case. - - ## Options - - * `:match` - an anonymous function that will be called to see if a - message matches, defaults to matching anything. - * `:to` - an anonymous function that will be called when the message - matches, defaults to returning received message. - - ## Examples - - {:ok, socket} = TestServer.websocket_init("/ws") - - TestServer.websocket_handle( - socket, - to: fn _frame, state -> - {:reply, {:text, "pong"}, state} - end, - match: fn frame, _state -> - frame == {:text, "ping"} - end) - - TestServer.websocket_handle(socket) - - {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) - - assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"} - assert WebSocketClient.send_message(client, "ping") == {:ok, "pong"} - """ - @spec websocket_handle(websocket_socket(), keyword()) :: :ok - def websocket_handle({instance, _route_ref} = socket, options) do - instance_alive!(instance) - - [_first_module_entry | stacktrace] = get_stacktrace() - - options = Keyword.put_new(options, :to, &default_websocket_handle/2) - - {:ok, _handler} = Instance.register(socket, {:websocket, {:handle, options, stacktrace}}) - - :ok - end - - defp default_websocket_handle(frame, state), - do: {:reply, frame, state} - - @doc """ - Sends an message to a websocket instance. - - ## Examples - - {:ok, socket} = TestServer.websocket_init("/ws") - {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) - - assert TestServer.websocket_info(socket, fn state -> - {:reply, {:text, "hello"}, state} - end) == :ok - - assert WebSocketClient.receive_message(client) == {:ok, "hello"} - """ - @spec websocket_info(websocket_socket(), function() | nil) :: :ok - def websocket_info({instance, _route_ref} = socket, callback \\ nil) - when is_function(callback) or is_nil(callback) do - instance_alive!(instance) - - [_first_module_entry | stacktrace] = get_stacktrace() - - callback = callback || (&default_websocket_info/1) - - for pid <- Instance.active_websocket_connections(socket) do - send(pid, {callback, stacktrace}) - end - - :ok - end - - defp default_websocket_info(state), do: {:reply, {:text, "ping"}, state} end diff --git a/lib/test_server/http.ex b/lib/test_server/http.ex new file mode 100644 index 0000000..8f01aa8 --- /dev/null +++ b/lib/test_server/http.ex @@ -0,0 +1,579 @@ +defmodule TestServer.HTTP do + @moduledoc """ + Public API for HTTP and WebSocket mock servers. + + See `TestServer` for full documentation. + """ + + alias Plug.Conn + alias TestServer.HTTP.Instance + alias TestServer.InstanceManager + + @type route :: reference() + @type websocket_socket :: {TestServer.instance(), route()} + @type websocket_frame :: {atom(), any()} + @type websocket_state :: any() + @type websocket_reply :: + {:reply, websocket_frame(), websocket_state()} | {:ok, websocket_state()} + + @doc """ + Start a test server instance. + + The instance will be terminated when the test case finishes. + + ## Options + + * `:port` - integer of port number, defaults to random port + that can be opened; + * `:scheme` - an atom for the http scheme. Defaults to `:http`; + * `:http_server` - HTTP server configuration. Defaults to + `{TestServer.HTTPServer.Bandit, []}`, + `{TestServer.HTTPServer.Plug.Cowboy, []}`, or + `{TestServer.HTTPServer.Httpd, []}` depending on which web server is + available in the project dependencies; + * `:tls` - Passthru options for TLS configuration handled by + the webserver; + * `:ipfamily` - The IP address type to use, either `:inet` or + `:inet6`. Defaults to `:inet`; + + ## Examples + + TestServer.HTTP.start( + scheme: :https, + ipfamily: :inet6, + http_server: {TestServer.HTTPServer.Bandit, [ip: :any]} + ) + + TestServer.HTTP.add("/", + to: fn conn -> + assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1} + + Plug.Conn.resp(conn, 200, to_string(Plug.Conn.get_http_protocol(conn))) + end + ) + + req_opts = [ + connect_options: [ + transport_opts: [cacerts: TestServer.HTTP.x509_suite().cacerts], + protocols: [:http2] + ] + ] + + assert {:ok, %Req.Response{status: 200, body: "HTTP/2"}} = + Req.get(TestServer.HTTP.url(), req_opts) + """ + @spec start(keyword()) :: {:ok, pid()} + def start(options \\ []) do + case ExUnit.fetch_test_supervisor() do + {:ok, sup} -> + start_with_ex_unit(options, sup) + + :error -> + raise ArgumentError, "can only be called in a test process" + end + end + + defp start_with_ex_unit(options, _sup) do + [_first_module_entry | stacktrace] = InstanceManager.get_stacktrace(__MODULE__) + caller = self() + + options = + options + |> Keyword.put_new(:caller, caller) + |> Keyword.put_new(:stacktrace, stacktrace) + + case InstanceManager.start_instance(caller, Instance.child_spec(options)) do + {:ok, instance} -> + put_ex_unit_on_exit_callback(instance) + + {:ok, instance} + + {:error, error} -> + raise_start_failure({:error, error}) + end + end + + defp put_ex_unit_on_exit_callback(instance) do + ExUnit.Callbacks.on_exit(fn -> + case Process.alive?(instance) do + true -> + verify_routes!(instance) + verify_websocket_handlers!(instance) + stop(instance) + + false -> + :ok + end + end) + end + + defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do + raise_start_failure({:error, reason}) + end + + defp raise_start_failure({:error, error}) do + raise """ + EXIT when starting #{inspect(Instance)}: + + #{Exception.format_exit(error)} + """ + end + + defp verify_routes!(instance) do + instance + |> Instance.routes() + |> Enum.reject(& &1.suspended) + |> case do + [] -> + :ok + + active_routes -> + raise """ + #{Instance.format_instance(instance)} did not receive a request for these routes before the test ended: + + #{Instance.format_routes(active_routes)} + """ + end + end + + defp verify_websocket_handlers!(instance) do + instance + |> Instance.websocket_handlers() + |> Enum.reject(& &1.suspended) + |> case do + [] -> + :ok + + active_websocket_handlers -> + raise """ + #{Instance.format_instance(instance)} did not receive a frame for these websocket handlers before the test ended: + + #{Instance.format_websocket_handlers(active_websocket_handlers)} + """ + end + end + + @doc """ + Shuts down the current test server. + + ## Examples + + TestServer.HTTP.start() + url = TestServer.HTTP.url() + TestServer.HTTP.stop() + + assert {:error, %Req.TransportError{}} = Req.get(url, retry: false) + """ + @spec stop() :: :ok | {:error, term()} + def stop, do: stop(fetch_instance!()) + + @doc """ + Shuts down a test server instance. + """ + @spec stop(pid()) :: :ok | {:error, term()} + def stop(instance) do + instance_alive!(instance) + + InstanceManager.stop_instance(instance) + end + + defp instance_alive!(instance) do + case Process.alive?(instance) do + true -> :ok + false -> raise "#{Instance.format_instance(instance)} is not running" + end + end + + @doc """ + Gets current test server instance if running. + + ## Examples + + refute TestServer.HTTP.get_instance() + + {:ok, instance} = TestServer.HTTP.start() + + assert TestServer.HTTP.get_instance() == instance + """ + @spec get_instance() :: pid() | nil + def get_instance do + case InstanceManager.fetch_instance(self(), :http, __MODULE__) do + {:ok, instance} -> instance + :error -> nil + end + end + + @spec url() :: binary() + def url, do: url("") + + @spec url(binary() | keyword() | pid()) :: binary() + def url(uri) when is_binary(uri), do: url(uri, []) + def url(opts) when is_list(opts), do: url("", opts) + def url(instance) when is_pid(instance), do: url(instance, "", []) + + @doc """ + Produces a URL for current test server. + + ## Options + * `:host` - binary host value, it'll be added to inet for IP `127.0.0.1` and `::1`, defaults to `"localhost"`; + + ## Examples + + TestServer.HTTP.start(port: 4444) + + assert TestServer.HTTP.url() == "http://localhost:4444" + assert TestServer.HTTP.url("/test") == "http://localhost:4444/test" + assert TestServer.HTTP.url(host: "example.com") == "http://example.com:4444" + """ + @spec url(binary(), keyword()) :: binary() + def url(uri, opts) when is_binary(uri), do: url(fetch_instance!(), uri, opts) + + @spec url(pid(), binary()) :: binary() + def url(instance, uri) when is_pid(instance), do: url(instance, uri, []) + + @doc """ + Produces a URL for a test server instance. + + See `url/2` for options. + """ + @spec url(pid(), binary(), keyword()) :: binary() + def url(instance, uri, opts) do + instance_alive!(instance) + + unless is_nil(opts[:host]) or is_binary(opts[:host]), + do: raise("Invalid host, got: #{inspect(opts[:host])}") + + domain = maybe_enable_host(opts[:host]) + options = Instance.get_options(instance) + + "#{Keyword.fetch!(options, :scheme)}://#{domain}:#{Keyword.fetch!(options, :port)}#{uri}" + end + + defp fetch_instance! do + case InstanceManager.fetch_instance(self(), :http, __MODULE__) do + :error -> raise "No current #{inspect(Instance)} running" + {:ok, instance} -> instance + end + end + + defp maybe_enable_host(nil), do: "localhost" + + defp maybe_enable_host(host) do + :inet_db.set_lookup([:file, :dns]) + :inet_db.add_host({127, 0, 0, 1}, [String.to_charlist(host)]) + :inet_db.add_host({0, 0, 0, 0, 0, 0, 0, 1}, [String.to_charlist(host)]) + + host + end + + @spec add(binary()) :: :ok + def add(uri), do: add(uri, []) + + @doc """ + Adds a route to the current test server. + + Matching routes are handled FIFO (first in, first out). Any requests to + routes not added to the TestServer and any routes that isn't matched will + raise an error in the test case. + + ## Options + + * `:via` - matches the route against some specific HTTP method(s) + specified as an atom, like `:get` or `:put`, or a list, like `[:get, :post]`. + * `:match` - an anonymous function that will be called to see if a + route matches, defaults to matching with arguments of uri and `:via` option. + * `:to` - a Plug or anonymous function that will be called when the + route matches, defaults to return the http scheme. + + ## Examples + + TestServer.HTTP.add("/", + match: fn conn -> + conn.query_params["a"] == "1" + end, + to: fn conn -> + Plug.Conn.resp(conn, 200, "a = 1") + end) + + TestServer.HTTP.add("/", to: &Plug.Conn.resp(&1, 200, "PONG")) + TestServer.HTTP.add("/") + + assert {:ok, %Req.Response{status: 200, body: "PONG"}} = Req.get(TestServer.HTTP.url("/")) + assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} = Req.post(TestServer.HTTP.url("/")) + assert {:ok, %Req.Response{status: 200, body: "a = 1"}} = Req.get(TestServer.HTTP.url("/?a=1")) + """ + @spec add(binary(), keyword()) :: :ok + def add(uri, options) when is_binary(uri) do + {:ok, instance} = autostart() + + add(instance, uri, options) + end + + @spec add(pid(), binary()) :: :ok + def add(instance, uri) when is_pid(instance) and is_binary(uri), do: add(instance, uri, []) + + @doc """ + Adds a route to a test server instance. + + See `add/2` for options. + """ + @spec add(pid(), binary(), keyword()) :: :ok + def add(instance, uri, options) when is_pid(instance) and is_binary(uri) and is_list(options) do + options = Keyword.put_new(options, :to, &default_response_handler/1) + + {:ok, _route} = register_route(instance, uri, options) + + :ok + end + + defp register_route(instance, uri, options) do + instance_alive!(instance) + + [_register_route, _first_module_entry | stacktrace] = + InstanceManager.get_stacktrace(__MODULE__) + + Instance.register(instance, {:plug_router_to, {uri, options, stacktrace}}) + end + + defp autostart do + case InstanceManager.fetch_instance(self(), :http, __MODULE__) do + :error -> start() + {:ok, instance} -> {:ok, instance} + end + end + + defp default_response_handler(conn) do + Conn.resp(conn, 200, to_string(Conn.get_http_protocol(conn))) + end + + @doc """ + Adds a plug to the current test server. + + This plug will be called for all requests before route is matched. + + ## Examples + + TestServer.HTTP.plug(MyPlug) + + TestServer.HTTP.plug(fn conn -> + {:ok, body, _conn} = Plug.Conn.read_body(conn, []) + + %{conn | body_params: Jason.decode!(body)} + end) + """ + @spec plug(module() | function()) :: :ok + def plug(plug) do + {:ok, instance} = autostart() + + plug(instance, plug) + end + + @doc """ + Adds a route to a test server instance. + + See `plug/1` for more. + """ + @spec plug(pid(), module() | function()) :: :ok + def plug(instance, plug) do + [_first_module_entry | stacktrace] = InstanceManager.get_stacktrace(__MODULE__) + + {:ok, _plug} = Instance.register(instance, {:plug, {plug, stacktrace}}) + + :ok + end + + @doc """ + Fetches the generated x509 suite for the current test server. + + ## Examples + + TestServer.HTTP.start(scheme: :https) + TestServer.HTTP.add("/") + + cacerts = TestServer.HTTP.x509_suite().cacerts + req_opts = [connect_options: [transport_opts: [cacerts: cacerts]]] + + assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} = + Req.get(TestServer.HTTP.url(), req_opts) + """ + @spec x509_suite() :: term() + def x509_suite, do: x509_suite(fetch_instance!()) + + @doc """ + Fetches the generated x509 suite for a test server instance. + + See `x509_suite/0` for more. + """ + @spec x509_suite(pid()) :: term() + def x509_suite(instance) do + instance_alive!(instance) + + options = Instance.get_options(instance) + + cond do + not (options[:scheme] == :https) -> + raise "#{Instance.format_instance(instance)} is not running with `[scheme: :https]` option" + + not Keyword.has_key?(options, :x509_suite) -> + raise "#{Instance.format_instance(instance)} is running with custom SSL" + + true -> + options[:x509_suite] + end + end + + @spec websocket_init(binary()) :: {:ok, websocket_socket()} | {:error, term()} + def websocket_init(uri) when is_binary(uri), do: websocket_init(uri, []) + + @doc """ + Adds a websocket route to current test server. + + The `:to` option can be overridden the same way as for `add/2`, and will be + called during the HTTP handshake. If the `conn.state` is `:unset` the + websocket will be initiated otherwise response is returned as-is. + + ## Options + + Takes the same options as `add/2`, except `:to`. + + ## Examples + + {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + TestServer.HTTP.websocket_handle(socket) + + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"} + + `:via` and `:match` are called during the HTTP handshake: + + TestServer.HTTP.websocket_init("/ws", via: :get, match: fn conn -> + conn.params["token"] == "secret" + end) + + assert {:ok, _client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws?token=secret")) + + `:to` option is also called during the HTTP handshake: + + TestServer.HTTP.websocket_init("/ws", + to: fn conn -> + Plug.Conn.send_resp(conn, 403, "Forbidden") + end + ) + + assert {:error, %WebSockex.RequestError{code: 403}} = + WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + """ + @spec websocket_init(binary(), keyword()) :: {:ok, websocket_socket()} + def websocket_init(uri, options) when is_binary(uri) do + {:ok, instance} = autostart() + + websocket_init(instance, uri, options) + end + + @spec websocket_init(pid(), binary()) :: {:ok, websocket_socket()} + def websocket_init(instance, uri) when is_pid(instance) and is_binary(uri) do + websocket_init(instance, uri, []) + end + + @doc """ + Adds a websocket route to a test server. + + See `websocket_init/2` for options. + """ + @spec websocket_init(pid(), binary(), keyword()) :: {:ok, websocket_socket()} + def websocket_init(instance, uri, options) do + options = + options + |> Keyword.put(:websocket, true) + |> Keyword.put_new(:to, & &1) + + {:ok, %{ref: ref}} = register_route(instance, uri, options) + + {:ok, {instance, ref}} + end + + @spec websocket_handle(websocket_socket()) :: :ok | {:error, term()} + def websocket_handle(socket), do: websocket_handle(socket, []) + + @doc """ + Adds a message handler to a websocket instance. + + Messages are matched FIFO (first in, first out). Any messages not expected by + TestServer or any message expectations not receiving a message will raise an + error in the test case. + + ## Options + + * `:match` - an anonymous function that will be called to see if a + message matches, defaults to matching anything. + * `:to` - an anonymous function that will be called when the message + matches, defaults to returning received message. + + ## Examples + + {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + + TestServer.HTTP.websocket_handle( + socket, + to: fn _frame, state -> + {:reply, {:text, "pong"}, state} + end, + match: fn frame, _state -> + frame == {:text, "ping"} + end) + + TestServer.HTTP.websocket_handle(socket) + + {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + + assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"} + assert WebSocketClient.send_message(client, "ping") == {:ok, "pong"} + """ + @spec websocket_handle(websocket_socket(), keyword()) :: :ok + def websocket_handle({instance, _route_ref} = socket, options) do + instance_alive!(instance) + + [_first_module_entry | stacktrace] = InstanceManager.get_stacktrace(__MODULE__) + + options = Keyword.put_new(options, :to, &default_websocket_handle/2) + + {:ok, _handler} = Instance.register(socket, {:websocket, {:handle, options, stacktrace}}) + + :ok + end + + defp default_websocket_handle(frame, state), + do: {:reply, frame, state} + + @doc """ + Sends an message to a websocket instance. + + ## Examples + + {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) + + assert TestServer.HTTP.websocket_info(socket, fn state -> + {:reply, {:text, "hello"}, state} + end) == :ok + + assert WebSocketClient.receive_message(client) == {:ok, "hello"} + """ + @spec websocket_info(websocket_socket(), function() | nil) :: :ok + def websocket_info({instance, _route_ref} = socket, callback \\ nil) + when is_function(callback) or is_nil(callback) do + instance_alive!(instance) + + [_first_module_entry | stacktrace] = InstanceManager.get_stacktrace(__MODULE__) + + callback = callback || (&default_websocket_info/1) + + for pid <- Instance.active_websocket_connections(socket) do + send(pid, {callback, stacktrace}) + end + + :ok + end + + defp default_websocket_info(state), do: {:reply, {:text, "ping"}, state} +end diff --git a/lib/test_server/instance.ex b/lib/test_server/http/instance.ex similarity index 92% rename from lib/test_server/instance.ex rename to lib/test_server/http/instance.ex index a1ae54a..6653564 100644 --- a/lib/test_server/instance.ex +++ b/lib/test_server/http/instance.ex @@ -1,9 +1,9 @@ -defmodule TestServer.Instance do +defmodule TestServer.HTTP.Instance do @moduledoc false use GenServer - alias TestServer.HTTPServer + alias TestServer.HTTP.Server def start_link(options) do GenServer.start_link(__MODULE__, options) @@ -22,7 +22,8 @@ defmodule TestServer.Instance do GenServer.call(instance, {:register, {:plug_router_to, {uri, options, stacktrace}}}) end - @spec register(pid(), {:plug, {atom() | function(), TestServer.stacktrace()}}) :: {:ok, map()} + @spec register(pid(), {:plug, {atom() | function(), TestServer.stacktrace()}}) :: + {:ok, map()} def register(instance, {:plug, {plug, stacktrace}}) do ensure_plug!(plug) @@ -30,7 +31,7 @@ defmodule TestServer.Instance do end @spec register( - TestServer.websocket_socket(), + TestServer.HTTP.websocket_socket(), {:websocket, {:handle, keyword(), TestServer.stacktrace()}} ) :: {:ok, map()} @@ -59,10 +60,11 @@ defmodule TestServer.Instance do end @spec dispatch( - TestServer.websocket_socket(), - {:websocket, {:handle, TestServer.websocket_frame()}, TestServer.websocket_state()} + TestServer.HTTP.websocket_socket(), + {:websocket, {:handle, TestServer.HTTP.websocket_frame()}, + TestServer.HTTP.websocket_state()} ) :: - {:ok, TestServer.websocket_reply()} + {:ok, TestServer.HTTP.websocket_reply()} | {:error, :not_found} | {:error, {term(), TestServer.stacktrace()}} def dispatch({instance, _router_ref} = socket, {:websocket, {:handle, frame}, state}) do @@ -70,10 +72,11 @@ defmodule TestServer.Instance do end @spec dispatch( - TestServer.websocket_socket(), - {:websocket, {:info, function(), TestServer.stacktrace()}, TestServer.websocket_state()} + TestServer.HTTP.websocket_socket(), + {:websocket, {:info, function(), TestServer.stacktrace()}, + TestServer.HTTP.websocket_state()} ) :: - {:ok, TestServer.websocket_reply()} + {:ok, TestServer.HTTP.websocket_reply()} | {:error, {term(), TestServer.stacktrace()}} def dispatch( {instance, _router_ref} = socket, @@ -95,12 +98,12 @@ defmodule TestServer.Instance do GenServer.call(instance, :routes) end - @spec put_websocket_connection(TestServer.websocket_socket(), pid()) :: :ok + @spec put_websocket_connection(TestServer.HTTP.websocket_socket(), pid()) :: :ok def put_websocket_connection({instance, route_ref}, pid) do GenServer.cast(instance, {:put, :websocket_connection, route_ref, pid}) end - @spec active_websocket_connections(TestServer.websocket_socket()) :: [pid()] + @spec active_websocket_connections(TestServer.HTTP.websocket_socket()) :: [pid()] def active_websocket_connections({instance, route_ref}) do GenServer.call(instance, {:get, :websocket_connections, route_ref}) end @@ -156,6 +159,10 @@ defmodule TestServer.Instance do @impl true def init(options) do + Process.flag(:trap_exit, true) + + options = Keyword.put(options, :protocol, :http) + init_state = %{ routes: [], plugs: [], @@ -171,7 +178,7 @@ defmodule TestServer.Instance do end defp start_http_server(state) do - case HTTPServer.start(self(), state.options) do + case Server.start(self(), state.options) do {:ok, options} -> state = Map.merge(state, %{options: options}) @@ -315,7 +322,7 @@ defmodule TestServer.Instance do defp run_plugs(conn, state) do state.plugs |> case do - [] -> [%{plug: TestServer.Plug.default_plug(), stacktrace: nil}] + [] -> [%{plug: TestServer.HTTP.Plug.default_plug(), stacktrace: nil}] plugs -> plugs end |> Enum.reduce_while(conn, fn %{plug: plug, stacktrace: stacktrace}, conn -> @@ -455,10 +462,18 @@ defmodule TestServer.Instance do """ end + @impl true + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} + @impl true def handle_cast({:put, :websocket_connection, route_ref, pid}, state) do connections = state.websocket_connections ++ [{route_ref, pid}] {:noreply, %{state | websocket_connections: connections}} end + + @impl true + def terminate(_reason, state) do + Server.stop(state.options) + end end diff --git a/lib/test_server/plug.ex b/lib/test_server/http/plug.ex similarity index 97% rename from lib/test_server/plug.ex rename to lib/test_server/http/plug.ex index 6642022..707863a 100644 --- a/lib/test_server/plug.ex +++ b/lib/test_server/http/plug.ex @@ -1,8 +1,8 @@ -defmodule TestServer.Plug do +defmodule TestServer.HTTP.Plug do @moduledoc false alias Plug.Conn - alias TestServer.Instance + alias TestServer.HTTP.Instance def init({http_server, args, instance}), do: {http_server, args, instance} diff --git a/lib/test_server/http_server.ex b/lib/test_server/http/server.ex similarity index 89% rename from lib/test_server/http_server.ex rename to lib/test_server/http/server.ex index c73c534..f37b34b 100644 --- a/lib/test_server/http_server.ex +++ b/lib/test_server/http/server.ex @@ -1,13 +1,13 @@ -defmodule TestServer.HTTPServer do +defmodule TestServer.HTTP.Server do @moduledoc """ - HTTP server adapter module. + HTTP server adapter behaviour. ## Usage defmodule MyApp.MyHTTPServer do - @behaviour TestServer.HTTPServer + @behaviour TestServer.HTTP.Server - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def start(instance, port, scheme, tls_options, server_options) do my_http_server_options = server_options @@ -20,10 +20,10 @@ defmodule TestServer.HTTPServer do end end - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def stop(instance, server_options), do: MyHTTPServer.stop() - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def get_socket_pid(%{adapter: {_, data}}), do: data.pid # or however your adapter provides the pid end """ @@ -125,14 +125,14 @@ defmodule TestServer.HTTPServer do defp default_http_server do cond do - Code.ensure_loaded?(TestServer.HTTPServer.Bandit) -> - {TestServer.HTTPServer.Bandit, []} + Code.ensure_loaded?(TestServer.HTTP.Server.Bandit) -> + {TestServer.HTTP.Server.Bandit, []} - Code.ensure_loaded?(TestServer.HTTPServer.Plug.Cowboy) -> - {TestServer.HTTPServer.Plug.Cowboy, []} + Code.ensure_loaded?(TestServer.HTTP.Server.Plug.Cowboy) -> + {TestServer.HTTP.Server.Plug.Cowboy, []} true -> - {TestServer.HTTPServer.Httpd, []} + {TestServer.HTTP.Server.Httpd, []} end end diff --git a/lib/test_server/http_server/bandit.ex b/lib/test_server/http/server/bandit.ex similarity index 81% rename from lib/test_server/http_server/bandit.ex rename to lib/test_server/http/server/bandit.ex index 09c5f18..be6a1a5 100644 --- a/lib/test_server/http_server/bandit.ex +++ b/lib/test_server/http/server/bandit.ex @@ -1,5 +1,5 @@ if Code.ensure_loaded?(Bandit) do - defmodule TestServer.HTTPServer.Bandit do + defmodule TestServer.HTTP.Server.Bandit do @moduledoc """ HTTP server adapter using `Bandit`. @@ -9,16 +9,16 @@ if Code.ensure_loaded?(Bandit) do ## Usage - TestServer.start( - http_server: {TestServer.HTTPServer.Bandit, bandit_options} + TestServer.HTTP.start( + http_server: {TestServer.HTTP.Server.Bandit, bandit_options} ) """ - @behaviour TestServer.HTTPServer + @behaviour TestServer.HTTP.Server @behaviour WebSock - alias TestServer.WebSocket + alias TestServer.HTTP.WebSocket - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def start(instance, port, scheme, options, bandit_options) do transport_options = bandit_options @@ -43,7 +43,7 @@ if Code.ensure_loaded?(Bandit) do bandit_options = bandit_options |> Keyword.put(:thousand_island_options, thousand_islands_options) - |> Keyword.put(:plug, {TestServer.HTTPServer.Bandit.Plug, {__MODULE__, [], instance}}) + |> Keyword.put(:plug, {TestServer.HTTP.Server.Bandit.Plug, {__MODULE__, [], instance}}) |> Keyword.put(:scheme, scheme) |> Keyword.put_new(:startup_log, false) @@ -59,13 +59,13 @@ if Code.ensure_loaded?(Bandit) do Keyword.merge(transport_options, Keyword.put_new(tls_options, :log_level, :warning)) end - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def stop(server_pid, _bandit_options) do ThousandIsland.stop(server_pid) end - @impl TestServer.HTTPServer - def get_socket_pid(%{adapter: {TestServer.HTTPServer.Bandit.Adapter, {_plug_pid, req}}}) do + @impl TestServer.HTTP.Server + def get_socket_pid(%{adapter: {TestServer.HTTP.Server.Bandit.Adapter, {_plug_pid, req}}}) do req.owner_pid end diff --git a/lib/test_server/http_server/bandit/adapter.ex b/lib/test_server/http/server/bandit/adapter.ex similarity index 98% rename from lib/test_server/http_server/bandit/adapter.ex rename to lib/test_server/http/server/bandit/adapter.ex index 9fcbccb..6231055 100644 --- a/lib/test_server/http_server/bandit/adapter.ex +++ b/lib/test_server/http/server/bandit/adapter.ex @@ -10,7 +10,7 @@ # https://github.com/mtrudel/bandit/issues/215 # https://github.com/mtrudel/bandit/issues/101 if Code.ensure_loaded?(Bandit) do - defmodule TestServer.HTTPServer.Bandit.Adapter do + defmodule TestServer.HTTP.Server.Bandit.Adapter do @moduledoc false @behaviour Plug.Conn.Adapter diff --git a/lib/test_server/http_server/bandit/plug.ex b/lib/test_server/http/server/bandit/plug.ex similarity index 67% rename from lib/test_server/http_server/bandit/plug.ex rename to lib/test_server/http/server/bandit/plug.ex index ab69a7a..bf90532 100644 --- a/lib/test_server/http_server/bandit/plug.ex +++ b/lib/test_server/http/server/bandit/plug.ex @@ -1,12 +1,12 @@ -# See TestServer.HTTPServer.Bandit.HTTP2Adapter for why this is required. -defmodule TestServer.HTTPServer.Bandit.Plug do +# See TestServer.HTTP.Server.Bandit.Adapter for why this is required. +defmodule TestServer.HTTP.Server.Bandit.Plug do @moduledoc false - defdelegate init(opts), to: TestServer.Plug + defdelegate init(opts), to: TestServer.HTTP.Plug def call(%{adapter: {Bandit.Adapter, req}} = conn, {http_server, args, instance}) do plug_pid = self() - conn = %{conn | adapter: {TestServer.HTTPServer.Bandit.Adapter, {plug_pid, req}}} + conn = %{conn | adapter: {TestServer.HTTP.Server.Bandit.Adapter, {plug_pid, req}}} loop( Task.async(fn -> @@ -21,7 +21,7 @@ defmodule TestServer.HTTPServer.Bandit.Plug do end def call(conn, {http_server, args, instance}) do - TestServer.Plug.call(conn, {http_server, args, instance}) + TestServer.HTTP.Plug.call(conn, {http_server, args, instance}) end defp loop(task) do diff --git a/lib/test_server/http_server/httpd.ex b/lib/test_server/http/server/httpd.ex similarity index 93% rename from lib/test_server/http_server/httpd.ex rename to lib/test_server/http/server/httpd.ex index 8c2e3cc..7353333 100644 --- a/lib/test_server/http_server/httpd.ex +++ b/lib/test_server/http/server/httpd.ex @@ -1,5 +1,5 @@ if Code.ensure_loaded?(:httpd) do - defmodule TestServer.HTTPServer.Httpd do + defmodule TestServer.HTTP.Server.Httpd do @moduledoc """ HTTP server adapter using `:httpd`. @@ -8,13 +8,13 @@ if Code.ensure_loaded?(:httpd) do ## Usage - TestServer.start( - http_server: {TestServer.HTTPServer.Httpd, httpd_options} + TestServer.HTTP.start( + http_server: {TestServer.HTTP.Server.Httpd, httpd_options} ) """ - @behaviour TestServer.HTTPServer + @behaviour TestServer.HTTP.Server - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def start(instance, port, scheme, options, httpd_options) do httpd_options = httpd_options @@ -23,7 +23,7 @@ if Code.ensure_loaded?(:httpd) do |> Keyword.put_new(:server_name, ~c"Httpd Test Server") |> Keyword.put_new(:document_root, ~c"/tmp") |> Keyword.put_new(:server_root, ~c"/tmp") - |> Keyword.put_new(:handler_plug, {TestServer.Plug, {__MODULE__, [], instance}}) + |> Keyword.put_new(:handler_plug, {TestServer.HTTP.Plug, {__MODULE__, [], instance}}) |> Keyword.put_new(:ipfamily, options[:ipfamily]) |> put_tls_options(scheme, options[:tls]) @@ -41,12 +41,12 @@ if Code.ensure_loaded?(:httpd) do Keyword.put(httpd_options, :socket_type, {:ssl, tls_options}) end - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def stop(pid, _httpd_options) do :inets.stop(:httpd, pid) end - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def get_socket_pid(%{adapter: {_, _data}}), do: self() # :httpd record handler diff --git a/lib/test_server/http_server/plug_cowboy.ex b/lib/test_server/http/server/plug_cowboy.ex similarity index 83% rename from lib/test_server/http_server/plug_cowboy.ex rename to lib/test_server/http/server/plug_cowboy.ex index 6fd353d..0e52d90 100644 --- a/lib/test_server/http_server/plug_cowboy.ex +++ b/lib/test_server/http/server/plug_cowboy.ex @@ -1,5 +1,5 @@ if Code.ensure_loaded?(Plug.Cowboy) do - defmodule TestServer.HTTPServer.Plug.Cowboy do + defmodule TestServer.HTTP.Server.Plug.Cowboy do @moduledoc """ HTTP server adapter using `Plug.Cowboy`. @@ -9,25 +9,25 @@ if Code.ensure_loaded?(Plug.Cowboy) do ## Usage - TestServer.start( - http_server: {TestServer.HTTPServer.Plug.Cowboy, cowboy_options} + TestServer.HTTP.start( + http_server: {TestServer.HTTP.Server.Plug.Cowboy, cowboy_options} ) """ # Server - @behaviour TestServer.HTTPServer + @behaviour TestServer.HTTP.Server @behaviour :cowboy_websocket alias Plug.{Cowboy, Cowboy.Handler} - alias TestServer.WebSocket + alias TestServer.HTTP.WebSocket @default_protocol_options [ idle_timeout: :timer.seconds(1), request_timeout: :timer.seconds(1) ] - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def start(instance, port, scheme, options, cowboy_options) do cowboy_options = cowboy_options @@ -41,14 +41,18 @@ if Code.ensure_loaded?(Plug.Cowboy) do |> Keyword.put(:net, options[:ipfamily]) |> put_tls_options(scheme, options[:tls]) - case apply(Cowboy, scheme, [TestServer.Plug, {__MODULE__, %{}, instance}, cowboy_options]) do + case apply(Cowboy, scheme, [ + TestServer.HTTP.Plug, + {__MODULE__, %{}, instance}, + cowboy_options + ]) do {:ok, pid} -> {:ok, pid, cowboy_options} {:error, error} -> {:error, error} end end defp dispatch(instance) do - dispatches = [{:_, __MODULE__, {TestServer.Plug, {__MODULE__, %{}, instance}}}] + dispatches = [{:_, __MODULE__, {TestServer.HTTP.Plug, {__MODULE__, %{}, instance}}}] [{:_, dispatches}] end @@ -59,7 +63,7 @@ if Code.ensure_loaded?(Plug.Cowboy) do Keyword.merge(cowboy_options, Keyword.put_new(tls_options, :log_level, :warning)) end - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def stop(_pid, cowboy_options) do port = Keyword.fetch!(cowboy_options, :port) @@ -70,7 +74,7 @@ if Code.ensure_loaded?(Plug.Cowboy) do {__MODULE__, port} end - @impl TestServer.HTTPServer + @impl TestServer.HTTP.Server def get_socket_pid(%{adapter: {_, req}}), do: req.pid # Dispatch handling diff --git a/lib/test_server/websocket.ex b/lib/test_server/http/websocket.ex similarity index 97% rename from lib/test_server/websocket.ex rename to lib/test_server/http/websocket.ex index ecdc0a3..68d47c8 100644 --- a/lib/test_server/websocket.ex +++ b/lib/test_server/http/websocket.ex @@ -1,7 +1,7 @@ -defmodule TestServer.WebSocket do +defmodule TestServer.HTTP.WebSocket do @moduledoc false - alias TestServer.Instance + alias TestServer.HTTP.Instance def handle_frame(frame, {{instance, _route_ref} = socket, state}) do case Instance.dispatch(socket, {:websocket, {:handle, frame}, state}) do diff --git a/lib/test_server/instance_manager.ex b/lib/test_server/instance_manager.ex index a2b07ef..86ae8ae 100644 --- a/lib/test_server/instance_manager.ex +++ b/lib/test_server/instance_manager.ex @@ -3,21 +3,14 @@ defmodule TestServer.InstanceManager do use GenServer - alias TestServer.{Instance, InstanceSupervisor} + alias TestServer.InstanceSupervisor def start_link(options) do GenServer.start_link(__MODULE__, options, name: __MODULE__) end - def start_instance(caller, stacktrace, options) do - options = - options - |> Keyword.put_new(:caller, caller) - |> Keyword.put_new(:stacktrace, stacktrace) - - caller = Keyword.fetch!(options, :caller) - - case DynamicSupervisor.start_child(InstanceSupervisor, Instance.child_spec(options)) do + def start_instance(caller, child_spec) do + case DynamicSupervisor.start_child(InstanceSupervisor, child_spec) do {:ok, instance} -> GenServer.call(__MODULE__, {:register, {caller, instance}}) {:error, error} -> {:error, error} end @@ -25,7 +18,6 @@ defmodule TestServer.InstanceManager do @spec stop_instance(pid()) :: :ok | {:error, :not_found} def stop_instance(instance) do - :ok = TestServer.HTTPServer.stop(Instance.get_options(instance)) res = DynamicSupervisor.terminate_child(InstanceSupervisor, instance) GenServer.call(__MODULE__, {:remove, instance}) @@ -37,6 +29,70 @@ defmodule TestServer.InstanceManager do GenServer.call(__MODULE__, {:get_by_caller, caller}) end + @spec fetch_instance(pid(), atom(), module()) :: {:ok, pid()} | :error + def fetch_instance(caller, protocol, calling_module) do + instances = + caller + |> get_by_caller() + |> List.wrap() + |> Enum.filter(&instance_protocol?(&1, protocol)) + + case instances do + [] -> + :error + + [instance] -> + {:ok, instance} + + [_ | _] = instances -> + [{m, f, a, _} | _] = get_stacktrace(calling_module) + + formatted_instances = + instances + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {instance, index} -> + options = GenServer.call(instance, :options) + + """ + ##{index + 1}: #{inspect(instance)} + #{Enum.map_join(options[:stacktrace], "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + + raise """ + Multiple instances running, please pass instance to `#{inspect(m)}.#{f}/#{a}`. + + #{formatted_instances} + """ + end + end + + @spec get_stacktrace(module()) :: list() + def get_stacktrace(calling_module) do + {:current_stacktrace, [{Process, :info, _, _} | stacktrace]} = + Process.info(self(), :current_stacktrace) + + first_module_entry = + stacktrace + |> Enum.reverse() + |> Enum.find(fn {mod, _, _, _} -> mod == calling_module end) + + [first_module_entry] ++ prune_stacktrace(stacktrace, calling_module) + end + + defp instance_protocol?(pid, protocol) do + GenServer.call(pid, :options)[:protocol] == protocol + rescue + _ -> false + end + + defp prune_stacktrace([{__MODULE__, _, _, _} | t], mod), do: prune_stacktrace(t, mod) + defp prune_stacktrace([{mod, _, _, _} | t], mod), do: prune_stacktrace(t, mod) + defp prune_stacktrace([{ExUnit.Assertions, _, _, _} | t], mod), do: prune_stacktrace(t, mod) + defp prune_stacktrace([{ExUnit.Runner, _, _, _} | _], _mod), do: [] + defp prune_stacktrace([h | t], mod), do: [h | prune_stacktrace(t, mod)] + defp prune_stacktrace([], _mod), do: [] + @impl true def init(_options) do {:ok, %{instances: []}} diff --git a/mix.exs b/mix.exs index 8db77a7..a23b92f 100644 --- a/mix.exs +++ b/mix.exs @@ -77,11 +77,17 @@ defmodule TestServer.MixProject do "CHANGELOG.md" ], groups_for_modules: [ - HTTPServer: [ - TestServer.HTTPServer, - TestServer.HTTPServer.Httpd, - TestServer.HTTPServer.Bandit, - TestServer.HTTPServer.Plug.Cowboy + HTTP: [ + TestServer.HTTP, + TestServer.HTTP.Instance, + TestServer.HTTP.Plug, + TestServer.HTTP.WebSocket + ], + "HTTP.Server": [ + TestServer.HTTP.Server, + TestServer.HTTP.Server.Httpd, + TestServer.HTTP.Server.Bandit, + TestServer.HTTP.Server.Cowboy ] ] ] diff --git a/test/http_server/bandit/http2_adapter_test.exs b/test/http_server/bandit/adapter_test.exs similarity index 71% rename from test/http_server/bandit/http2_adapter_test.exs rename to test/http_server/bandit/adapter_test.exs index a7cbdb3..7be8795 100644 --- a/test/http_server/bandit/http2_adapter_test.exs +++ b/test/http_server/bandit/adapter_test.exs @@ -1,30 +1,29 @@ -defmodule TestServer.HTTPServer.Bandit.HTTP2AdapterTest do +defmodule TestServer.HTTP.Server.Bandit.HTTP2AdapterTest do use ExUnit.Case - doctest TestServer setup do Application.ensure_all_started(:bandit) {:ok, _instance} = - TestServer.start(scheme: :https, http_server: {TestServer.HTTPServer.Bandit, []}) + TestServer.HTTP.start(scheme: :https, http_server: {TestServer.HTTP.Server.Bandit, []}) :ok end test "Plug.Conn.send_resp/3" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> Plug.Conn.send_resp(conn, 200, "test") end ) - assert {:ok, "test"} = http2_request(TestServer.url()) + assert {:ok, "test"} = http2_request(TestServer.HTTP.url()) end test "Plug.Conn.send_file/1" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> Plug.Conn.send_file(conn, 200, __ENV__.file) end @@ -32,12 +31,12 @@ defmodule TestServer.HTTPServer.Bandit.HTTP2AdapterTest do expected = File.read!(__ENV__.file) - assert {:ok, ^expected} = http2_request(TestServer.url()) + assert {:ok, ^expected} = http2_request(TestServer.HTTP.url()) end test "Plug.Conn.send_chunked/1 and Plug.Conn.chunk/1" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> conn = Plug.Conn.send_chunked(conn, 200) {:ok, conn} = Plug.Conn.chunk(conn, "Hello\n") @@ -47,12 +46,12 @@ defmodule TestServer.HTTPServer.Bandit.HTTP2AdapterTest do end ) - assert {:ok, "Hello\nWorld"} = http2_request(TestServer.url()) + assert {:ok, "Hello\nWorld"} = http2_request(TestServer.HTTP.url()) end test "Plug.Conn.get_peer_data/1" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert %{address: {127, 0, 0, 1}} = Plug.Conn.get_peer_data(conn) @@ -60,38 +59,38 @@ defmodule TestServer.HTTPServer.Bandit.HTTP2AdapterTest do end ) - assert {:ok, "OK"} = http2_request(TestServer.url()) + assert {:ok, "OK"} = http2_request(TestServer.HTTP.url()) end test "Plug.Conn.get_http_protocol/1" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert Plug.Conn.get_http_protocol(conn) == :"HTTP/2" Plug.Conn.send_resp(conn, 200, "OK") end ) - assert {:ok, "OK"} = http2_request(TestServer.url()) + assert {:ok, "OK"} = http2_request(TestServer.HTTP.url()) end test "Plug.Conn.read_body/1" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert {:ok, body, _data} = Plug.Conn.read_body(conn) Plug.Conn.resp(conn, 200, body) end ) - assert {:ok, "test"} = http2_request(TestServer.url(), method: :post, body: "test") + assert {:ok, "test"} = http2_request(TestServer.HTTP.url(), method: :post, body: "test") end defp http2_request(url, opts \\ []) do pools = %{ default: [ protocols: [:http2], - conn_opts: [transport_opts: [cacerts: TestServer.x509_suite().cacerts]] + conn_opts: [transport_opts: [cacerts: TestServer.HTTP.x509_suite().cacerts]] ] } diff --git a/test/test_helper.exs b/test/test_helper.exs index e47b5b1..d7aaebf 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -6,6 +6,6 @@ http_server = System.get_env("HTTP_SERVER", "Bandit") # `Header timestamp couldn't be fetched from ETS cache` warnings if http_server == "Bandit", do: Application.ensure_all_started(:bandit) -http_server = Module.concat(TestServer.HTTPServer, http_server) +http_server = Module.concat([TestServer.HTTP.Server, http_server]) Application.put_env(:test_server, :http_server, {http_server, []}) IO.puts("Testing with #{inspect(http_server)}") diff --git a/test/test_server_test.exs b/test/test_server/http_test.exs similarity index 57% rename from test/test_server_test.exs rename to test/test_server/http_test.exs index 2eb80bc..87a00b3 100644 --- a/test/test_server_test.exs +++ b/test/test_server/http_test.exs @@ -1,6 +1,6 @@ -defmodule TestServerTest do +defmodule TestServer.HTTPTest do use ExUnit.Case - doctest TestServer + doctest TestServer.HTTP import ExUnit.CaptureIO @@ -9,41 +9,41 @@ defmodule TestServerTest do describe "start/1" do test "with invalid port" do assert_raise RuntimeError, ~r/Invalid port, got: :invalid/, fn -> - TestServer.start(port: :invalid) + TestServer.HTTP.start(port: :invalid) end assert_raise RuntimeError, ~r/Invalid port, got: 65536/, fn -> - TestServer.start(port: 65_536) + TestServer.HTTP.start(port: 65_536) end assert_raise RuntimeError, ~r/Could not listen to port 4444, because: :eaddrinuse/, fn -> - TestServer.start(port: 4444) - TestServer.start(port: 4444) + TestServer.HTTP.start(port: 4444) + TestServer.HTTP.start(port: 4444) end end test "with invalid scheme" do assert_raise RuntimeError, ~r/Invalid scheme, got: :invalid/, fn -> - TestServer.start(scheme: :invalid) + TestServer.HTTP.start(scheme: :invalid) end end test "starts with multiple ports" do - {:ok, instance_1} = TestServer.start() - {:ok, instance_2} = TestServer.start() + {:ok, instance_1} = TestServer.HTTP.start() + {:ok, instance_2} = TestServer.HTTP.start() refute instance_1 == instance_2 - options_1 = TestServer.Instance.get_options(instance_1) - options_2 = TestServer.Instance.get_options(instance_2) + options_1 = TestServer.HTTP.Instance.get_options(instance_1) + options_2 = TestServer.HTTP.Instance.get_options(instance_2) refute options_1[:port] == options_2[:port] end test "starts with self-signed SSL" do - {:ok, instance} = TestServer.start(scheme: :https) + {:ok, instance} = TestServer.HTTP.start(scheme: :https) - options = TestServer.Instance.get_options(instance) + options = TestServer.HTTP.Instance.get_options(instance) assert %X509.Test.Suite{} = options[:x509_suite] @@ -62,24 +62,26 @@ defmodule TestServerTest do ] end - valid_cacerts = TestServer.x509_suite().cacerts + valid_cacerts = TestServer.HTTP.x509_suite().cacerts invalid_cacerts = X509.Test.Suite.new().cacerts assert {:error, {:failed_connect, _}} = - http1_request(TestServer.url("/"), http_opts: http_opts.(invalid_cacerts)) + http1_request(TestServer.HTTP.url("/"), http_opts: http_opts.(invalid_cacerts)) - assert :ok = TestServer.add("/") - assert {:ok, _} = http1_request(TestServer.url("/"), http_opts: http_opts.(valid_cacerts)) + assert :ok = TestServer.HTTP.add("/") + + assert {:ok, _} = + http1_request(TestServer.HTTP.url("/"), http_opts: http_opts.(valid_cacerts)) end test "starts in IPv6-only mode`" do - {:ok, instance} = TestServer.start(ipfamily: :inet6) - options = TestServer.Instance.get_options(instance) + {:ok, instance} = TestServer.HTTP.start(ipfamily: :inet6) + options = TestServer.HTTP.Instance.get_options(instance) assert options[:ipfamily] == :inet6 assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1} @@ -87,118 +89,122 @@ defmodule TestServerTest do end ) - assert %{host: hostname} = URI.parse(TestServer.url("/")) + assert %{host: hostname} = URI.parse(TestServer.HTTP.url("/")) assert {:ok, {0, 0, 0, 0, 0, 0, 0, 1}} == :inet.getaddr(String.to_charlist(hostname), :inet6) - assert {:ok, _} = http1_request(TestServer.url("/")) + assert {:ok, _} = http1_request(TestServer.HTTP.url("/")) end test "with invalid http server" do assert_raise RuntimeError, ~r/Invalid http_server, got: :invalid/, fn -> - TestServer.start(http_server: :invalid) + TestServer.HTTP.start(http_server: :invalid) end end end describe "stop/1" do test "when not running" do - assert_raise RuntimeError, "No current TestServer.Instance running", fn -> - TestServer.stop() + assert_raise RuntimeError, "No current TestServer.HTTP.Instance running", fn -> + TestServer.HTTP.stop() end - assert_raise RuntimeError, ~r/TestServer.Instance \#PID\<[0-9.]+\> is not running/, fn -> - {:ok, instance} = TestServer.start() + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + {:ok, instance} = TestServer.HTTP.start() - assert :ok = TestServer.stop() + assert :ok = TestServer.HTTP.stop() - TestServer.stop(instance) - end + TestServer.HTTP.stop(instance) + end end test "stops" do - assert {:ok, pid} = TestServer.start() - url = TestServer.url("/") + assert {:ok, pid} = TestServer.HTTP.start() + url = TestServer.HTTP.url("/") - assert :ok = TestServer.stop() + assert :ok = TestServer.HTTP.stop() refute Process.alive?(pid) assert {:error, {:failed_connect, _}} = http1_request(url) end test "with multiple instances" do - {:ok, instance_1} = TestServer.start() - {:ok, _instance_2} = TestServer.start() + {:ok, instance_1} = TestServer.HTTP.start() + {:ok, _instance_2} = TestServer.HTTP.start() assert_raise RuntimeError, - ~r/Multiple TestServer\.Instance's running, please pass instance to `TestServer\.stop\/0`/, + ~r/Multiple instances running, please pass instance to `TestServer\.HTTP\.stop\/0`/, fn -> - TestServer.stop() + TestServer.HTTP.stop() end - assert :ok = TestServer.stop(instance_1) - assert :ok = TestServer.stop() + assert :ok = TestServer.HTTP.stop(instance_1) + assert :ok = TestServer.HTTP.stop() end end describe "get_instance/0" do test "when not running" do - refute TestServer.get_instance() + refute TestServer.HTTP.get_instance() end test "with multiple instances" do - {:ok, _instance_1} = TestServer.start() - {:ok, _instance_2} = TestServer.start() + {:ok, _instance_1} = TestServer.HTTP.start() + {:ok, _instance_2} = TestServer.HTTP.start() - assert_raise RuntimeError, ~r/Multiple TestServer\.Instance's running./, fn -> - TestServer.get_instance() + assert_raise RuntimeError, ~r/Multiple instances running./, fn -> + TestServer.HTTP.get_instance() end end test "with instance" do - {:ok, instance} = TestServer.start() + {:ok, instance} = TestServer.HTTP.start() - assert TestServer.get_instance() == instance + assert TestServer.HTTP.get_instance() == instance end end describe "url/3" do test "when instance not running" do - assert_raise RuntimeError, "No current TestServer.Instance running", fn -> - TestServer.url() + assert_raise RuntimeError, "No current TestServer.HTTP.Instance running", fn -> + TestServer.HTTP.url() end - assert_raise RuntimeError, ~r/TestServer.Instance \#PID\<[0-9.]+\> is not running/, fn -> - {:ok, instance} = TestServer.start() + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + {:ok, instance} = TestServer.HTTP.start() - assert :ok = TestServer.stop() + assert :ok = TestServer.HTTP.stop() - TestServer.url(instance) - end + TestServer.HTTP.url(instance) + end end test "with invalid `:host`" do - TestServer.start() + TestServer.HTTP.start() assert_raise RuntimeError, ~r/Invalid host, got: :invalid/, fn -> - TestServer.url("/", host: :invalid) + TestServer.HTTP.url("/", host: :invalid) end end test "produces routes" do - TestServer.start() + TestServer.HTTP.start() - assert TestServer.url("/") =~ ~r/^http\:\/\/localhost\:[0-9]+\/$/ - refute TestServer.url("/") == TestServer.url("/path") - refute TestServer.url("/") == TestServer.url("/", host: "bad-host") + assert TestServer.HTTP.url("/") =~ ~r/^http\:\/\/localhost\:[0-9]+\/$/ + refute TestServer.HTTP.url("/") == TestServer.HTTP.url("/path") + refute TestServer.HTTP.url("/") == TestServer.HTTP.url("/", host: "bad-host") end test "with `:host`" do - TestServer.start() + TestServer.HTTP.start() assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert conn.remote_ip == {127, 0, 0, 1} assert conn.host == "custom-host" @@ -207,14 +213,14 @@ defmodule TestServerTest do end ) - assert {:ok, _} = http1_request(TestServer.url("/", host: "custom-host")) + assert {:ok, _} = http1_request(TestServer.HTTP.url("/", host: "custom-host")) end test "with `:host` in IPv6-only mode" do - TestServer.start(ipfamily: :inet6, http_server: {TestServer.HTTPServer.Httpd, []}) + TestServer.HTTP.start(ipfamily: :inet6, http_server: {TestServer.HTTP.Server.Httpd, []}) assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1} assert conn.host == "custom-host" @@ -223,57 +229,59 @@ defmodule TestServerTest do end ) - assert {:ok, _} = http1_request(TestServer.url("/", host: "custom-host")) + assert {:ok, _} = http1_request(TestServer.HTTP.url("/", host: "custom-host")) end test "with multiple instances" do - {:ok, instance_1} = TestServer.start() - {:ok, instance_2} = TestServer.start() + {:ok, instance_1} = TestServer.HTTP.start() + {:ok, instance_2} = TestServer.HTTP.start() assert_raise RuntimeError, - ~r/Multiple TestServer\.Instance's running, please pass instance to `TestServer\.url\/2`/, + ~r/Multiple instances running, please pass instance to `TestServer\.HTTP\.url\/2`/, fn -> - TestServer.url() + TestServer.HTTP.url() end - refute TestServer.url(instance_1) == TestServer.url(instance_2) + refute TestServer.HTTP.url(instance_1) == TestServer.HTTP.url(instance_2) end end describe "add/3" do test "when instance not running" do - assert_raise RuntimeError, ~r/TestServer.Instance \#PID\<[0-9.]+\> is not running/, fn -> - {:ok, instance} = TestServer.start() + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + {:ok, instance} = TestServer.HTTP.start() - assert :ok = TestServer.stop() + assert :ok = TestServer.HTTP.stop() - TestServer.add(instance, "/") - end + TestServer.HTTP.add(instance, "/") + end end test "invalid options" do assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.add("/", match: :invalid) + TestServer.HTTP.add("/", match: :invalid) end assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.add("/", to: :invalid) + TestServer.HTTP.add("/", to: :invalid) end end test "with multiple instances" do - {:ok, instance_1} = TestServer.start() - {:ok, _instance_2} = TestServer.start() + {:ok, instance_1} = TestServer.HTTP.start() + {:ok, _instance_2} = TestServer.HTTP.start() assert_raise RuntimeError, - ~r/Multiple TestServer\.Instance's running, please pass instance to `TestServer\.add\/2`/, + ~r/Multiple instances running, please pass instance to `TestServer\.HTTP\.add\/2`/, fn -> - TestServer.add("/") + TestServer.HTTP.add("/") end - assert :ok = TestServer.add(instance_1, "/") + assert :ok = TestServer.HTTP.add(instance_1, "/") - TestServer.stop(instance_1) + TestServer.HTTP.stop(instance_1) end test "with mismatching URI" do @@ -281,9 +289,9 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) - assert :ok = TestServer.add("/") - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/path")) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) + assert :ok = TestServer.HTTP.add("/") + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/path")) end end @@ -295,10 +303,10 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.add("/", via: :post) - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/")) + assert :ok = TestServer.HTTP.add("/", via: :post) + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) end end @@ -310,11 +318,11 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) - assert :ok = TestServer.add("/") + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) + assert :ok = TestServer.HTTP.add("/") - assert {:ok, _} = unquote(__MODULE__).http1_request(TestServer.url("/")) - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/?a=1")) + assert {:ok, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/?a=1")) end end @@ -328,7 +336,7 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - assert :ok = TestServer.add("/") + assert :ok = TestServer.HTTP.add("/") end end @@ -343,8 +351,8 @@ defmodule TestServerTest do def call(conn, _opts), do: Plug.Conn.resp(conn, 200, to_string(__MODULE__)) end - assert :ok = TestServer.add("/", to: ToPlug) - assert http1_request(TestServer.url("/")) == {:ok, to_string(ToPlug)} + assert :ok = TestServer.HTTP.add("/", to: ToPlug) + assert http1_request(TestServer.HTTP.url("/")) == {:ok, to_string(ToPlug)} end test "with callback function raising exception" do @@ -352,16 +360,16 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.add("/", to: fn _conn -> raise "boom" end) - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/")) + assert :ok = TestServer.HTTP.add("/", to: 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 TestServerTest.ToFunctionRaiseTest" + assert io =~ "anonymous fn/1 in TestServer.HTTPTest.ToFunctionRaiseTest" end test "with callback function halts" do @@ -369,10 +377,10 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.add("/", to: fn conn -> Plug.Conn.halt(conn) end) - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/")) + assert :ok = TestServer.HTTP.add("/", to: fn conn -> Plug.Conn.halt(conn) end) + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) end end @@ -382,46 +390,46 @@ defmodule TestServerTest do test "with callback function" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> Plug.Conn.resp(conn, 200, "function called") end ) - assert http1_request(TestServer.url("/")) == {:ok, "function called"} + assert http1_request(TestServer.HTTP.url("/")) == {:ok, "function called"} end test "with match function" do assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", match: fn %{params: %{"a" => "1"}} = _conn -> true _conn -> false end ) - assert {:ok, _} = http1_request(TestServer.url("/ignore") <> "?a=1") + assert {:ok, _} = http1_request(TestServer.HTTP.url("/ignore") <> "?a=1") end test "with :via method" do - assert :ok = TestServer.add("/", via: :get) - assert :ok = TestServer.add("/", via: :post) - assert {:ok, _} = http1_request(TestServer.url("/")) - assert {:ok, _} = http1_request(TestServer.url("/"), method: :post) + assert :ok = TestServer.HTTP.add("/", via: :get) + assert :ok = TestServer.HTTP.add("/", via: :post) + assert {:ok, _} = http1_request(TestServer.HTTP.url("/")) + assert {:ok, _} = http1_request(TestServer.HTTP.url("/"), method: :post) end # `:httpd` has no HTTP/2 support unless System.get_env("HTTP_SERVER") == "Httpd" do test "with HTTP/2" do - {:ok, _instance} = TestServer.start(scheme: :https) + {:ok, _instance} = TestServer.HTTP.start(scheme: :https) - assert :ok = TestServer.add("/") - assert {:ok, "HTTP/2"} = http2_request(TestServer.url()) + assert :ok = TestServer.HTTP.add("/") + assert {:ok, "HTTP/2"} = http2_request(TestServer.HTTP.url()) end test "with HTTP/2 with plug function" do - {:ok, _instance} = TestServer.start(scheme: :https) + {:ok, _instance} = TestServer.HTTP.start(scheme: :https) assert :ok = - TestServer.add("/", + TestServer.HTTP.add("/", to: fn conn -> assert Plug.Conn.get_http_protocol(conn) == :"HTTP/2" assert {:ok, body, _data} = Plug.Conn.read_body(conn) @@ -429,7 +437,7 @@ defmodule TestServerTest do end ) - assert {:ok, "test"} = http2_request(TestServer.url(), method: :post, body: "test") + assert {:ok, "test"} = http2_request(TestServer.HTTP.url(), method: :post, body: "test") end end end @@ -437,20 +445,21 @@ defmodule TestServerTest do describe "plug/2" do test "with invalid plug" do assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.plug(:invalid) + TestServer.HTTP.plug(:invalid) end end test "with plug function" do assert :ok = - TestServer.plug(fn conn -> + TestServer.HTTP.plug(fn conn -> assert {:ok, body, _data} = Plug.Conn.read_body(conn) %{conn | params: %{"plug" => "anonymous function", body: body}} end) - assert :ok = TestServer.add("/", to: &Plug.Conn.resp(&1, 200, URI.encode_query(&1.params))) + assert :ok = + TestServer.HTTP.add("/", to: &Plug.Conn.resp(&1, 200, URI.encode_query(&1.params))) - assert {:ok, query} = http1_request(TestServer.url("/")) + assert {:ok, query} = http1_request(TestServer.HTTP.url("/")) assert URI.decode_query(query) == %{"plug" => "anonymous function", "body" => ""} end @@ -461,9 +470,9 @@ defmodule TestServerTest do def call(conn, _opts), do: %{conn | params: %{"plug" => to_string(__MODULE__)}} end - assert :ok = TestServer.plug(ModulePlug) - assert :ok = TestServer.add("/", to: &Plug.Conn.resp(&1, 200, &1.params["plug"])) - assert http1_request(TestServer.url("/")) == {:ok, to_string(ModulePlug)} + assert :ok = TestServer.HTTP.plug(ModulePlug) + assert :ok = TestServer.HTTP.add("/", to: &Plug.Conn.resp(&1, 200, &1.params["plug"])) + assert http1_request(TestServer.HTTP.url("/")) == {:ok, to_string(ModulePlug)} end test "when plug errors" do @@ -471,16 +480,16 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.plug(fn _conn -> raise "boom" end) - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/")) + assert :ok = TestServer.HTTP.plug(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 TestServerTest.PlugFunctionRaiseTest" + assert io =~ "anonymous fn/1 in TestServer.HTTPTest.PlugFunctionRaiseTest" end test "when plug function halts" do @@ -488,11 +497,11 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert :ok = TestServer.plug(fn conn -> Plug.Conn.halt(conn) end) - assert :ok = TestServer.add("/") - assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/")) + assert :ok = TestServer.HTTP.plug(fn conn -> Plug.Conn.halt(conn) end) + assert :ok = TestServer.HTTP.add("/") + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.HTTP.url("/")) end end @@ -503,26 +512,28 @@ defmodule TestServerTest do describe "x509_suite/0" do test "when instance not running" do - assert_raise RuntimeError, "No current TestServer.Instance running", fn -> - TestServer.x509_suite() + assert_raise RuntimeError, "No current TestServer.HTTP.Instance running", fn -> + TestServer.HTTP.x509_suite() end - assert_raise RuntimeError, ~r/TestServer\.Instance \#PID\<[0-9.]+\> is not running/, fn -> - {:ok, instance} = TestServer.start() + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + {:ok, instance} = TestServer.HTTP.start() - assert :ok = TestServer.stop() + assert :ok = TestServer.HTTP.stop() - TestServer.x509_suite(instance) - end + TestServer.HTTP.x509_suite(instance) + end end test "when instance not running in http" do - TestServer.start() + TestServer.HTTP.start() assert_raise RuntimeError, - ~r/TestServer\.Instance \#PID\<[0-9.]+\> is not running with `\[scheme: :https\]` option/, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running with `\[scheme: :https\]` option/, fn -> - TestServer.x509_suite() + TestServer.HTTP.x509_suite() end end @@ -535,12 +546,12 @@ defmodule TestServerTest do cacerts: suite.chain ++ suite.cacerts ] - TestServer.start(scheme: :https, tls: tls_options) + TestServer.HTTP.start(scheme: :https, tls: tls_options) assert_raise RuntimeError, - ~r/TestServer\.Instance \#PID\<[0-9.]+\> is running with custom SSL/, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is running with custom SSL/, fn -> - TestServer.x509_suite() + TestServer.HTTP.x509_suite() end end end @@ -549,54 +560,56 @@ defmodule TestServerTest do unless System.get_env("HTTP_SERVER") == "Httpd" do describe "websocket_init/3" do test "when instance not running" do - {:ok, instance} = TestServer.start() - assert :ok = TestServer.stop() + {:ok, instance} = TestServer.HTTP.start() + assert :ok = TestServer.HTTP.stop() - assert_raise RuntimeError, ~r/TestServer\.Instance \#PID\<[0-9.]+\> is not running/, fn -> - TestServer.websocket_init(instance, "/ws") - end + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + TestServer.HTTP.websocket_init(instance, "/ws") + end end test "invalid options" do assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.websocket_init("/", to: :invalid) + TestServer.HTTP.websocket_init("/", to: :invalid) end assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.websocket_init("/", match: :invalid) + TestServer.HTTP.websocket_init("/", match: :invalid) end assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.websocket_init("/", match: :invalid) + TestServer.HTTP.websocket_init("/", match: :invalid) end end test "with multiple instances" do - {:ok, _instance_1} = TestServer.start() - {:ok, _instance_2} = TestServer.start() + {:ok, _instance_1} = TestServer.HTTP.start() + {:ok, _instance_2} = TestServer.HTTP.start() assert_raise RuntimeError, - ~r/Multiple TestServer\.Instance's running, please pass instance to `TestServer\.websocket_init\/2`/, + ~r/Multiple instances running, please pass instance to `TestServer\.HTTP\.websocket_init\/2`/, fn -> - TestServer.websocket_init("/ws") + TestServer.HTTP.websocket_init("/ws") end end test "with handshake callback function with set conn" do assert {:ok, _socket} = - TestServer.websocket_init("/ws", + TestServer.HTTP.websocket_init("/ws", to: fn conn -> Plug.Conn.resp(conn, 403, "Forbidden") end ) assert {:error, %WebSockex.RequestError{code: 403}} = - WebSocketClient.start_link(TestServer.url("/ws")) + WebSocketClient.start_link(TestServer.HTTP.url("/ws")) end test "with handshake callback function with unset conn" do assert {:ok, _socket} = - TestServer.websocket_init("/ws", + TestServer.HTTP.websocket_init("/ws", to: fn conn -> assert Plug.Conn.get_req_header(conn, "upgrade") == ["websocket"] @@ -604,33 +617,35 @@ defmodule TestServerTest do end ) - assert {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws")) + assert {:ok, _client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) end end describe "websocket_handle/3" do test "when instance not running" do - {:ok, instance} = TestServer.start() - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert :ok = TestServer.stop(instance) + {:ok, instance} = TestServer.HTTP.start() + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert :ok = TestServer.HTTP.stop(instance) - assert_raise RuntimeError, ~r/TestServer\.Instance \#PID\<[0-9.]+\> is not running/, fn -> - TestServer.websocket_handle(socket) - end + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + TestServer.HTTP.websocket_handle(socket) + end end test "invalid options" do - assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.websocket_handle(socket, to: :invalid) + TestServer.HTTP.websocket_handle(socket, to: :invalid) end assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> - TestServer.websocket_handle(socket, match: :invalid) + TestServer.HTTP.websocket_handle(socket, match: :invalid) end - TestServer.stop() + TestServer.HTTP.stop() end test "with no message received" do @@ -638,9 +653,9 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws")) - assert :ok = TestServer.websocket_handle(socket) + 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 end @@ -653,10 +668,10 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert {:ok, _socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + assert {:ok, _socket} = TestServer.HTTP.websocket_init("/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" @@ -673,11 +688,11 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) - assert :ok = TestServer.websocket_handle(socket) + 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 WebSocketClient.send_message(client, "ping") == {:ok, "ping"} @@ -697,12 +712,12 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + {: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.websocket_handle(socket, + TestServer.HTTP.websocket_handle(socket, to: fn _frame, _state -> raise "boom" end ) @@ -713,7 +728,7 @@ defmodule TestServerTest do assert io = capture_io(fn -> ExUnit.run() end) assert io =~ "(RuntimeError) boom" - assert io =~ "anonymous fn/2 in TestServerTest.WebSocketHandleToFunctionRaiseTest" + assert io =~ "anonymous fn/2 in TestServer.HTTPTest.WebSocketHandleToFunctionRaiseTest" end test "with callback function with invalid response" do @@ -721,13 +736,15 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, _instance} = TestServer.HTTP.start(suppress_warning: true) - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = - TestServer.websocket_handle(socket, to: fn _frame, _state -> :invalid end) + TestServer.HTTP.websocket_handle(socket, + to: fn _frame, _state -> :invalid end + ) assert {:ok, msg} = WebSocketClient.send_message(client, "ping") assert msg =~ "(RuntimeError) Invalid callback response, got: :invalid." @@ -739,11 +756,11 @@ defmodule TestServerTest do end test "with callback function" do - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = - TestServer.websocket_handle(socket, + TestServer.HTTP.websocket_handle(socket, to: fn {:text, _any}, state -> {:reply, {:text, "function called"}, state} end ) @@ -751,11 +768,13 @@ defmodule TestServerTest do end test "with match function" do - assert {:ok, socket} = TestServer.websocket_init("/ws", init_state: %{custom: true}) - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + 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.websocket_handle(socket, + TestServer.HTTP.websocket_handle(socket, match: fn _frame, %{custom: true} -> true end @@ -767,13 +786,15 @@ defmodule TestServerTest do describe "websocket_info/2" do test "when instance not running" do - {:ok, instance} = TestServer.start() - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert :ok = TestServer.stop(instance) + {:ok, instance} = TestServer.HTTP.start() + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert :ok = TestServer.HTTP.stop(instance) - assert_raise RuntimeError, ~r/TestServer\.Instance \#PID\<[0-9.]+\> is not running/, fn -> - TestServer.websocket_info(socket) - end + assert_raise RuntimeError, + ~r/TestServer\.HTTP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + TestServer.HTTP.websocket_info(socket) + end end test "with invalid callback response" do @@ -781,10 +802,10 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) - assert :ok = TestServer.websocket_info(socket, fn _state -> :invalid end) + {: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, message} = WebSocketClient.receive_message(client) assert message =~ "(RuntimeError) Invalid callback response, got: :invalid." @@ -800,11 +821,12 @@ defmodule TestServerTest do use ExUnit.Case test "fails" do - {:ok, _instance} = TestServer.start(suppress_warning: true) - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + {: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.websocket_info(socket, fn _state -> raise "boom" end) + assert :ok = + TestServer.HTTP.websocket_info(socket, fn _state -> raise "boom" end) assert {:ok, message} = WebSocketClient.receive_message(client) assert message =~ "(RuntimeError) boom" @@ -813,15 +835,15 @@ defmodule TestServerTest do assert io = capture_io(fn -> ExUnit.run() end) assert io =~ "(RuntimeError) boom" - assert io =~ "anonymous fn/1 in TestServerTest.WebSocketInfoToFunctionRaiseTest" + assert io =~ "anonymous fn/1 in TestServer.HTTPTest.WebSocketInfoToFunctionRaiseTest" end test "with callback function" do - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) assert :ok = - TestServer.websocket_info(socket, fn state -> + TestServer.HTTP.websocket_info(socket, fn state -> {:reply, {:text, "pong"}, state} end) @@ -829,10 +851,10 @@ defmodule TestServerTest do end test "with default callback function" do - assert {:ok, socket} = TestServer.websocket_init("/ws") - assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + assert {:ok, socket} = TestServer.HTTP.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.HTTP.url("/ws")) - assert :ok = TestServer.websocket_info(socket) + assert :ok = TestServer.HTTP.websocket_info(socket) assert {:ok, "ping"} = WebSocketClient.receive_message(client) end end @@ -897,7 +919,7 @@ defmodule TestServerTest do pools = %{ default: [ protocols: [:http2], - conn_opts: [transport_opts: [cacerts: TestServer.x509_suite().cacerts]] + conn_opts: [transport_opts: [cacerts: TestServer.HTTP.x509_suite().cacerts]] ] } From 425387f79c64a69644186bac8e4053131b32097b Mon Sep 17 00:00:00 2001 From: Richard Ash Date: Fri, 6 Mar 2026 12:56:42 -0800 Subject: [PATCH 2/3] Add TestServer.SSH --- README.md | 46 ++++++ lib/test_server/ssh.ex | 200 +++++++++++++++++++++++ lib/test_server/ssh/channel.ex | 120 ++++++++++++++ lib/test_server/ssh/instance.ex | 239 ++++++++++++++++++++++++++++ lib/test_server/ssh/key_api.ex | 23 +++ mix.exs | 8 +- test/support/ssh_client.ex | 48 ++++++ test/test_helper.exs | 3 + test/test_server/ssh_test.exs | 272 ++++++++++++++++++++++++++++++++ 9 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 lib/test_server/ssh.ex create mode 100644 lib/test_server/ssh/channel.ex create mode 100644 lib/test_server/ssh/instance.ex create mode 100644 lib/test_server/ssh/key_api.ex create mode 100644 test/support/ssh_client.ex create mode 100644 test/test_server/ssh_test.exs diff --git a/README.md b/README.md index b332994..c3f5c00 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Features: - HTTP/1 - HTTP/2 - WebSocket +- SSH - Built-in TLS with self-signed certificates - Plug route matching @@ -140,6 +141,51 @@ end *Note: WebSocket is not supported by the `:httpd` adapter.* +### SSH + +SSH exec and shell handlers can be registered with `TestServer.SSH.exec/1` and `TestServer.SSH.shell/1`. The server autostarts on first use and is torn down when the test exits. + +```elixir +test "run remote command" do + TestServer.SSH.exec(to: fn _cmd, state -> + {:reply, {0, "file1\nfile2\n", ""}, state} + end) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = :ssh.connect(String.to_charlist(host), port, + user: ~c"test", + silently_accept_hosts: true, + user_interaction: false + ) + + {:ok, ch} = :ssh_connection.session_channel(conn, :infinity) + :success = :ssh_connection.exec(conn, ch, ~c"ls", :infinity) + # collect stdout, exit_status, closed messages... +end +``` + +Password and public key credentials can be set with the `:credentials` option: + +```elixir +# Password auth +TestServer.SSH.start(credentials: [{"alice", "secret"}]) + +# Public key auth +TestServer.SSH.start(credentials: [{"bob", :public_key, pem_binary}]) + +# No auth (default — accepts any connection) +TestServer.SSH.start() +``` + +Handlers are matched FIFO and can be filtered with `:match`: + +```elixir +TestServer.SSH.exec( + match: fn cmd, _state -> cmd == "ls" end, + to: fn _cmd, state -> {:reply, {0, "file1\n", ""}, state} end +) +``` + ### HTTP Server Adapter TestServer supports `Bandit`, `Plug.Cowboy`, and `:httpd` out of the box. The HTTP adapter will be selected in this order depending which is available in the dependencies. You can also explicitly set the http server in the configuration when calling `TestServer.HTTP.start/1`: diff --git a/lib/test_server/ssh.ex b/lib/test_server/ssh.ex new file mode 100644 index 0000000..dd0e0ca --- /dev/null +++ b/lib/test_server/ssh.ex @@ -0,0 +1,200 @@ +defmodule TestServer.SSH do + @moduledoc false + + alias TestServer.{InstanceManager, SSH} + + @spec start(keyword()) :: {:ok, pid()} + def start(options \\ []) do + case ExUnit.fetch_test_supervisor() do + {:ok, _sup} -> start_with_ex_unit(options) + :error -> raise ArgumentError, "can only be called in a test process" + end + end + + defp start_with_ex_unit(options) do + [_first_module_entry | stacktrace] = get_stacktrace() + caller = self() + + options = + options + |> Keyword.put_new(:caller, caller) + |> Keyword.put_new(:stacktrace, stacktrace) + + case InstanceManager.start_instance(caller, SSH.Instance.child_spec(options)) do + {:ok, instance} -> + put_ex_unit_on_exit_callback(instance) + {:ok, instance} + + {:error, error} -> + raise_start_failure({:error, error}) + end + end + + defp put_ex_unit_on_exit_callback(instance) do + ExUnit.Callbacks.on_exit(fn -> + if Process.alive?(instance) do + verify_handlers!(:exec, instance) + verify_handlers!(:shell, instance) + stop(instance) + end + end) + end + + defp verify_handlers!(type, instance) do + handlers_fn = + if type == :exec, do: &SSH.Instance.exec_handlers/1, else: &SSH.Instance.shell_handlers/1 + + instance + |> handlers_fn.() + |> Enum.reject(& &1.suspended) + |> case do + [] -> + :ok + + active -> + raise """ + #{SSH.Instance.format_instance(instance)} did not receive #{type} requests for these handlers before the test ended: + + #{SSH.Instance.format_handlers(active)} + """ + end + end + + @spec stop(pid()) :: :ok | {:error, term()} + def stop(instance) do + instance_alive!(instance) + InstanceManager.stop_instance(instance) + end + + @spec address() :: {binary(), :inet.port_number()} + def address, do: address(fetch_instance!()) + + @spec address(pid()) :: {binary(), :inet.port_number()} + def address(instance) do + instance_alive!(instance) + options = SSH.Instance.get_options(instance) + {"localhost", Keyword.fetch!(options, :port)} + end + + @spec exec(keyword()) :: :ok + def exec(options) when is_list(options) do + {:ok, instance} = autostart() + exec(instance, options) + end + + @spec exec(pid(), keyword()) :: :ok + def exec(instance, options) when is_pid(instance) and is_list(options) do + instance_alive!(instance) + [_first_module_entry | stacktrace] = get_stacktrace() + options = Keyword.put_new(options, :to, &default_exec_handler/2) + {:ok, _handler} = SSH.Instance.register(instance, {:exec, options, stacktrace}) + :ok + end + + defp default_exec_handler(_cmd, state), do: {:reply, {0, "", ""}, state} + + @spec shell(keyword()) :: :ok + def shell(options) when is_list(options) do + {:ok, instance} = autostart() + shell(instance, options) + end + + @spec shell(pid(), keyword()) :: :ok + def shell(instance, options) when is_pid(instance) and is_list(options) do + instance_alive!(instance) + [_first_module_entry | stacktrace] = get_stacktrace() + options = Keyword.put_new(options, :to, &default_shell_handler/2) + {:ok, _handler} = SSH.Instance.register(instance, {:shell, options, stacktrace}) + :ok + end + + defp default_shell_handler(data, state), do: {:reply, data, state} + + defp autostart do + case fetch_instance() do + :error -> start() + {:ok, instance} -> {:ok, instance} + end + end + + defp fetch_instance! do + case fetch_instance() do + :error -> raise "No current #{inspect(SSH.Instance)} running" + {:ok, instance} -> instance + end + end + + defp fetch_instance do + instances = InstanceManager.get_by_caller(self()) || [] + ssh_instances = Enum.filter(instances, &ssh_instance?/1) + + case ssh_instances do + [] -> + :error + + [instance] -> + {:ok, instance} + + [_first | _rest] = multiple -> + [{m, f, a, _} | _] = get_stacktrace() + + formatted = + multiple + |> Enum.map(&{&1, SSH.Instance.get_options(&1)}) + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {{instance, options}, index} -> + """ + ##{index + 1}: #{SSH.Instance.format_instance(instance)} + #{Enum.map_join(options[:stacktrace], "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + + raise """ + Multiple #{inspect(SSH.Instance)}'s running, please pass instance to `#{inspect(m)}.#{f}/#{a}`. + + #{formatted} + """ + end + end + + defp ssh_instance?(pid) do + SSH.Instance.get_options(pid)[:protocol] == :ssh + rescue + _ -> false + end + + defp instance_alive!(instance) do + unless Process.alive?(instance), + do: raise("#{SSH.Instance.format_instance(instance)} is not running") + end + + defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do + raise_start_failure({:error, reason}) + end + + defp raise_start_failure({:error, error}) do + raise """ + EXIT when starting #{inspect(SSH.Instance)}: + + #{Exception.format_exit(error)} + """ + end + + defp get_stacktrace do + {:current_stacktrace, [{Process, :info, _, _} | stacktrace]} = + Process.info(self(), :current_stacktrace) + + first_module_entry = + stacktrace + |> Enum.reverse() + |> Enum.find(fn {mod, _, _, _} -> mod == __MODULE__ end) + + [first_module_entry] ++ prune_stacktrace(stacktrace) + end + + defp prune_stacktrace([{__MODULE__, _, _, _} | t]), do: prune_stacktrace(t) + defp prune_stacktrace([{ExUnit.Assertions, _, _, _} | t]), do: prune_stacktrace(t) + defp prune_stacktrace([{ExUnit.Runner, _, _, _} | _]), do: [] + defp prune_stacktrace([h | t]), do: [h | prune_stacktrace(t)] + defp prune_stacktrace([]), do: [] +end diff --git a/lib/test_server/ssh/channel.ex b/lib/test_server/ssh/channel.ex new file mode 100644 index 0000000..0d01def --- /dev/null +++ b/lib/test_server/ssh/channel.ex @@ -0,0 +1,120 @@ +defmodule TestServer.SSH.Channel do + @moduledoc false + + @behaviour :ssh_server_channel + + alias TestServer.SSH.Instance + + defstruct [:instance, :channel_id, :connection, type: nil, handler_state: %{}] + + @impl true + def init(instance: instance) do + {:ok, %__MODULE__{instance: instance}} + end + + @impl true + def handle_msg({:ssh_channel_up, channel_id, connection}, state) do + {:ok, %{state | channel_id: channel_id, connection: connection}} + end + + def handle_msg(_msg, state) do + {:ok, state} + end + + @impl true + def handle_ssh_msg({:ssh_cm, conn, {:exec, ch_id, want_reply, command}}, state) do + command = to_string(command) + :ssh_connection.reply_request(conn, want_reply, :success, ch_id) + + case GenServer.call(state.instance, {:dispatch, {:exec, command, state.handler_state}}) do + {:ok, {:reply, {exit_code, stdout, stderr}, new_handler_state}} -> + unless IO.iodata_length(stdout) == 0, + do: :ssh_connection.send(conn, ch_id, stdout) + + unless IO.iodata_length(stderr) == 0, + do: :ssh_connection.send(conn, ch_id, 1, stderr) + + :ssh_connection.exit_status(conn, ch_id, exit_code) + :ssh_connection.send_eof(conn, ch_id) + :ssh_connection.close(conn, ch_id) + {:stop, ch_id, %{state | handler_state: new_handler_state}} + + {:ok, {:ok, new_handler_state}} -> + :ssh_connection.exit_status(conn, ch_id, 0) + :ssh_connection.send_eof(conn, ch_id) + :ssh_connection.close(conn, ch_id) + {:stop, ch_id, %{state | handler_state: new_handler_state}} + + {:error, :not_found} -> + message = + "#{Instance.format_instance(state.instance)} received an unexpected SSH exec request: #{inspect(command)}" + + report_error_and_close_exec(conn, ch_id, state, RuntimeError.exception(message), []) + + {:error, {exception, stacktrace}} -> + report_error_and_close_exec(conn, ch_id, state, exception, stacktrace) + end + end + + def handle_ssh_msg({:ssh_cm, conn, {:shell, ch_id, want_reply}}, state) do + :ssh_connection.reply_request(conn, want_reply, :success, ch_id) + {:ok, %{state | type: :shell, channel_id: ch_id, connection: conn}} + end + + def handle_ssh_msg({:ssh_cm, conn, {:data, ch_id, 0, data}}, %{type: :shell} = state) do + :ssh_connection.adjust_window(conn, ch_id, byte_size(data)) + + case GenServer.call(state.instance, {:dispatch, {:shell, data, state.handler_state}}) do + {:ok, {:reply, output, new_handler_state}} -> + :ssh_connection.send(conn, ch_id, output) + {:ok, %{state | handler_state: new_handler_state}} + + {:ok, {:ok, new_handler_state}} -> + {:ok, %{state | handler_state: new_handler_state}} + + {:error, :not_found} -> + message = + "#{Instance.format_instance(state.instance)} received unexpected SSH shell data: #{inspect(data)}" + + Instance.report_error(state.instance, {RuntimeError.exception(message), []}) + {:ok, state} + + {:error, {exception, stacktrace}} -> + Instance.report_error(state.instance, {exception, stacktrace}) + {:ok, state} + end + end + + def handle_ssh_msg({:ssh_cm, conn, {:pty, ch_id, want_reply, _pty_info}}, state) do + :ssh_connection.reply_request(conn, want_reply, :success, ch_id) + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, conn, {:env, ch_id, want_reply, _name, _value}}, state) do + :ssh_connection.reply_request(conn, want_reply, :success, ch_id) + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _conn, {:eof, _ch_id}}, state) do + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _conn, {:closed, ch_id}}, state) do + {:stop, ch_id, state} + end + + def handle_ssh_msg(_msg, state) do + {:ok, state} + end + + @impl true + def terminate(_reason, _state), do: :ok + + defp report_error_and_close_exec(conn, ch_id, state, exception, stacktrace) do + Instance.report_error(state.instance, {exception, stacktrace}) + :ssh_connection.exit_status(conn, ch_id, 1) + :ssh_connection.send_eof(conn, ch_id) + :ssh_connection.close(conn, ch_id) + {:stop, ch_id, state} + end +end diff --git a/lib/test_server/ssh/instance.ex b/lib/test_server/ssh/instance.ex new file mode 100644 index 0000000..c466f35 --- /dev/null +++ b/lib/test_server/ssh/instance.ex @@ -0,0 +1,239 @@ +defmodule TestServer.SSH.Instance do + @moduledoc false + + use GenServer + + def start_link(options) do + GenServer.start_link(__MODULE__, options) + end + + def register(instance, {:exec, options, stacktrace}) do + options[:match] && ensure_function!(options[:match], 2) + ensure_function!(Keyword.fetch!(options, :to), 2) + GenServer.call(instance, {:register, {:exec, options, stacktrace}}) + end + + def register(instance, {:shell, options, stacktrace}) do + options[:match] && ensure_function!(options[:match], 2) + ensure_function!(Keyword.fetch!(options, :to), 2) + GenServer.call(instance, {:register, {:shell, options, stacktrace}}) + end + + defp ensure_function!(fun, arity) when is_function(fun, arity), do: :ok + defp ensure_function!(fun, _arity), do: raise(BadFunctionError, term: fun) + + def get_options(instance) do + GenServer.call(instance, :options) + end + + def exec_handlers(instance) do + GenServer.call(instance, :exec_handlers) + end + + def shell_handlers(instance) do + GenServer.call(instance, :shell_handlers) + end + + def format_instance(instance) do + "#{inspect(__MODULE__)} #{inspect(instance)}" + end + + def format_handlers(handlers) do + handlers + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {handler, index} -> + """ + ##{index + 1}: #{inspect(handler.to)} + #{Enum.map_join(handler.stacktrace, "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + end + + def report_error(instance, {exception, stacktrace}) do + options = get_options(instance) + caller = Keyword.fetch!(options, :caller) + + unless Keyword.get(options, :suppress_warning, false), + do: IO.warn(Exception.format(:error, exception, stacktrace)) + + ExUnit.OnExitHandler.add(caller, make_ref(), fn -> + reraise exception, stacktrace + end) + + :ok + end + + @impl true + def init(options) do + Process.flag(:trap_exit, true) + + host_key = :public_key.generate_key({:rsa, 2048, 65_537}) + port = Keyword.get(options, :port, 0) + credentials = options[:credentials] + instance = self() + + daemon_opts = build_daemon_opts(instance, host_key, credentials) + + case :ssh.daemon(port, daemon_opts) do + {:ok, daemon_ref} -> + {:ok, info} = :ssh.daemon_info(daemon_ref) + actual_port = :proplists.get_value(:port, info) + + options = + options + |> Keyword.put(:port, actual_port) + |> Keyword.put(:ip, {127, 0, 0, 1}) + |> Keyword.put(:protocol, :ssh) + + {:ok, + %{ + options: options, + host_key: host_key, + daemon_ref: daemon_ref, + exec_handlers: [], + shell_handlers: [] + }} + + {:error, reason} -> + {:stop, reason} + end + end + + defp build_daemon_opts(instance, host_key, credentials) do + base = [ + system_dir: String.to_charlist(System.tmp_dir!()), + key_cb: {TestServer.SSH.KeyAPI, [instance: instance, host_key: host_key]}, + ssh_cli: {TestServer.SSH.Channel, [instance: instance]}, + subsystems: [], + auth_methods: ~c"password,publickey" + ] + + if credentials do + Keyword.put(base, :pwdfun, fn user, pass -> + GenServer.call(instance, {:check_password, to_string(user), to_string(pass)}) + end) + else + base + |> Keyword.put(:no_auth_needed, true) + |> Keyword.delete(:auth_methods) + end + end + + @impl true + def handle_call({:register, {:exec, options, stacktrace}}, _from, state) do + handler = build_handler(options, stacktrace) + {:reply, {:ok, handler}, %{state | exec_handlers: state.exec_handlers ++ [handler]}} + end + + def handle_call({:register, {:shell, options, stacktrace}}, _from, state) do + handler = build_handler(options, stacktrace) + {:reply, {:ok, handler}, %{state | shell_handlers: state.shell_handlers ++ [handler]}} + end + + def handle_call({:dispatch, {:exec, command, chan_state}}, _from, state) do + {result, state} = dispatch(state.exec_handlers, command, chan_state, state, :exec_handlers) + {:reply, result, state} + end + + def handle_call({:dispatch, {:shell, data, chan_state}}, _from, state) do + {result, state} = dispatch(state.shell_handlers, data, chan_state, state, :shell_handlers) + {:reply, result, state} + end + + def handle_call({:check_password, user, pass}, _from, state) do + result = + Enum.any?(state.options[:credentials] || [], fn + {^user, ^pass} -> true + _ -> false + end) + + {:reply, result, state} + end + + def handle_call({:is_auth_key, user, pub_key}, _from, state) do + result = + Enum.any?(state.options[:credentials] || [], fn + {^user, :public_key, pem} -> + case :public_key.pem_decode(pem) do + [{_type, _der, :not_encrypted} = entry] -> + :public_key.pem_entry_decode(entry) == pub_key + + _ -> + false + end + + _ -> + false + end) + + {:reply, result, state} + end + + def handle_call(key, _from, state) when key in [:options, :exec_handlers, :shell_handlers] do + {:reply, Map.fetch!(state, key), state} + end + + @impl true + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + :ssh.stop_daemon(state.daemon_ref) + end + + defp build_handler(options, stacktrace) do + %{ + ref: make_ref(), + match: options[:match], + to: Keyword.fetch!(options, :to), + stacktrace: stacktrace, + suspended: false, + received: [] + } + end + + defp dispatch(handlers, input, chan_state, state, key) do + handlers + |> Enum.find_index(fn + %{suspended: true} -> false + %{match: nil} -> true + %{match: match} -> try_match(match, input, chan_state) + end) + |> case do + nil -> + {{:error, :not_found}, state} + + index -> + %{to: handler} = Enum.at(handlers, index) + result = try_handler(handler, input, chan_state) + + updated_handlers = + List.update_at(handlers, index, fn h -> + %{h | suspended: true, received: h.received ++ [input]} + end) + + {result, Map.put(state, key, updated_handlers)} + end + end + + defp try_match(match, input, chan_state) do + match.(input, chan_state) + rescue + _ -> false + end + + defp try_handler(handler, input, chan_state) do + case handler.(input, chan_state) do + {:reply, _, _} = reply -> + {:ok, reply} + + {:ok, _} = ok -> + {:ok, ok} + + other -> + {:error, {RuntimeError.exception("Invalid handler response: #{inspect(other)}"), []}} + end + rescue + error -> {:error, {error, __STACKTRACE__}} + end +end diff --git a/lib/test_server/ssh/key_api.ex b/lib/test_server/ssh/key_api.ex new file mode 100644 index 0000000..9af0a5e --- /dev/null +++ b/lib/test_server/ssh/key_api.ex @@ -0,0 +1,23 @@ +defmodule TestServer.SSH.KeyAPI do + @moduledoc false + + @behaviour :ssh_server_key_api + + @impl true + def host_key(algorithm, daemon_options) do + private = Keyword.get(daemon_options, :key_cb_private, []) + host_key = Keyword.fetch!(private, :host_key) + + if algorithm in [:"ssh-rsa", :"rsa-sha2-256", :"rsa-sha2-512"] do + {:ok, host_key} + else + {:error, {:unsupported_algorithm, algorithm}} + end + end + + @impl true + def is_auth_key(public_key, user, daemon_options) do + instance = daemon_options |> Keyword.get(:key_cb_private, []) |> Keyword.fetch!(:instance) + GenServer.call(instance, {:is_auth_key, to_string(user), public_key}) + end +end diff --git a/mix.exs b/mix.exs index a23b92f..0dc5a2a 100644 --- a/mix.exs +++ b/mix.exs @@ -28,7 +28,7 @@ defmodule TestServer.MixProject do def application do [ - extra_applications: [:logger, :crypto, :public_key, :inets], + extra_applications: [:logger, :crypto, :public_key, :inets, :ssh], mod: {TestServer.Application, []} ] end @@ -88,6 +88,12 @@ defmodule TestServer.MixProject do TestServer.HTTP.Server.Httpd, TestServer.HTTP.Server.Bandit, TestServer.HTTP.Server.Cowboy + ], + SSH: [ + TestServer.SSH, + TestServer.SSH.Instance, + TestServer.SSH.KeyAPI, + TestServer.SSH.Channel ] ] ] diff --git a/test/support/ssh_client.ex b/test/support/ssh_client.ex new file mode 100644 index 0000000..601e1c3 --- /dev/null +++ b/test/support/ssh_client.ex @@ -0,0 +1,48 @@ +defmodule TestServer.SSHClient do + @moduledoc false + + def connect(host, port, opts \\ []) do + defaults = [ + user_interaction: false, + silently_accept_hosts: true, + save_accepted_host: false + ] + + :ssh.connect(String.to_charlist(host), port, Keyword.merge(defaults, opts)) + end + + def exec(conn, command) do + {:ok, ch} = :ssh_connection.session_channel(conn, :infinity) + :success = :ssh_connection.exec(conn, ch, String.to_charlist(command), :infinity) + collect_exec(ch, {0, "", ""}) + end + + def open_shell(conn) do + {:ok, ch} = :ssh_connection.session_channel(conn, :infinity) + :ok = :ssh_connection.shell(conn, ch) + ch + end + + def send(conn, ch, data) do + :ssh_connection.send(conn, ch, data) + end + + def recv(ch, timeout \\ 5_000) do + receive do + {:ssh_cm, _, {:data, ^ch, 0, data}} -> {:ok, data} + after + timeout -> {:error, :timeout} + end + end + + defp collect_exec(ch, {code, out, err}) do + receive do + {:ssh_cm, _, {:data, ^ch, 0, data}} -> collect_exec(ch, {code, out <> data, err}) + {:ssh_cm, _, {:data, ^ch, 1, data}} -> collect_exec(ch, {code, out, err <> data}) + {:ssh_cm, _, {:exit_status, ^ch, c}} -> collect_exec(ch, {c, out, err}) + {:ssh_cm, _, {:closed, ^ch}} -> {code, out, err} + after + 5_000 -> {:error, :timeout} + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index d7aaebf..41f0057 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,8 @@ ExUnit.start() +# Suppress verbose SSH application logs (debug KEX ordering, notice disconnect messages) +Logger.configure(level: :warning) + http_server = System.get_env("HTTP_SERVER", "Bandit") # This ensures that `Bandit.Clock` has started and prevents diff --git a/test/test_server/ssh_test.exs b/test/test_server/ssh_test.exs new file mode 100644 index 0000000..fe79031 --- /dev/null +++ b/test/test_server/ssh_test.exs @@ -0,0 +1,272 @@ +defmodule TestServer.SSHTest do + use ExUnit.Case + + import ExUnit.CaptureIO + + alias TestServer.SSHClient + + describe "start/1" do + test "auto-assigns port" do + {:ok, instance} = TestServer.SSH.start() + {_host, port} = TestServer.SSH.address(instance) + assert is_integer(port) and port > 0 + end + + test "multiple independent instances have different ports" do + {:ok, instance_1} = TestServer.SSH.start() + {:ok, instance_2} = TestServer.SSH.start() + {_, port1} = TestServer.SSH.address(instance_1) + {_, port2} = TestServer.SSH.address(instance_2) + refute port1 == port2 + end + end + + describe "exec/1" do + test "default handler returns exit 0 with empty output" do + TestServer.SSH.exec([]) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + assert SSHClient.exec(conn, "anything") == {0, "", ""} + :ssh.close(conn) + end + + test "to: function receives command and returns output" do + TestServer.SSH.exec(to: fn cmd, state -> {:reply, {0, "got: #{cmd}", ""}, state} end) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + assert SSHClient.exec(conn, "ls") == {0, "got: ls", ""} + :ssh.close(conn) + end + + test "returns exit code and stderr" do + TestServer.SSH.exec(to: fn _cmd, state -> {:reply, {1, "", "error\n"}, state} end) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + assert SSHClient.exec(conn, "fail") == {1, "", "error\n"} + :ssh.close(conn) + end + + test "match: filters which handler runs" do + TestServer.SSH.exec( + match: fn cmd, _state -> cmd == "ls" end, + to: fn _cmd, state -> {:reply, {0, "matched\n", ""}, state} end + ) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + assert SSHClient.exec(conn, "ls") == {0, "matched\n", ""} + :ssh.close(conn) + end + + test "FIFO consumption order" do + TestServer.SSH.exec(to: fn _cmd, state -> {:reply, {0, "first\n", ""}, state} end) + TestServer.SSH.exec(to: fn _cmd, state -> {:reply, {0, "second\n", ""}, state} end) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + assert SSHClient.exec(conn, "cmd") == {0, "first\n", ""} + assert SSHClient.exec(conn, "cmd") == {0, "second\n", ""} + :ssh.close(conn) + end + + test "unmatched request reports error at test exit" do + defmodule UnmatchedExecTest do + use ExUnit.Case + + test "fails" do + TestServer.SSH.exec( + match: fn cmd, _state -> cmd == "ls" end, + to: fn _cmd, state -> {:reply, {0, "ok", ""}, state} end + ) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = TestServer.SSHClient.connect(host, port, user: ~c"test") + TestServer.SSHClient.exec(conn, "not-ls") + :ssh.close(conn) + end + end + + log = + capture_io(:stderr, fn -> + capture_io(fn -> ExUnit.run() end) + end) + + assert log =~ "received an unexpected SSH exec request" + end + + test "unconsumed handler raises at test exit" do + defmodule UnconsumedExecTest do + use ExUnit.Case + + test "fails" do + TestServer.SSH.exec(to: fn _cmd, state -> {:reply, {0, "ok", ""}, state} end) + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ "did not receive exec requests" + end + end + + describe "shell/1" do + test "echoes data by default" do + TestServer.SSH.shell([]) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + ch = SSHClient.open_shell(conn) + SSHClient.send(conn, ch, "hello\n") + assert SSHClient.recv(ch) == {:ok, "hello\n"} + :ssh.close(conn) + end + + test "to: function receives data and returns reply" do + TestServer.SSH.shell(to: fn _data, state -> {:reply, "pong\n", state} end) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + ch = SSHClient.open_shell(conn) + SSHClient.send(conn, ch, "ping\n") + assert SSHClient.recv(ch) == {:ok, "pong\n"} + :ssh.close(conn) + end + + test "match: filters which handler runs" do + TestServer.SSH.shell( + match: fn data, _state -> data == "ping\n" end, + to: fn _data, state -> {:reply, "pong\n", state} end + ) + + {host, port} = TestServer.SSH.address() + {:ok, conn} = SSHClient.connect(host, port, user: ~c"test") + ch = SSHClient.open_shell(conn) + SSHClient.send(conn, ch, "ping\n") + assert SSHClient.recv(ch) == {:ok, "pong\n"} + :ssh.close(conn) + end + end + + describe "credentials - no auth" do + test "accepts any user without credentials option" do + {:ok, instance} = TestServer.SSH.start() + TestServer.SSH.exec(instance, to: fn _cmd, state -> {:reply, {0, "ok", ""}, state} end) + + {host, port} = TestServer.SSH.address(instance) + {:ok, conn} = SSHClient.connect(host, port, user: ~c"anyone") + assert SSHClient.exec(conn, "cmd") == {0, "ok", ""} + :ssh.close(conn) + end + end + + describe "credentials - password auth" do + test "accepts valid password" do + {:ok, instance} = TestServer.SSH.start(credentials: [{"alice", "secret"}]) + TestServer.SSH.exec(instance, to: fn _cmd, state -> {:reply, {0, "ok", ""}, state} end) + + {host, port} = TestServer.SSH.address(instance) + {:ok, conn} = SSHClient.connect(host, port, user: ~c"alice", password: ~c"secret") + assert SSHClient.exec(conn, "cmd") == {0, "ok", ""} + :ssh.close(conn) + end + + test "rejects invalid password" do + {:ok, _instance} = TestServer.SSH.start(credentials: [{"alice", "secret"}]) + {host, port} = TestServer.SSH.address() + assert {:error, _} = SSHClient.connect(host, port, user: ~c"alice", password: ~c"wrong") + end + + test "rejects unknown user" do + {:ok, _instance} = TestServer.SSH.start(credentials: [{"alice", "secret"}]) + {host, port} = TestServer.SSH.address() + assert {:error, _} = SSHClient.connect(host, port, user: ~c"bob", password: ~c"secret") + end + end + + describe "credentials - public key auth" do + setup do + private_key = :public_key.generate_key({:rsa, 2048, 65_537}) + {:RSAPrivateKey, _, modulus, public_exp, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, public_exp} + + public_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPublicKey, public_key)]) + + user_dir = Path.join(System.tmp_dir!(), "ssh_test_#{System.unique_integer([:positive])}") + File.mkdir_p!(user_dir) + + private_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)]) + + id_rsa_path = Path.join(user_dir, "id_rsa") + File.write!(id_rsa_path, private_pem) + File.chmod!(id_rsa_path, 0o600) + + on_exit(fn -> File.rm_rf!(user_dir) end) + + {:ok, public_pem: public_pem, user_dir: user_dir} + end + + test "accepts valid public key", %{public_pem: public_pem, user_dir: user_dir} do + {:ok, instance} = + TestServer.SSH.start(credentials: [{"bob", :public_key, public_pem}]) + + TestServer.SSH.exec(instance, to: fn _cmd, state -> {:reply, {0, "ok", ""}, state} end) + + {host, port} = TestServer.SSH.address(instance) + + {:ok, conn} = + SSHClient.connect(host, port, + user: ~c"bob", + user_dir: String.to_charlist(user_dir), + auth_methods: ~c"publickey" + ) + + assert SSHClient.exec(conn, "cmd") == {0, "ok", ""} + :ssh.close(conn) + end + + test "rejects unknown public key", %{user_dir: user_dir} do + other_key = :public_key.generate_key({:rsa, 2048, 65_537}) + {:RSAPrivateKey, _, modulus, public_exp, _, _, _, _, _, _, _} = other_key + other_public_key = {:RSAPublicKey, modulus, public_exp} + + other_public_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPublicKey, other_public_key)]) + + {:ok, _instance} = + TestServer.SSH.start(credentials: [{"bob", :public_key, other_public_pem}]) + + {host, port} = TestServer.SSH.address() + + assert {:error, _} = + SSHClient.connect(host, port, + user: ~c"bob", + user_dir: String.to_charlist(user_dir), + auth_methods: ~c"publickey" + ) + end + end + + describe "multi-instance" do + test "error when multiple instances and no explicit instance arg" do + {:ok, _instance_1} = TestServer.SSH.start() + {:ok, _instance_2} = TestServer.SSH.start() + + assert_raise RuntimeError, ~r/Multiple.*running.*pass instance/, fn -> + TestServer.SSH.address() + end + end + + test "explicit instance arg works with multiple instances" do + {:ok, instance_1} = TestServer.SSH.start() + {:ok, instance_2} = TestServer.SSH.start() + + {_, port1} = TestServer.SSH.address(instance_1) + {_, port2} = TestServer.SSH.address(instance_2) + + refute port1 == port2 + end + end +end From ee7a0502a15da1e266d8ada7cca343e26a109223 Mon Sep 17 00:00:00 2001 From: Richard Ash Date: Mon, 9 Mar 2026 09:01:24 -0700 Subject: [PATCH 3/3] Add TestServer.SMTP --- lib/test_server/smtp.ex | 185 ++++++++++++++++ lib/test_server/smtp/email.ex | 19 ++ lib/test_server/smtp/instance.ex | 222 ++++++++++++++++++++ lib/test_server/smtp/session.ex | 211 +++++++++++++++++++ mix.exs | 7 + mix.lock | 1 + test/support/smtp_client.ex | 175 ++++++++++++++++ test/test_server/smtp_test.exs | 347 +++++++++++++++++++++++++++++++ 8 files changed, 1167 insertions(+) create mode 100644 lib/test_server/smtp.ex create mode 100644 lib/test_server/smtp/email.ex create mode 100644 lib/test_server/smtp/instance.ex create mode 100644 lib/test_server/smtp/session.ex create mode 100644 test/support/smtp_client.ex create mode 100644 test/test_server/smtp_test.exs diff --git a/lib/test_server/smtp.ex b/lib/test_server/smtp.ex new file mode 100644 index 0000000..1098bb5 --- /dev/null +++ b/lib/test_server/smtp.ex @@ -0,0 +1,185 @@ +defmodule TestServer.SMTP do + @moduledoc false + + alias TestServer.{InstanceManager, SMTP} + + @spec start(keyword()) :: {:ok, pid()} + def start(options \\ []) do + case ExUnit.fetch_test_supervisor() do + {:ok, _sup} -> start_with_ex_unit(options) + :error -> raise ArgumentError, "can only be called in a test process" + end + end + + defp start_with_ex_unit(options) do + [_first_module_entry | stacktrace] = get_stacktrace() + caller = self() + + options = + options + |> Keyword.put_new(:caller, caller) + |> Keyword.put_new(:stacktrace, stacktrace) + + case InstanceManager.start_instance(caller, SMTP.Instance.child_spec(options)) do + {:ok, instance} -> + put_ex_unit_on_exit_callback(instance) + {:ok, instance} + + {:error, error} -> + raise_start_failure({:error, error}) + end + end + + defp put_ex_unit_on_exit_callback(instance) do + ExUnit.Callbacks.on_exit(fn -> + if Process.alive?(instance) do + verify_handlers!(instance) + stop(instance) + end + end) + end + + defp verify_handlers!(instance) do + instance + |> SMTP.Instance.handlers() + |> Enum.reject(& &1.suspended) + |> case do + [] -> + :ok + + active -> + raise """ + #{SMTP.Instance.format_instance(instance)} did not receive mail for these handlers before the test ended: + + #{SMTP.Instance.format_handlers(active)} + """ + end + end + + @spec stop(pid()) :: :ok | {:error, term()} + def stop(instance) do + instance_alive!(instance) + InstanceManager.stop_instance(instance) + end + + @spec address() :: {binary(), :inet.port_number()} + def address, do: address(fetch_instance!()) + + @spec address(pid()) :: {binary(), :inet.port_number()} + def address(instance) do + instance_alive!(instance) + options = SMTP.Instance.get_options(instance) + {"localhost", Keyword.fetch!(options, :port)} + end + + @spec receive_mail(keyword()) :: :ok + def receive_mail(options) when is_list(options) do + {:ok, instance} = autostart() + receive_mail(instance, options) + end + + @spec receive_mail(pid(), keyword()) :: :ok + def receive_mail(instance, options) when is_pid(instance) and is_list(options) do + instance_alive!(instance) + [_first_module_entry | stacktrace] = get_stacktrace() + {:ok, _handler} = SMTP.Instance.register(instance, {options, stacktrace}) + :ok + end + + @spec x509_suite() :: term() + def x509_suite, do: x509_suite(fetch_instance!()) + + @spec x509_suite(pid()) :: term() + def x509_suite(instance) do + instance_alive!(instance) + GenServer.call(instance, :x509_suite) + end + + defp autostart do + case fetch_instance() do + :error -> start() + {:ok, instance} -> {:ok, instance} + end + end + + defp fetch_instance! do + case fetch_instance() do + :error -> raise "No current #{inspect(SMTP.Instance)} running" + {:ok, instance} -> instance + end + end + + defp fetch_instance do + instances = InstanceManager.get_by_caller(self()) || [] + smtp_instances = Enum.filter(instances, &smtp_instance?/1) + + case smtp_instances do + [] -> + :error + + [instance] -> + {:ok, instance} + + [_first | _rest] = multiple -> + [{m, f, a, _} | _] = get_stacktrace() + + formatted = + multiple + |> Enum.map(&{&1, SMTP.Instance.get_options(&1)}) + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {{instance, options}, index} -> + """ + ##{index + 1}: #{SMTP.Instance.format_instance(instance)} + #{Enum.map_join(options[:stacktrace], "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + + raise """ + Multiple #{inspect(SMTP.Instance)}'s running, please pass instance to `#{inspect(m)}.#{f}/#{a}`. + + #{formatted} + """ + end + end + + defp smtp_instance?(pid) do + SMTP.Instance.get_options(pid)[:protocol] == :smtp + rescue + _ -> false + end + + defp instance_alive!(instance) do + unless Process.alive?(instance), + do: raise("#{SMTP.Instance.format_instance(instance)} is not running") + end + + defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do + raise_start_failure({:error, reason}) + end + + defp raise_start_failure({:error, error}) do + raise """ + EXIT when starting #{inspect(SMTP.Instance)}: + + #{Exception.format_exit(error)} + """ + end + + defp get_stacktrace do + {:current_stacktrace, [{Process, :info, _, _} | stacktrace]} = + Process.info(self(), :current_stacktrace) + + first_module_entry = + stacktrace + |> Enum.reverse() + |> Enum.find(fn {mod, _, _, _} -> mod == __MODULE__ end) + + [first_module_entry] ++ prune_stacktrace(stacktrace) + end + + defp prune_stacktrace([{__MODULE__, _, _, _} | t]), do: prune_stacktrace(t) + defp prune_stacktrace([{ExUnit.Assertions, _, _, _} | t]), do: prune_stacktrace(t) + defp prune_stacktrace([{ExUnit.Runner, _, _, _} | _]), do: [] + defp prune_stacktrace([h | t]), do: [h | prune_stacktrace(t)] + defp prune_stacktrace([]), do: [] +end diff --git a/lib/test_server/smtp/email.ex b/lib/test_server/smtp/email.ex new file mode 100644 index 0000000..a57a625 --- /dev/null +++ b/lib/test_server/smtp/email.ex @@ -0,0 +1,19 @@ +defmodule TestServer.SMTP.Email do + @moduledoc false + + defstruct [ + :mail_from, + :rcpt_to, + :from, + :to, + :cc, + :subject, + :date, + :message_id, + :headers, + :text_body, + :html_body, + :attachments, + :raw + ] +end diff --git a/lib/test_server/smtp/instance.ex b/lib/test_server/smtp/instance.ex new file mode 100644 index 0000000..c1f8868 --- /dev/null +++ b/lib/test_server/smtp/instance.ex @@ -0,0 +1,222 @@ +defmodule TestServer.SMTP.Instance do + @moduledoc false + + use GenServer + + def start_link(options) do + GenServer.start_link(__MODULE__, options) + end + + def register(instance, {options, stacktrace}) do + options[:match] && ensure_function!(options[:match], 2) + ensure_function!(Keyword.fetch!(options, :to), 2) + GenServer.call(instance, {:register, {options, stacktrace}}) + end + + defp ensure_function!(fun, arity) when is_function(fun, arity), do: :ok + defp ensure_function!(fun, _arity), do: raise(BadFunctionError, term: fun) + + def get_options(instance) do + GenServer.call(instance, :options) + end + + def handlers(instance) do + GenServer.call(instance, :handlers) + end + + def format_instance(instance) do + "#{inspect(__MODULE__)} #{inspect(instance)}" + end + + def format_handlers(handlers) do + handlers + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {handler, index} -> + """ + ##{index + 1}: #{inspect(handler.to)} + #{Enum.map_join(handler.stacktrace, "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + end + + def report_error(instance, {exception, stacktrace}) do + options = get_options(instance) + caller = Keyword.fetch!(options, :caller) + + unless Keyword.get(options, :suppress_warning, false), + do: IO.warn(Exception.format(:error, exception, stacktrace)) + + ExUnit.OnExitHandler.add(caller, make_ref(), fn -> + reraise exception, stacktrace + end) + + :ok + end + + @impl true + def init(options) do + Process.flag(:trap_exit, true) + + port = Keyword.get(options, :port, 0) + tls = Keyword.get(options, :tls, :none) + credentials = options[:credentials] + hostname = Keyword.get(options, :hostname, "localhost") + instance = self() + + {x509_suite, tls_options} = maybe_generate_x509(tls) + + listener_ref = make_ref() + + callback_opts = [ + instance: instance, + tls: tls, + credentials: credentials + ] + + session_opts = + [callbackoptions: callback_opts] ++ + if(tls_options != [], do: [tls_options: tls_options], else: []) + + server_options = [ + port: port, + domain: hostname, + sessionoptions: session_opts + ] + + case :gen_smtp_server.start(listener_ref, TestServer.SMTP.Session, server_options) do + {:ok, _} -> + actual_port = :ranch.get_port(listener_ref) + + options = + options + |> Keyword.put(:port, actual_port) + |> Keyword.put(:protocol, :smtp) + + {:ok, + %{ + options: options, + listener_ref: listener_ref, + x509_suite: x509_suite, + handlers: [] + }} + + {:error, reason} -> + {:stop, reason} + end + end + + defp maybe_generate_x509(:starttls) do + suite = X509.Test.Suite.new() + key_der = X509.PrivateKey.to_der(suite.server_key) + cert_der = X509.Certificate.to_der(suite.valid) + + tls_opts = [ + key: {:RSAPrivateKey, key_der}, + cert: cert_der, + cacerts: suite.chain ++ suite.cacerts + ] + + {suite, tls_opts} + end + + defp maybe_generate_x509(_), do: {nil, []} + + @impl true + def handle_call({:register, {options, stacktrace}}, _from, state) do + handler = build_handler(options, stacktrace) + {:reply, {:ok, handler}, %{state | handlers: state.handlers ++ [handler]}} + end + + def handle_call({:dispatch, email}, _from, state) do + {result, state} = dispatch(email, state) + {:reply, result, state} + end + + def handle_call({:check_credentials, user, pass}, _from, state) do + result = + Enum.any?(state.options[:credentials] || [], fn + {^user, ^pass} -> true + _ -> false + end) + + {:reply, result, state} + end + + def handle_call(:handlers, _from, state) do + {:reply, state.handlers, state} + end + + def handle_call(:options, _from, state) do + {:reply, state.options, state} + end + + def handle_call(:x509_suite, _from, state) do + {:reply, state.x509_suite, state} + end + + @impl true + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + :gen_smtp_server.stop(state.listener_ref) + end + + defp build_handler(options, stacktrace) do + %{ + ref: make_ref(), + match: options[:match], + to: Keyword.fetch!(options, :to), + stacktrace: stacktrace, + suspended: false, + received: [] + } + end + + defp dispatch(email, state) do + state.handlers + |> Enum.find_index(fn + %{suspended: true} -> false + %{match: nil} -> true + %{match: match} -> try_match(match, email, %{}) + end) + |> case do + nil -> + {{:error, :not_found}, state} + + index -> + %{to: handler} = Enum.at(state.handlers, index) + result = try_handler(handler, email, %{}) + + updated_handlers = + List.update_at(state.handlers, index, fn h -> + %{h | suspended: true, received: h.received ++ [email]} + end) + + {result, %{state | handlers: updated_handlers}} + end + end + + defp try_match(match, email, state) do + match.(email, state) + rescue + _ -> false + end + + defp try_handler(handler, email, state) do + case handler.(email, state) do + {:ok, _} = ok -> + {:ok, ok} + + {:reply, _, _} = reply -> + {:ok, reply} + + other -> + {:error, + {RuntimeError.exception("Invalid handler response: #{inspect(other)}"), + []}} + end + rescue + error -> {:error, {error, __STACKTRACE__}} + end +end diff --git a/lib/test_server/smtp/session.ex b/lib/test_server/smtp/session.ex new file mode 100644 index 0000000..309d844 --- /dev/null +++ b/lib/test_server/smtp/session.ex @@ -0,0 +1,211 @@ +defmodule TestServer.SMTP.Session do + @moduledoc false + + # credo:disable-for-this-file Credo.Check.Readability.FunctionNames + @behaviour :gen_smtp_server_session + + alias TestServer.SMTP.Instance + + @impl true + def init(hostname, _count, _address, opts) do + state = %{instance: opts[:instance], from: nil, to: [], options: opts} + {:ok, "#{hostname} ESMTP TestServer", state} + end + + @impl true + def handle_HELO(_hostname, state) do + {:ok, state} + end + + @impl true + def handle_EHLO(_hostname, extensions, state) do + extensions = + if state.options[:tls] == :starttls do + extensions ++ [{~c"STARTTLS", true}] + else + extensions + end + + extensions = + if state.options[:credentials] do + extensions ++ [{~c"AUTH", ~c"PLAIN LOGIN"}] + else + extensions + end + + {:ok, extensions, state} + end + + @impl true + def handle_MAIL(from, state) do + {:ok, %{state | from: from}} + end + + @impl true + def handle_MAIL_extension(_extension, state) do + {:ok, state} + end + + @impl true + def handle_RCPT(to, state) do + {:ok, %{state | to: state.to ++ [to]}} + end + + @impl true + def handle_RCPT_extension(_extension, state) do + {:ok, state} + end + + @impl true + def handle_DATA(from, to, data, state) do + email = build_email(from, to, data) + + case GenServer.call(state.instance, {:dispatch, email}) do + {:ok, _} -> + {:ok, "OK", state} + + {:error, :not_found} -> + message = + "#{Instance.format_instance(state.instance)} received an unexpected SMTP email from #{from}" + + Instance.report_error(state.instance, {RuntimeError.exception(message), []}) + {:error, "550 No handler registered for this email", state} + + {:error, {exception, stacktrace}} -> + Instance.report_error(state.instance, {exception, stacktrace}) + {:error, "500 Error processing email", state} + end + end + + @impl true + def handle_RSET(state) do + %{state | from: nil, to: []} + end + + @impl true + def handle_VRFY(_address, state) do + {:error, "252 Cannot VRFY user, but will accept message", state} + end + + @impl true + def handle_other(verb, _args, state) do + {"500 Error: bad syntax - unrecognized command '#{verb}'", state} + end + + @impl true + def handle_AUTH(type, user, pass, state) when type in [:plain, :login] do + case GenServer.call(state.instance, {:check_credentials, to_string(user), to_string(pass)}) do + true -> {:ok, state} + false -> :error + end + end + + def handle_AUTH(_type, _user, _pass, _state), do: :error + + @impl true + def handle_STARTTLS(state) do + state + end + + @impl true + def code_change(_old_vsn, state, _extra) do + {:ok, state} + end + + @impl true + def terminate(_reason, state) do + {:ok, state} + end + + defp build_email(from, to, data) do + raw = to_string(data) + + {top_headers, text_body, html_body, attachments} = + try do + decoded = :mimemail.decode(data) + parse_mime(decoded) + rescue + _ -> {[], nil, nil, []} + end + + %TestServer.SMTP.Email{ + mail_from: to_string(from), + rcpt_to: Enum.map(to, &to_string/1), + from: find_header(top_headers, "from"), + to: find_header(top_headers, "to"), + cc: find_header(top_headers, "cc"), + subject: find_header(top_headers, "subject"), + date: find_header(top_headers, "date"), + message_id: find_header(top_headers, "message-id"), + headers: top_headers, + text_body: text_body, + html_body: html_body, + attachments: attachments, + raw: raw + } + end + + # Returns {headers, text_body, html_body, attachments} + defp parse_mime({"text", "plain", headers, _params, body}) do + {normalize_headers(headers), to_string(body), nil, []} + end + + defp parse_mime({"text", "html", headers, _params, body}) do + {normalize_headers(headers), nil, to_string(body), []} + end + + defp parse_mime({"multipart", _subtype, headers, _params, parts}) when is_list(parts) do + {text, html, attachments} = + Enum.reduce(parts, {nil, nil, []}, fn part, {t, h, atts} -> + {_ph, pt, ph, patt} = parse_mime(part) + {t || pt, h || ph, atts ++ patt} + end) + + {normalize_headers(headers), text, html, attachments} + end + + defp parse_mime({type, subtype, headers, _params, body}) do + hs = normalize_headers(headers) + + if attachment?(hs) do + attachment = %{ + filename: get_filename(hs), + content_type: "#{type}/#{subtype}", + data: body, + headers: hs + } + + {hs, nil, nil, [attachment]} + else + {hs, nil, nil, []} + end + end + + defp normalize_headers(headers) do + Enum.map(headers, fn {k, v} -> {to_string(k), to_string(v)} end) + end + + defp attachment?(headers) do + Enum.any?(headers, fn {k, v} -> + String.downcase(k) == "content-disposition" and + String.downcase(v) |> String.starts_with?("attachment") + end) + end + + defp get_filename(headers) do + Enum.find_value(headers, fn {k, v} -> + if String.downcase(k) == "content-disposition" do + case Regex.run(~r/filename="?([^";]+)"?/i, v) do + [_, name] -> name + _ -> nil + end + end + end) + end + + defp find_header(headers, name) do + Enum.find_value(headers, fn {k, v} -> + if String.downcase(k) == name, do: v + end) + end +end diff --git a/mix.exs b/mix.exs index 0dc5a2a..1119b3f 100644 --- a/mix.exs +++ b/mix.exs @@ -37,6 +37,7 @@ defmodule TestServer.MixProject do [ {:plug, "~> 1.14"}, {:x509, "~> 0.6"}, + {:gen_smtp, "~> 1.2"}, # Optional web servers {:bandit, ">= 1.4.0", optional: true}, @@ -94,6 +95,12 @@ defmodule TestServer.MixProject do TestServer.SSH.Instance, TestServer.SSH.KeyAPI, TestServer.SSH.Channel + ], + SMTP: [ + TestServer.SMTP, + TestServer.SMTP.Instance, + TestServer.SMTP.Session, + TestServer.SMTP.Email ] ] ] diff --git a/mix.lock b/mix.lock index 0d2026a..a249f49 100644 --- a/mix.lock +++ b/mix.lock @@ -11,6 +11,7 @@ "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"}, "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"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "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"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, diff --git a/test/support/smtp_client.ex b/test/support/smtp_client.ex new file mode 100644 index 0000000..04828c2 --- /dev/null +++ b/test/support/smtp_client.ex @@ -0,0 +1,175 @@ +defmodule TestServer.SMTPClient do + @moduledoc false + + @timeout 5_000 + + def connect(host, port, _opts \\ []) do + host_charlist = String.to_charlist(host) + + case :gen_tcp.connect(host_charlist, port, [:binary, active: false], @timeout) do + {:ok, socket} -> + case recv({:tcp, socket}) do + {:ok, "220" <> _} -> {:ok, {:tcp, socket}} + {:ok, response} -> {:error, {:unexpected_banner, response}} + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + def send_command({:tcp, socket} = sock, command) do + :ok = :gen_tcp.send(socket, command <> "\r\n") + recv(sock) + end + + def send_command({:ssl, socket} = sock, command) do + :ok = :ssl.send(socket, command <> "\r\n") + recv(sock) + end + + def ehlo(sock, hostname \\ "test.local") do + send_command(sock, "EHLO #{hostname}") + end + + def mail_from(sock, address) do + send_command(sock, "MAIL FROM:<#{address}>") + end + + def rcpt_to(sock, address) do + send_command(sock, "RCPT TO:<#{address}>") + end + + def data(sock, body) do + {:ok, _} = send_command(sock, "DATA") + send_raw(sock, normalize_body(body) <> "\r\n.\r\n") + recv(sock) + end + + def quit(sock) do + send_command(sock, "QUIT") + end + + def close({:tcp, socket}), do: :gen_tcp.close(socket) + def close({:ssl, socket}), do: :ssl.close(socket) + + def starttls(sock, ssl_opts \\ []) do + {:ok, _} = send_command(sock, "STARTTLS") + {:tcp, socket} = sock + ssl_opts = Keyword.merge([verify: :verify_none], ssl_opts) + + case :ssl.connect(socket, ssl_opts, @timeout) do + {:ok, ssl_socket} -> {:ok, {:ssl, ssl_socket}} + {:error, reason} -> {:error, reason} + end + end + + def auth_plain(sock, user, pass) do + credentials = Base.encode64("\0#{user}\0#{pass}") + send_command(sock, "AUTH PLAIN #{credentials}") + end + + def send_email(host, port, opts) do + from = Keyword.fetch!(opts, :from) + to_list = List.wrap(Keyword.fetch!(opts, :to)) + subject = Keyword.get(opts, :subject, "Test Email") + body = Keyword.get(opts, :body, "Test body") + use_tls = Keyword.get(opts, :tls, false) + auth = Keyword.get(opts, :auth, nil) + ssl_opts = Keyword.get(opts, :ssl_opts, []) + + {:ok, sock} = connect(host, port) + {:ok, _} = ehlo(sock) + + sock = + if use_tls do + {:ok, ssl_sock} = starttls(sock, ssl_opts) + {:ok, _} = ehlo(ssl_sock) + ssl_sock + else + sock + end + + if auth do + {user, pass} = auth + {:ok, "235" <> _} = auth_plain(sock, user, pass) + end + + {:ok, _} = mail_from(sock, from) + + Enum.each(to_list, fn recipient -> + {:ok, _} = rcpt_to(sock, recipient) + end) + + message = build_message(from, to_list, subject, body) + {:ok, _} = data(sock, message) + quit(sock) + close(sock) + :ok + end + + defp build_message(from, to_list, subject, body) do + to_str = Enum.join(to_list, ", ") + + "From: #{from}\r\nTo: #{to_str}\r\nSubject: #{subject}\r\n\r\n#{body}" + end + + defp send_raw({:tcp, socket}, data), do: :gen_tcp.send(socket, data) + defp send_raw({:ssl, socket}, data), do: :ssl.send(socket, data) + + # Read one or more response lines until we get a final line (code + space) + defp recv(sock) do + recv_loop(sock, "") + end + + defp recv_loop(sock, acc) do + case do_recv(sock) do + {:ok, data} -> + full = acc <> data + + # Check if we have a complete response (last line is "XYZ " not "XYZ-") + lines = String.split(full, "\r\n", trim: true) + + if Enum.any?(lines, &final_line?/1) do + final_line = Enum.find(lines, &final_line?/1) + {:ok, final_line} + else + recv_loop(sock, full) + end + + {:error, reason} -> + {:error, reason} + end + end + + defp do_recv({:tcp, socket}) do + case :gen_tcp.recv(socket, 0, @timeout) do + {:ok, data} -> {:ok, to_string(data)} + {:error, reason} -> {:error, reason} + end + end + + defp do_recv({:ssl, socket}) do + case :ssl.recv(socket, 0, @timeout) do + {:ok, data} -> {:ok, to_string(data)} + {:error, reason} -> {:error, reason} + end + end + + # A final SMTP response line: 3 digits followed by a space (not a dash) + defp final_line?(line) do + Regex.match?(~r/^\d{3} /, line) + end + + # Ensure lines end with \r\n and leading dots are escaped (dot-stuffing) + defp normalize_body(body) do + body + |> String.replace("\r\n", "\n") + |> String.replace("\r", "\n") + |> String.split("\n") + |> Enum.map_join("\r\n", fn line -> + if String.starts_with?(line, "."), do: "." <> line, else: line + end) + end +end diff --git a/test/test_server/smtp_test.exs b/test/test_server/smtp_test.exs new file mode 100644 index 0000000..93d61c2 --- /dev/null +++ b/test/test_server/smtp_test.exs @@ -0,0 +1,347 @@ +defmodule TestServer.SMTPTest do + use ExUnit.Case + + import ExUnit.CaptureIO + + alias TestServer.SMTPClient + + describe "start/1" do + test "auto-assigns port" do + {:ok, instance} = TestServer.SMTP.start() + {_host, port} = TestServer.SMTP.address(instance) + assert is_integer(port) and port > 0 + end + + test "multiple independent instances have different ports" do + {:ok, instance_1} = TestServer.SMTP.start() + {:ok, instance_2} = TestServer.SMTP.start() + {_, port1} = TestServer.SMTP.address(instance_1) + {_, port2} = TestServer.SMTP.address(instance_2) + refute port1 == port2 + end + end + + describe "receive_mail/1" do + test "basic handler receives email" do + test_pid = self() + + TestServer.SMTP.receive_mail( + to: fn email, state -> + send(test_pid, {:received, email}) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = + SMTPClient.send_email(host, port, + from: "sender@example.com", + to: "recipient@example.com", + subject: "Hello", + body: "World" + ) + + assert_receive {:received, email} + assert email.mail_from == "sender@example.com" + assert "recipient@example.com" in email.rcpt_to + end + + test "FIFO consumption order" do + test_pid = self() + + TestServer.SMTP.receive_mail( + to: fn _email, state -> + send(test_pid, :first) + {:ok, state} + end + ) + + TestServer.SMTP.receive_mail( + to: fn _email, state -> + send(test_pid, :second) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = SMTPClient.send_email(host, port, from: "a@b.com", to: "c@d.com", body: "1") + :ok = SMTPClient.send_email(host, port, from: "a@b.com", to: "c@d.com", body: "2") + + assert_receive :first + assert_receive :second + end + + test "match: filters which handler runs" do + test_pid = self() + + TestServer.SMTP.receive_mail( + match: fn email, _state -> email.subject == "Special" end, + to: fn email, state -> + send(test_pid, {:matched, email.subject}) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = + SMTPClient.send_email(host, port, + from: "a@b.com", + to: "c@d.com", + subject: "Special", + body: "test" + ) + + assert_receive {:matched, "Special"} + end + end + + describe "error cases" do + test "unmatched email reports error at test exit" do + defmodule UnmatchedSMTPTest do + use ExUnit.Case + + test "fails" do + TestServer.SMTP.receive_mail( + match: fn email, _state -> email.subject == "Expected" end, + to: fn _email, state -> {:ok, state} end + ) + + {host, port} = TestServer.SMTP.address() + + TestServer.SMTPClient.send_email(host, port, + from: "a@b.com", + to: "c@d.com", + subject: "Other", + body: "test" + ) + end + end + + log = + capture_io(:stderr, fn -> + capture_io(fn -> ExUnit.run() end) + end) + + assert log =~ "received an unexpected SMTP email" + end + + test "unconsumed handler raises at test exit" do + defmodule UnconsumedSMTPTest do + use ExUnit.Case + + test "fails" do + TestServer.SMTP.receive_mail(to: fn _email, state -> {:ok, state} end) + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ "did not receive mail" + end + end + + describe "email struct" do + test "mail_from and rcpt_to populated from envelope" do + test_pid = self() + + TestServer.SMTP.receive_mail( + to: fn email, state -> + send(test_pid, {:email, email}) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = + SMTPClient.send_email(host, port, + from: "sender@example.com", + to: ["recip1@example.com", "recip2@example.com"], + body: "multi" + ) + + assert_receive {:email, email} + assert email.mail_from == "sender@example.com" + assert "recip1@example.com" in email.rcpt_to + assert "recip2@example.com" in email.rcpt_to + end + + test "subject and headers parsed" do + test_pid = self() + + TestServer.SMTP.receive_mail( + to: fn email, state -> + send(test_pid, {:email, email}) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = + SMTPClient.send_email(host, port, + from: "from@example.com", + to: "to@example.com", + subject: "Test Subject", + body: "Body text" + ) + + assert_receive {:email, email} + assert email.subject == "Test Subject" + assert email.from =~ "from@example.com" + assert email.to =~ "to@example.com" + end + + test "text_body extracted from plain text email" do + test_pid = self() + + TestServer.SMTP.receive_mail( + to: fn email, state -> + send(test_pid, {:email, email}) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = + SMTPClient.send_email(host, port, + from: "a@b.com", + to: "c@d.com", + subject: "Plain", + body: "Hello plain text" + ) + + assert_receive {:email, email} + assert email.text_body =~ "Hello plain text" + end + + test "raw field contains full DATA payload" do + test_pid = self() + + TestServer.SMTP.receive_mail( + to: fn email, state -> + send(test_pid, {:email, email}) + {:ok, state} + end + ) + + {host, port} = TestServer.SMTP.address() + + :ok = + SMTPClient.send_email(host, port, + from: "a@b.com", + to: "c@d.com", + body: "raw content" + ) + + assert_receive {:email, email} + assert is_binary(email.raw) + assert email.raw =~ "raw content" + end + end + + describe "TLS / STARTTLS" do + test "plain SMTP works without TLS" do + {:ok, instance} = TestServer.SMTP.start() + + TestServer.SMTP.receive_mail(instance, to: fn _email, state -> {:ok, state} end) + + {host, port} = TestServer.SMTP.address(instance) + assert :ok = SMTPClient.send_email(host, port, from: "a@b.com", to: "c@d.com", body: "hi") + end + + test "STARTTLS upgrade works" do + {:ok, instance} = TestServer.SMTP.start(tls: :starttls) + + TestServer.SMTP.receive_mail(instance, to: fn _email, state -> {:ok, state} end) + + {host, port} = TestServer.SMTP.address(instance) + + assert :ok = + SMTPClient.send_email(host, port, + from: "a@b.com", + to: "c@d.com", + body: "tls test", + tls: true + ) + end + + test "x509_suite/0 returns suite when TLS enabled" do + {:ok, _instance} = TestServer.SMTP.start(tls: :starttls) + suite = TestServer.SMTP.x509_suite() + assert suite != nil + assert %X509.Test.Suite{} = suite + end + + test "x509_suite/0 returns nil when TLS not enabled" do + {:ok, _instance} = TestServer.SMTP.start() + assert TestServer.SMTP.x509_suite() == nil + end + end + + describe "AUTH" do + test "no credentials accepts any client" do + {:ok, instance} = TestServer.SMTP.start() + TestServer.SMTP.receive_mail(instance, to: fn _email, state -> {:ok, state} end) + {host, port} = TestServer.SMTP.address(instance) + assert :ok = SMTPClient.send_email(host, port, from: "a@b.com", to: "c@d.com", body: "hi") + end + + test "valid credentials accepted" do + {:ok, instance} = TestServer.SMTP.start(credentials: [{"alice", "secret"}]) + TestServer.SMTP.receive_mail(instance, to: fn _email, state -> {:ok, state} end) + {host, port} = TestServer.SMTP.address(instance) + + assert :ok = + SMTPClient.send_email(host, port, + from: "a@b.com", + to: "c@d.com", + body: "hi", + auth: {"alice", "secret"} + ) + end + + test "wrong password rejected" do + {:ok, instance} = TestServer.SMTP.start(credentials: [{"alice", "secret"}]) + {host, port} = TestServer.SMTP.address(instance) + + {:ok, sock} = SMTPClient.connect(host, port) + {:ok, _} = SMTPClient.ehlo(sock) + {:ok, response} = SMTPClient.auth_plain(sock, "alice", "wrong") + assert response =~ "535" + SMTPClient.close(sock) + end + end + + describe "multi-instance" do + test "error when multiple instances and no explicit instance arg" do + {:ok, _instance_1} = TestServer.SMTP.start() + {:ok, _instance_2} = TestServer.SMTP.start() + + assert_raise RuntimeError, ~r/Multiple.*running.*pass instance/, fn -> + TestServer.SMTP.address() + end + end + + test "explicit instance arg works with multiple instances" do + {:ok, instance_1} = TestServer.SMTP.start() + {:ok, instance_2} = TestServer.SMTP.start() + + {_, port1} = TestServer.SMTP.address(instance_1) + {_, port2} = TestServer.SMTP.address(instance_2) + + refute port1 == port2 + end + end + + describe "address/0" do + test "returns localhost and port" do + {:ok, _instance} = TestServer.SMTP.start() + {host, port} = TestServer.SMTP.address() + assert host == "localhost" + assert is_integer(port) and port > 0 + end + end +end