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
endIf you need them in other test files, add:
import Sigra.Testing
import MyApp.AuthFixturesScenario 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"
endParametric 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
endAssertions
Sigra.Testing ships targeted assertion helpers:
assert_password_hashed(user)— confirmsuser.hashed_passwordstarts with"$argon2id$".assert_email_sent(to:, subject:)— checks the Swoosh test mailbox for a matching email.assert_rate_limited(conn)— asserts a 429 response withRetry-Afterheader.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'srevoked_atis 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_passworddiffers from a known baseline.assert_deletion_scheduled(user)—scheduled_deletion_atis set.assert_deletion_cancelled(user)— inverse.assert_account_deleted(repo, user_schema, user_id)— assert the configured deletion strategy outcome::hard_deleteexpects the row to be gone,:anonymizeexpects Sigra-owned PII cleared, and:soft_delete preserves the user rowwhile 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()]
endAPI 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
endOAuth 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"
endEmail 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)
endAudit 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_byon audit queries when multiple rows can exist — the assertion helpers already applyORDER BY inserted_at DESC, id DESCbefore taking the latest row; when writing your own queries, keep an explicit ordering soRepo.all/1results are deterministic.async: truetests may needEcto.Adapters.SQL.Sandbox.allow/3when audit runs in another process (for example aTaskor 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
DataCasesetup snippet (adjust module names; paths follow your host app). The goal is a single Sandbox checkout for the test PID, then explicitallow/3from 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
endPitfalls
- Scenario fixtures return different keys. Don't pattern-match expecting a uniform map; see the table above.
locked_fixturedoes not set a conn. Locked users cannot log in — if you need a conn, useauthenticated_fixtureand callsimulate_lockout/3after.setup_totp/2requires a config. The user's TOTP secret is stored via the configured repo; passconfig: MyApp.Auth.sigra_config()every time.- Swoosh test mailbox is per-test-process. Use
assert_email_sent/1inside 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:
| Check | Safe 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_term | No — global mutable |
| Uses a named ETS table (any non-process-local name) | No — global mutable |
Attaches/detaches a :telemetry handler globally | No — handler registry is shared |
Sends set_mox_global in setup | No — leaks expectations across tests |
| Mutates filesystem paths, cwd, or Mix archives | No — shared host-process state |
Runs checkout_repo! / unboxed_run (durable DDL) | No — non-sandboxed DB writes |
DB access through Sigra.Test.PostgresCase only | Yes — shared: not tags[:async] auto-isolates the sandbox connection |
| All state lives in the process dictionary | Yes — 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: falseThis 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.
Related
- Getting Started — the flow these tests cover.
- Registration, Login and Logout, MFA, API Authentication — each guide has a "Testing" section.
Sigra.Testing— the full helper module (see HexDocs for every function).