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.

ScenarioFunctionReturns
Anonymousanonymous_fixture/0%{conn: build_conn()}
Authenticatedauthenticated_fixture/1%{user: user, session: session, conn: logged_in_conn}
MFA pendingmfa_pending_fixture/1%{user: user, session: session, totp_secret: secret} (no conn — pre-challenge)
MFA completemfa_complete_fixture/1%{user: user, session: session, conn: logged_in_conn, totp_secret: secret}
Sudosudo_fixture/1%{user: user, session: session, conn: logged_in_conn}
Lockedlocked_fixture/1%{user: user} (no conn — locked users can't log in)
Unconfirmedunconfirmed_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)scheduled_deletion_at is set.
  • assert_deletion_cancelled(user) — inverse.
  • assert_account_deleted(repo, user_schema, user_id) — assert the configured deletion strategy outcome: :hard_delete expects the row to be gone, :anonymize expects Sigra-owned PII cleared, and :soft_delete preserves the user row while finalizing lifecycle state.
  • assert_audit_event(expected, opts) — most recent audit row matches; supports metadata subset matching.

For auth/account export tests, assert omissions when optional generated schemas are absent. That proves partial Sigra-owned exports are explicit instead of accidentally treated as complete host-domain exports.

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.

Is this test allowed to be async: true?

A test module may run async: true only if it touches no shared mutable global state. Use this checklist before flipping a module:

CheckSafe for async: true?
Reads/writes Application env (e.g. Application.put_env/3)No — global mutable
Reads/writes System env (e.g. System.put_env/2)No — global mutable
Reads/writes :persistent_termNo — global mutable
Uses a named ETS table (any non-process-local name)No — global mutable
Attaches/detaches a :telemetry handler globallyNo — handler registry is shared
Sends set_mox_global in setupNo — leaks expectations across tests
Mutates filesystem paths, cwd, or Mix archivesNo — shared host-process state
Runs checkout_repo! / unboxed_run (durable DDL)No — non-sandboxed DB writes
DB access through Sigra.Test.PostgresCase onlyYes — shared: not tags[:async] auto-isolates the sandbox connection
All state lives in the process dictionaryYes — process-local, never shared
All mocks use Mox.set_mox_private (private mode)Yes — per-test, no global leak

If any row above is "No", the module must stay async: false and carry a short comment explaining why:

# async: false because Application env is global mutable state (sets :sigra config)
use ExUnit.Case, async: false

This convention is already established in test/sigra/audit/forwarders/threadline_test.exs — see lines 6–11 for an example with multiple reasons.

Note: test-suite partitioning (running shards in parallel CI lanes) does not change this rule. Each shard is an independent VM process with its own connection against its own PG container. The async-safety requirement applies within a single ExUnit process, not across shards.