Skip to content

Latest commit

 

History

History
229 lines (168 loc) · 9.21 KB

File metadata and controls

229 lines (168 loc) · 9.21 KB

Testing Auth Flows

Sigra ships a rich Sigra.Testing helper module and a generated AuthFixtures module with seven named scenario fixtures covering every documented auth state. This recipe shows how to use them for fast, AAA-style tests.

Setup

In your generated conn_case.ex the helpers are already imported:

using do
  quote do
    import Sigra.Testing
    import MyApp.AuthFixtures
    import MyAppWeb.ConnCase
  end
end

If you need them in other test files, add:

import Sigra.Testing
import MyApp.AuthFixtures

Scenario fixtures

The generated AuthFixtures module provides seven named fixtures — one per documented auth state — plus a scenario/2 dispatcher for parametric tests. Each fixture returns exactly the data needed for that scenario, not a uniform map.

Scenario Function Returns
Anonymous anonymous_fixture/0 %{conn: build_conn()}
Authenticated authenticated_fixture/1 %{user: user, session: session, conn: logged_in_conn}
MFA pending mfa_pending_fixture/1 %{user: user, session: session, totp_secret: secret} (no conn — pre-challenge)
MFA complete mfa_complete_fixture/1 %{user: user, session: session, conn: logged_in_conn, totp_secret: secret}
Sudo sudo_fixture/1 %{user: user, session: session, conn: logged_in_conn}
Locked locked_fixture/1 %{user: user} (no conn — locked users can't log in)
Unconfirmed unconfirmed_fixture/1 %{user: user} (no conn — email not yet confirmed)

Use a fixture directly

test "authenticated user sees the dashboard" do
  %{conn: conn, user: user} = authenticated_fixture()

  conn = get(conn, ~p"/dashboard")
  assert html_response(conn, 200) =~ user.email
end

test "locked user cannot log in" do
  %{user: user} = locked_fixture()

  conn =
    post(build_conn(), ~p"/users/log-in", %{"user" => %{"email" => user.email, "password" => "password1234"}})

  assert get_flash(conn, :error) =~ "locked"
end

Parametric tests via scenario/2

for state <- [:anonymous, :authenticated, :locked] do
  test "GET / renders for #{state}" do
    fixture = MyApp.AuthFixtures.scenario(unquote(state))
    conn = Map.get(fixture, :conn, build_conn())

    assert get(conn, ~p"/").status == 200
  end
end

Assertions

Sigra.Testing ships targeted assertion helpers:

  • assert_password_hashed(user) — confirms user.hashed_password starts with "$argon2id$".
  • assert_email_sent(to:, subject:) — checks the Swoosh test mailbox for a matching email.
  • assert_rate_limited(conn) — asserts a 429 response with Retry-After header.
  • assert_mfa_enabled(user, backup_code_schema:) — user has a confirmed TOTP secret.
  • assert_mfa_disabled(user, backup_code_schema:) — inverse.
  • assert_token_revoked(config, token_id) — API token's revoked_at is non-nil.
  • assert_scope_denied(conn) — API response is 403 and halted.
  • assert_sessions_invalidated(repo, user) — no session tokens remain for the user.
  • assert_password_changed(user)hashed_password differs from a known baseline.
  • assert_deletion_scheduled(user)deletion_scheduled_at is set.
  • assert_deletion_cancelled(user) — inverse.
  • assert_account_deleted(repo, user_schema, user_id) — the user row is gone (or anonymized).
  • assert_audit_event(expected, opts) — most recent audit row matches; supports metadata subset matching.

MFA helpers

test "MFA challenge succeeds with generated code" do
  %{user: user} = locked_fixture()  # reuse locked_fixture for the user, then unlock
  secret = Sigra.Testing.setup_totp(user, config: MyApp.Auth.sigra_config())

  code = Sigra.Testing.generate_totp_code(secret)

  assert {:ok, _} = Sigra.MFA.verify_totp(MyApp.Auth.sigra_config(), user, code)
end

test "bypass MFA when you don't care about the flow" do
  %{user: user, conn: conn} = mfa_complete_fixture()
  conn = Sigra.Testing.bypass_mfa(conn)

  assert get(conn, ~p"/settings").status == 200
end

test "trust-this-browser cookie is honored" do
  %{user: user, conn: conn} = mfa_complete_fixture()
  conn = Sigra.Testing.trust_browser(conn, user, config: MyApp.Auth.sigra_config())

  # Next login on same conn skips MFA challenge
  assert conn.resp_cookies[Sigra.MFA.Trust.cookie_name()]
end

API token helpers

test "authenticated API request" do
  %{user: user} = authenticated_fixture()
  config = MyApp.Auth.sigra_config()
  {raw, _token} = Sigra.Testing.create_api_token(config, user, name: "CI", scopes: ["read:projects"])

  conn = build_conn() |> Sigra.Testing.put_bearer_token(raw) |> get(~p"/api/projects")
  assert json_response(conn, 200)
end

test "revoked token returns 401" do
  %{user: user} = authenticated_fixture()
  config = MyApp.Auth.sigra_config()
  {raw, token} = Sigra.Testing.create_api_token(config, user, name: "doomed")

  Sigra.Auth.revoke_api_token(config, token.id)

  conn = build_conn() |> Sigra.Testing.put_bearer_token(raw) |> get(~p"/api/projects")
  assert conn.status == 401
end

OAuth helpers

test "OAuth callback creates a new user" do
  params = Sigra.Testing.mock_oauth_callback(provider: :google, email: "new@example.com")
  conn = get(build_conn(), ~p"/auth/google/callback", params)

  assert conn.assigns.current_scope.user.email == "new@example.com"
end

Email helpers

test "extract and confirm reset token" do
  user = user_fixture()

  Accounts.deliver_user_reset_password_instructions(user, fn token -> "/reset/#{token}" end)
  Sigra.Testing.assert_email_sent(to: user.email, subject: "Reset")

  # Extract the token from the most recent mail body
  url = Swoosh.Adapters.Test.Storage.all() |> List.last() |> Map.get(:html_body)
  token = Sigra.Testing.extract_reset_token(url)

  assert is_binary(token)
end

Audit assertions and Ecto Sandbox

When you assert on rows in audit_events, prefer Sigra.Audit.Assertions from the library (lib/sigra/audit/assertions.ex) so both the main Sigra test suite and test/example subprojects can share the same helpers. See that module’s @moduledoc for the full API (latest_audit_event/3, assert_audit_fields/3).

  • Use order_by on audit queries when multiple rows can exist — the assertion helpers already apply ORDER BY inserted_at DESC, id DESC before taking the latest row; when writing your own queries, keep an explicit ordering so Repo.all/1 results are deterministic.

  • async: true tests may need Ecto.Adapters.SQL.Sandbox.allow/3 when audit runs in another process (for example a Task or a Plug pipeline that hands work to another PID). Allow the owner of the DB connection to the process that performs the insert before asserting.

  • Copy-paste DataCase setup snippet (adjust module names; paths follow your host app). The goal is a single Sandbox checkout for the test PID, then explicit allow/3 from that owner to any child process that hits the DB:

    setup tags do
      :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
    
      unless tags[:async] do
        Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
      end
    
      {:ok, conn: Phoenix.ConnTest.build_conn()}
    end
    
    # In tests that spawn work (Task, LiveView, etc.):
    parent = self()
    
    Task.async(fn ->
      Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, parent, self())
      # ... code that inserts into audit_events via MyApp.Repo ...
    end)
    |> Task.await()
    

Audit helpers

test "login is audited" do
  %{user: user} = authenticated_fixture()

  Sigra.Testing.assert_audit_event(%{
    action: "auth.login.success",
    actor_id: user.id
  })
end

test "pre-seed an audit event for query tests" do
  user = user_fixture()

  Sigra.Testing.audit_event_fixture(
    repo: Repo,
    audit_schema: MyApp.AuditEvent,
    actor_id: user.id,
    action: "auth.login.success"
  )

  events = Sigra.Audit.query(MyApp.Auth.sigra_config(), actor_id: user.id) |> Repo.all()
  assert length(events) == 1
end

Pitfalls

  • Scenario fixtures return different keys. Don't pattern-match expecting a uniform map; see the table above.
  • locked_fixture does not set a conn. Locked users cannot log in — if you need a conn, use authenticated_fixture and call simulate_lockout/3 after.
  • setup_totp/2 requires a config. The user's TOTP secret is stored via the configured repo; pass config: MyApp.Auth.sigra_config() every time.
  • Swoosh test mailbox is per-test-process. Use assert_email_sent/1 inside the same test that triggered delivery. For async background workers, configure the mailer to send synchronously in test env.

Related