defmodule AuthTestSupport do @moduledoc """ A collection of common funcitonality to use in your Phoenix test suites. `use AuthTestSupport` in your test files. `use` is necessary for `authenticate` and it will import the remaining functions. If you'd like to use another of the other functions in isolation feel free to import them specifically. """ @api_actions [:index, :show, :delete, :update, :create] Module.add_doc(__MODULE__, __ENV__.line + 1, :def, {:authenticate, 2}, (quote do: [conn, credentials]), """ Authenticate the session with the given credentials This function assumes that the session creation path is `session_path` and is using `post`. Feel free to override this function. """) defmacro __using__(_) do quote do import AuthTestSupport @before_compile AuthTestSupport end end defmacro __before_compile__(_) do quote do @doc false def authenticate(conn, creds), do: Phoenix.ConnTest.post(conn, session_path(conn, :create, creds)) defoverridable [authenticate: 2] end end @doc """ Assert that the current connection is authenticated as a given account Will run the following assertions: 1. assert that `:account_id` value in the session is not `nil` and is equal to the `account`'s primary key value 2. assert that `:account_type` value in the sesion is not `nil` and is equal to the `account`'s struct The original `conn` will be returned. """ def assert_authorized_as(%Plug.Conn{assigns: %{account: assigned_account}} = conn, account) do ExUnit.Assertions.assert assigned_account == account, "expected the account to match the assigned account in the session" conn end def assert_authorized_as(conn, account) do {account_id, account_type} = get_account_info(account) session_account_id = Plug.Conn.get_session(conn, :account_id) session_account_type = Plug.Conn.get_session(conn, :account_type) ExUnit.Assertions.assert session_account_id, "expected an account_id to be set" ExUnit.Assertions.assert session_account_id == account_id, "expected the authenticated account to have a primary key value of: #{account_id}" ExUnit.Assertions.assert session_account_type, "expected an account_type to be set" ExUnit.Assertions.assert session_account_type == account_type, "expected the authenticated account to be of type: #{inspect account_type}" conn end @doc """ Authorizes a connection with an account Equivalent to running: Plug.Conn.assign(conn, :account, account) This function differs from `authentication_as/2` as that will run the actual authentication whereas this function simply assigns to the `conn`. The database is not hit, no encryption checks are run. """ def authorize_as(conn, account), do: Plug.Conn.assign(conn, :account, account) @doc false def get_account_info(account) do module = account.__struct__ [primary_key] = module.__schema__(:primary_key) primary_key_value = Map.get(account, primary_key) {primary_key_value, module} end @doc """ Macro that generates a test for asserting that RESTful actions require authorization The assertion being run will expect that unauthorized route access will return a `401` Options: * `:roles` takes an keyword list of role names. Keyword values can be a function reference that to manipulate the `conn` object * `:only` only the actions in the keyword list given. Keyword values can be a map for passing custom params to the action * `:except` all actions (`index, show, create, update, destroy`) except those in the keyword list. Keyword value behave similiar to `only` ### Examples require_authorization :profile_path require_authorization :profile_path, roles: [:no_auth, auth: &auth_conn/1] defp auth_conn(conn) do authenticate(conn, username: "user@example.com", password: "password") end require_authorization :profile_path, only: [create: %{foo: "bar"}] Each call to `require_authorization` only generates a single test, not multiple tests. This saves on compilation time. """ defmacro require_authorization(path_helper, opts \\ []) do roles = (opts[:roles] || [:no_auth]) |> List.wrap() actions = cond do opts[:only] -> opts[:only] opts[:except] -> @api_actions -- opts[:except] true -> @api_actions end role_keys = Enum.map roles, fn {role, _fun} -> role role -> role end role_text = Enum.map roles, fn {role, fun} -> "#{role} via #{inspect fun}" role -> role end action_keys = Enum.map actions, fn {action, _params} -> action action -> action end action_text = Enum.map actions, fn {action, params} -> "#{action} with #{inspect params}" action -> action end quote do test "require authentication for #{Enum.join(unquote(role_text), ", ")} on #{Enum.join(unquote(action_text), ", ")}", %{conn: conn} do path_helper_full = inspect(unquote({:&, [], [{:/, [], [{path_helper, [], nil}, 3]}]})) path_helper_module = Regex.run(~r/\&([\w|\.]+)?\.\w+\/\d+/, path_helper_full) |> List.last() |> Code.eval_string() |> elem(0) for role <- unquote(role_keys) do for action <- unquote(action_keys) do {methods, params} = case action do :show -> {[:get], [0]} :delete -> {[:delete], [0]} :create -> {[:post], [nil, unquote(actions)[action] || %{}]} :index -> {[:get], []} :update -> {[:put, :patch], [0, unquote(actions)[action] || %{}]} end conn = case unquote(roles)[role] do nil -> conn func -> func.(conn) end for method <- methods do args = [conn, action, List.first(params)] |> Enum.reject(&is_nil/1) path = apply(path_helper_module, unquote(path_helper), args) {conn, message} = cond do action in [:show, :delete, :index] -> conn = Phoenix.ConnTest.dispatch(conn, @endpoint, method, path, nil) message = "[#{method |> Atom.to_string() |> String.upcase()}] #{action} on #{path} expected to return 401, got: #{conn.status}" {conn, message} action in [:create, :update] -> conn = Phoenix.ConnTest.dispatch(conn, @endpoint, method, path, List.last(params)) message = "[#{method |> Atom.to_string() |> String.upcase()}] #{action} on #{path} with #{inspect List.last(params)} expected to return 401, got: #{conn.status}" {conn, message} end assert conn.status == 401, message end end end end end end end