1defmodule Phoenix.Ecto.SQL.Sandbox do
2  @moduledoc """
3  A plug to allow concurrent, transactional acceptance tests with Ecto.Adapters.SQL.Sandbox.
4
5  ## Example
6
7  This plug should only be used during tests. First, set a flag to
8  enable it in `config/test.exs`:
9
10      config :your_app, sql_sandbox: true
11
12  And use the flag to conditionally add the plug to `lib/your_app/endpoint.ex`:
13
14      if Application.get_env(:your_app, :sql_sandbox) do
15        plug Phoenix.Ecto.SQL.Sandbox
16      end
17
18  It's important that this is at the top of `endpoint.ex`, before any other plugs.
19
20  Then, within an acceptance test, checkout a sandboxed connection as before.
21  Use `metadata_for/2` helper to get the session metadata to that will allow access
22  to the test's connection.
23  Here's an example using [Hound](https://hex.pm/packages/hound):
24
25      use Hound.Helpers
26
27      setup do
28        :ok = Ecto.Adapters.SQL.Sandbox.checkout(YourApp.Repo)
29        metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(YourApp.Repo, self())
30        Hound.start_session(metadata: metadata)
31      end
32
33  ## Concurrent end-to-end tests with external clients
34
35  Concurrent and transactional tests for external HTTP clients is supported,
36  allowing for complete end-to-end tests. This is useful for cases such as
37  JavaScript test suites for single page applications that exercise the
38  Phoenix endpoint for end-to-end test setup and teardown. To enable this,
39  you can expose a sandbox route on the `Phoenix.Ecto.SQL.Sandbox` plug by
40  providing the `:at`, and `:repo` options. For example:
41
42      plug Phoenix.Ecto.SQL.Sandbox,
43        at: "/sandbox",
44        repo: MyApp.Repo
45
46  This would expose a route at `"/sandbox"` for the given repo where
47  external clients send POST requests to spawn a new sandbox session,
48  and DELETE requests to stop an active sandbox session. By default,
49  the external client is expected to pass up the `"user-agent" header
50  containing serialized sandbox metadata returned from the POST request,
51  but this value may customized with the `:header` option.
52  """
53
54  import Plug.Conn
55  alias Plug.Conn
56  alias Phoenix.Ecto.SQL.SandboxSupervisor
57
58  @doc """
59  Spawns a sandbox session to checkout a connection for a remote client.
60
61  ## Examples
62
63      iex> {:ok, _owner_pid, metdata} = start_child(MyApp.Repo)
64  """
65  def start_child(repo, opts \\ []) do
66    case Supervisor.start_child(SandboxSupervisor, [repo, self(), opts]) do
67      {:ok, owner} ->
68        metadata = metadata_for(repo, owner)
69        {:ok, owner, metadata}
70
71      {:error, reason} ->
72        {:error, reason}
73    end
74  end
75
76  @doc """
77  Stops a sandbox session holding a connection for a remote client.
78
79  ## Examples
80
81      iex> {:ok, owner_pid, metadata} = start_child(MyApp.Repo)
82      iex> :ok = stop(owner_pid)
83  """
84  def stop(owner) when is_pid(owner) do
85    GenServer.call(owner, :checkin)
86  end
87
88  def init(opts \\ []) do
89    %{
90      sandbox: Keyword.get(opts, :sandbox, Ecto.Adapters.SQL.Sandbox),
91      header: Keyword.get(opts, :header, "user-agent"),
92      path: get_path_info(opts[:at]),
93      repo: opts[:repo]
94    }
95  end
96  defp get_path_info(nil), do: nil
97  defp get_path_info(path), do: Plug.Router.Utils.split(path)
98
99  def call(%Conn{method: "POST", path_info: path} = conn, %{path: path} = opts) do
100    {:ok, _owner, metadata} = start_child(opts.repo, sandbox: opts.sandbox)
101
102    conn
103    |> put_resp_content_type("text/plain")
104    |> send_resp(200, encode_metadata(metadata))
105    |> halt()
106  end
107  def call(%Conn{method: "DELETE", path_info: path} = conn, %{path: path} = opts) do
108    case extract_metadata(conn, opts.header) do
109      %{owner: owner} ->
110        :ok = stop(owner)
111
112        conn
113        |> put_resp_content_type("text/plain")
114        |> send_resp(200, "")
115        |> halt()
116
117      %{} ->
118        conn
119        |> send_resp(410, "")
120        |> halt()
121    end
122  end
123
124  def call(conn, %{header: header, sandbox: sandbox}) do
125    _result =
126      conn
127      |> extract_metadata(header)
128      |> allow_sandbox_access(sandbox)
129
130    conn
131  end
132
133  defp extract_metadata(%Conn{} = conn, header) do
134    conn
135    |> get_req_header(header)
136    |> List.first()
137    |> decode_metadata()
138  end
139
140  @doc """
141  Returns metadata to associate with the session
142  to allow the endpoint to acces the database connection checked
143  out by the test process.
144  """
145  @spec metadata_for(Ecto.Repo.t | [Ecto.Repo.t], pid) :: map
146  def metadata_for(repo_or_repos, pid) when is_pid(pid) do
147    %{repo: repo_or_repos, owner: pid}
148  end
149
150  @doc """
151  Encodes metadata generated by `metadata_for/2` for client response.
152  """
153  def encode_metadata(metadata) do
154    encoded =
155      {:v1, metadata}
156      |> :erlang.term_to_binary()
157      |> Base.url_encode64()
158
159    "BeamMetadata (#{encoded})"
160  end
161
162  @doc """
163  Decodes encoded metadata back into map generated from `metadata_for/2`.
164  """
165  def decode_metadata(encoded_meta) when is_binary(encoded_meta) do
166    last_part = encoded_meta |> String.split("/") |> List.last()
167    case Regex.run(~r/BeamMetadata \((.*?)\)/, last_part) do
168      [_, metadata] -> parse_metadata(metadata)
169      _             -> %{}
170    end
171  end
172  def decode_metadata(_), do: %{}
173
174  defp allow_sandbox_access(%{repo: repo, owner: owner}, sandbox) do
175    Enum.each(List.wrap(repo), &sandbox.allow(&1, owner, self()))
176  end
177  defp allow_sandbox_access(_metadata, _sandbox), do: nil
178
179  defp parse_metadata(encoded_metadata) do
180    encoded_metadata
181    |> Base.url_decode64!()
182    |> :erlang.binary_to_term()
183    |> case do
184         {:v1, metadata} -> metadata
185         _               -> %{}
186       end
187  end
188end
189