# `Sigra.Testing`
[🔗](https://github.com/sztheory/sigra/blob/v1.20.0/lib/sigra/testing.ex#L1)

Test assertion helpers for Sigra authentication.

This module provides convenience assertions for testing authentication
flows in your application. Import it in your test cases:

    use ExUnit.Case
    import Sigra.Testing

## Available Assertions

- `assert_password_hashed/1` - verifies a user has a properly hashed password
- `assert_session_created/1` - verifies a session was created on a conn (stub)
- `assert_token_sent/2` - verifies a token email was sent (stub)

Stub functions will be filled in by later phases as the corresponding
features are implemented.

# `assert_account_deleted`
*since 0.8.0* 

```elixir
@spec assert_account_deleted(module(), module(), term()) :: true
```

Asserts that the user account was permanently deleted or anonymized.

Checks that the user either no longer exists or has an anonymized email.

# `assert_audit_event`
*since 0.10.0* 

```elixir
@spec assert_audit_event(
  map(),
  keyword()
) :: true
```

Asserts that an audit event matches the given map.

By default checks the most recent event (ordered by `inserted_at` desc);
pass `:position` to check the Nth-most-recent (`0` is newest).

Top-level keys (`:action`, `:outcome`, `:actor_id`, etc.) are compared
with strict equality. The `:metadata` key, if present, deep-matches a
subset — keys present in the expected map must equal the corresponding
values on the event, but extra keys on the event are ignored. Both atom
and string metadata keys are tolerated.

Raises `ExUnit.AssertionError` with a diff-style message on mismatch.

## Options

  * `:repo` (required) — the Ecto repo module
  * `:audit_schema` (required) — the audit_events schema module
  * `:position` (default `0`) — 0-based offset from newest

## Examples

    assert_audit_event(
      %{action: "billing.charge.created", outcome: "success"},
      repo: MyApp.Repo,
      audit_schema: MyApp.AuditEvent
    )

    assert_audit_event(
      %{metadata: %{plan: "pro"}},
      repo: MyApp.Repo,
      audit_schema: MyApp.AuditEvent
    )

# `assert_audit_logged`
*since 0.11.0* 

```elixir
@spec assert_audit_logged(
  map(),
  keyword()
) :: true
```

Asserts that the latest audit event matches the given field expectations.

Thin alias for `assert_audit_event/2` with a name aligned to REQ DX-02
(`assert_audit_logged_for_org/2` family naming). Takes a map of expected
fields and a keyword options list. See `assert_audit_event/2` for the
full option list (`:repo`, `:audit_schema`, `:position`).

## Examples

    assert_audit_logged(
      %{
        action: "auth.login.success",
        actor_id: user.id,
        effective_user_id: user.id,
        organization_id: org.id
      },
      repo: MyApp.Repo,
      audit_schema: MyApp.AuditEvent
    )

## Signature note

This helper intentionally takes `(map, keyword)` — NOT `(repo, fields)`. See
the `deviations` field in
`.planning/phases/15-audit-integration/15-02-semantic-workers-credo-PLAN.md`
for the D-31 refinement rationale (the `(repo, fields)` shape from
CONTEXT.md would require synthesizing `:audit_schema` via process-dict
magic and would no longer be a "thin alias" — it would either duplicate
the implementation or hide required options).

# `assert_audit_logged_for_org`
*since 0.11.0* 

```elixir
@spec assert_audit_logged_for_org(
  map() | binary(),
  keyword()
) :: true
```

Asserts that the latest audit event was logged for the expected organization.

# `assert_deletion_cancelled`
*since 0.8.0* 

```elixir
@spec assert_deletion_cancelled(struct()) :: true
```

Asserts that the user account deletion was cancelled.

Raises `ExUnit.AssertionError` if `deleted_at` or `scheduled_deletion_at` is not nil.

# `assert_deletion_scheduled`
*since 0.8.0* 

```elixir
@spec assert_deletion_scheduled(struct()) :: true
```

Asserts that the user account is scheduled for deletion.

Raises `ExUnit.AssertionError` if `deleted_at` or `scheduled_deletion_at` is nil.

# `assert_email_sent`
*since 0.3.0* 

```elixir
@spec assert_email_sent(keyword()) :: true
```

Asserts that an email was sent (via Swoosh test adapter).

Checks the Swoosh test mailbox for an email matching the given criteria.
Uses `Swoosh.TestAssertions` under the hood when available.

## Options

- `:to` - Expected recipient email
- `:subject` - Expected subject (substring match)

# `assert_membership`
*since 0.11.0* 

```elixir
@spec assert_membership(map(), map(), keyword()) :: true
```

Asserts that a membership belongs to the expected user, organization, and role.

# `assert_mfa_disabled`
*since 0.6.0* 

```elixir
@spec assert_mfa_disabled(struct(), keyword()) :: true
```

Asserts that the user does not have MFA enabled.

Raises `ExUnit.AssertionError` on failure.

## Options

  * `:config` - `%Sigra.Config{}` (required)
  * `:mfa_credential_schema` - MFA credential schema module (required)

# `assert_mfa_enabled`
*since 0.6.0* 

```elixir
@spec assert_mfa_enabled(struct(), keyword()) :: true
```

Asserts that the user has MFA enabled (has credential with non-nil enabled_at).

Raises `ExUnit.AssertionError` on failure.

## Options

  * `:config` - `%Sigra.Config{}` (required)
  * `:mfa_credential_schema` - MFA credential schema module (required)

# `assert_password_changed`
*since 0.8.0* 

```elixir
@spec assert_password_changed(struct()) :: true
```

Asserts that the user's password was recently changed.

Checks that `password_changed_at` is set and within the last 60 seconds.

# `assert_password_hashed`
*since 0.1.0* 

```elixir
@spec assert_password_hashed(map()) :: true
```

Asserts that the given user struct has a properly hashed password.

Checks that the `hashed_password` field starts with `"$argon2id$"`,
indicating it was hashed with the Argon2id algorithm.

## Examples

    user = %{hashed_password: Sigra.Crypto.hash_password("password")}
    assert_password_hashed(user)

# `assert_rate_limited`
*since 0.4.0* 

```elixir
@spec assert_rate_limited(Plug.Conn.t()) :: true
```

Assert rate limited response (429 status with Retry-After header).

Checks that the connection has a 429 status code and a non-empty
`retry-after` response header.

## Examples

    conn = post(conn, "/login", %{email: "test@example.com", password: "wrong"})
    Sigra.Testing.assert_rate_limited(conn)

# `assert_scope_denied`
*since 0.7.0* 

```elixir
@spec assert_scope_denied(Plug.Conn.t()) :: true
```

Asserts that a conn received a 403 insufficient scope response.

Checks that the status is 403 and the connection is halted.

# `assert_scope_has_org`
*since 0.11.0* 

```elixir
@spec assert_scope_has_org(map(), map() | binary()) :: true
```

Asserts that a scope has the expected active organization id.

# `assert_session_created`
*since 0.1.0* 

```elixir
@spec assert_session_created(term()) :: true
```

Asserts that a session was created on the given conn.

> #### Stub {: .warning}
>
> This function is a stub that will be filled in by later phases
> when session management is fully implemented.

# `assert_sessions_invalidated`
*since 0.8.0* 

```elixir
@spec assert_sessions_invalidated(module(), struct(), keyword()) :: true
```

Asserts that all sessions except the optionally specified one were invalidated.

## Options

  * `:except_token` - A session token to exclude from the count (the current session)
  * `:session_schema` - The session schema module (required)

# `assert_token_revoked`
*since 0.7.0* 

```elixir
@spec assert_token_revoked(Sigra.Config.t(), term()) :: true
```

Asserts that a token has been revoked.

Raises `ExUnit.AssertionError` if the token's `revoked_at` is nil.

# `assert_token_sent`
*since 0.1.0* 

```elixir
@spec assert_token_sent(String.t(), atom()) :: true
```

Asserts that a token email was sent to the given address for the given context.

Delegates to `assert_email_sent/1` with `:to` set to the given address.

# `audit_event_fixture`
*since 0.10.0* 

```elixir
@spec audit_event_fixture(keyword()) :: struct()
```

Inserts an audit event directly via the configured repo, bypassing
`Ecto.Multi` wrapping. Test-only — production audit writes go through
`Sigra.Audit.log/2` or `Sigra.Audit.log_multi/3`.

## Options

  * `:repo` (required) — the Ecto repo module
  * `:audit_schema` (required) — the generated `audit_events` schema module
  * `:action` (default `"test.event"`)
  * `:outcome` (default `"success"`)
  * `:actor_id`, `:actor_type` (default `"user"`), `:target_id`, `:target_type`
  * `:metadata` (default `%{}`)
  * `:occurred_at` (default `DateTime.utc_now/0`)

## Examples

    audit_event_fixture(repo: MyApp.Repo, audit_schema: MyApp.AuditEvent)

    audit_event_fixture(
      repo: MyApp.Repo,
      audit_schema: MyApp.AuditEvent,
      action: "billing.charge.created",
      outcome: "failure",
      metadata: %{amount: 99}
    )

# `bypass_mfa`
*since 0.6.0* 

```elixir
@spec bypass_mfa(Plug.Conn.t()) :: Plug.Conn.t()
```

Marks user's session as MFA-completed without requiring code entry.

For tests that don't care about the MFA verification flow. Returns
a conn with the session type set to `:standard`.

## Examples

    conn = Sigra.Testing.bypass_mfa(conn)

# `create_api_token`
*since 0.7.0* 

```elixir
@spec create_api_token(Sigra.Config.t(), struct(), keyword()) ::
  {String.t(), struct()}
```

Creates an API token and returns `{raw_key, token_record}`.

## Options

  * `:name` - Token name (default: `"test-token"`)
  * `:scopes` - List of scope strings (default: `["*"]`)
  * `:expires_at` - Expiration datetime (default: `nil`)

# `create_backup_codes`
*since 0.6.0* 

```elixir
@spec create_backup_codes(struct(), keyword()) :: [String.t()]
```

Generates backup codes for a user and stores hashes in the DB.

Returns a list of raw formatted codes (shown once to user).

## Options

  * `:config` - `%Sigra.Config{}` (required)
  * `:backup_code_schema` - Backup code schema module (required)
  * `:count` - Number of codes to generate (default: 8)

# `create_identity`
*since 0.5.0* 

```elixir
@spec create_identity(keyword()) :: Sigra.Identity.t()
```

Creates an identity struct for testing.

## Options

  - `:provider` - Provider string (default: `"google"`)
  - `:provider_uid` - UID string (default: auto-generated)
  - `:user_id` - Associated user ID (required)
  - `:email` - Provider email (default: `"oauth@example.com"`)
  - `:name` - Provider name (default: `"Test User"`)
  - `:id` - Identity ID (default: auto-generated)

# `deleted_user_fixture`
*since 0.8.0* 

```elixir
@spec deleted_user_fixture(
  module(),
  struct()
) :: struct()
```

Creates a user fixture in fully deleted/anonymized state.

Sets `deleted_at` to a past timestamp and anonymizes the email.

# `expired_api_token_fixture`
*since 0.7.0* 

```elixir
@spec expired_api_token_fixture(Sigra.Config.t(), struct(), keyword()) ::
  {String.t(), struct()}
```

Creates an expired API token fixture.

# `expired_jwt`
*since 0.7.0* 

```elixir
@spec expired_jwt(Sigra.Config.t(), struct(), keyword()) :: String.t()
```

Generates an expired JWT for testing.

Uses Joken directly to create a token with a past expiry timestamp.

## Options

  * `:scopes` - List of scope strings (default: `["*"]`)

# `extract_confirmation_token`
*since 0.3.0* 

```elixir
@spec extract_confirmation_token(String.t()) :: String.t()
```

Extracts the confirmation token from a confirmation URL string.

Parses `/users/confirm/<token>` and returns the token portion. Works
with absolute URLs, URLs with query strings, and bare paths.

## Examples

    iex> Sigra.Testing.extract_confirmation_token("https://example.com/users/confirm/abc123")
    "abc123"

    iex> Sigra.Testing.extract_confirmation_token("/users/confirm/abc123")
    "abc123"

    iex> Sigra.Testing.extract_confirmation_token("https://example.com/users/confirm/SFMyNTY.encoded-token")
    "SFMyNTY.encoded-token"

# `extract_reset_token`
*since 0.3.0* 

```elixir
@spec extract_reset_token(String.t()) :: String.t()
```

Extracts the reset password token from a reset URL string.

Parses `/users/reset-password/<token>` and returns the token portion.
Same path-tail semantics as `extract_confirmation_token/1`.

## Examples

    iex> Sigra.Testing.extract_reset_token("https://example.com/users/reset-password/xyz789")
    "xyz789"

    iex> Sigra.Testing.extract_reset_token("/users/reset-password/xyz789")
    "xyz789"

    iex> Sigra.Testing.extract_reset_token("https://app.example.com/users/reset-password/SFMyNTY.tok")
    "SFMyNTY.tok"

# `force_password_change_fixture`
*since 0.8.0* 

```elixir
@spec force_password_change_fixture(
  module(),
  struct()
) :: struct()
```

Creates a user fixture with the force password change flag set.

Sets `must_change_password` to `true` on the user.

# `generate_jwt`
*since 0.7.0* 

```elixir
@spec generate_jwt(Sigra.Config.t(), struct(), keyword()) :: String.t()
```

Generates a JWT access token for testing.

## Options

  * `:scopes` - List of scope strings (default: `["*"]`)

# `generate_totp_code`
*since 0.6.0* 

```elixir
@spec generate_totp_code(binary()) :: String.t()
```

Generates a valid TOTP code for the given raw secret.

Uses `NimbleTOTP.verification_code/1` to produce a real 6-digit code
valid for the current time window.

## Examples

    code = Sigra.Testing.generate_totp_code(raw_secret)
    assert String.length(code) == 6

# `jwt_with_scopes`
*since 0.7.0* 

```elixir
@spec jwt_with_scopes(Sigra.Config.t(), struct(), [String.t()]) :: String.t()
```

Generates a JWT with specific scopes for testing.

# `mock_oauth_callback`
*since 0.5.0* 

```elixir
@spec mock_oauth_callback(keyword()) :: map()
```

Creates a mock OAuth callback result for testing.

Returns a map matching the shape used by `Sigra.OAuth.Callback`.

## Options

  - `:provider` - Provider atom (default: `:google`)
  - `:email` - User email (default: `"oauth@example.com"`)
  - `:uid` - Provider UID (default: `"provider_123"`)
  - `:name` - User name (default: `"OAuth User"`)
  - `:email_verified` - Whether the email is verified (default: `true`)

# `oauth_user_fixture`
*since 0.5.0* 

```elixir
@spec oauth_user_fixture(keyword()) :: map()
```

Creates a user registered via OAuth for testing.

Returns `%{user: user_attrs, identity: identity}` map with default
OAuth-specific values (confirmed_at set since OAuth auto-confirms).

## Options

  - `:email` - User email (default: `"oauth@example.com"`)
  - `:provider` - Provider string (default: `"google"`)
  - `:provider_uid` - Provider UID (default: auto-generated)
  - `:user_id` - User ID (default: auto-generated)

# `put_api_token`
*since 0.7.0* 

```elixir
@spec put_api_token(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
```

Alias for `put_bearer_token/2`.

## Examples

    iex> conn = Plug.Test.conn(:get, "/api/me")
    iex> conn = Sigra.Testing.put_api_token(conn, "sigra_sk_xyz")
    iex> Plug.Conn.get_req_header(conn, "authorization")
    ["Bearer sigra_sk_xyz"]

# `put_bearer_token`
*since 0.7.0* 

```elixir
@spec put_bearer_token(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
```

Adds a Bearer token header to a conn for API testing.

Overwrites any existing `authorization` header on the conn. The token is
wrapped in `"Bearer "` exactly — pass the raw `sigra_sk_...` value, not
a pre-formatted header.

## Examples

    iex> conn = Plug.Test.conn(:get, "/api/me")
    iex> conn = Sigra.Testing.put_bearer_token(conn, "sigra_sk_abc123")
    iex> Plug.Conn.get_req_header(conn, "authorization")
    ["Bearer sigra_sk_abc123"]

    iex> conn = Plug.Test.conn(:get, "/api/me")
    iex> conn = Sigra.Testing.put_bearer_token(conn, "raw-token-value")
    iex> Plug.Conn.get_req_header(conn, "authorization")
    ["Bearer raw-token-value"]

# `revoked_api_token_fixture`
*since 0.7.0* 

```elixir
@spec revoked_api_token_fixture(Sigra.Config.t(), struct(), keyword()) ::
  {String.t(), struct()}
```

Creates a revoked API token fixture.

# `scheduled_deletion_fixture`
*since 0.8.0* 

```elixir
@spec scheduled_deletion_fixture(module(), struct(), keyword()) :: struct()
```

Creates a user fixture with scheduled deletion.

Sets `deleted_at` and `scheduled_deletion_at` on the user, plus
stores the original email in `original_email` for restore on cancel.

## Options

  * `:grace_period_days` - Days until permanent deletion (default: 14)

# `scoped_api_token_fixture`
*since 0.7.0* 

```elixir
@spec scoped_api_token_fixture(Sigra.Config.t(), struct(), [String.t()], keyword()) ::
  {String.t(), struct()}
```

Creates a scoped API token fixture.

# `setup_totp`
*since 0.6.0* 

```elixir
@spec setup_totp(struct(), keyword()) :: map()
```

Creates a fully enrolled MFA credential for the user.

Generates a real TOTP secret, creates the credential in the DB,
and generates backup codes. Returns the secret, credential, and
raw formatted backup codes.

## Options

  * `:config` - `%Sigra.Config{}` (required)
  * `:mfa_credential_schema` - MFA credential schema module (required)
  * `:backup_code_schema` - Backup code schema module (required)
  * `:backup_code_count` - Number of backup codes (default: 8)

## Returns

    %{secret: raw_secret, credential: credential, backup_codes: [formatted_codes]}

# `simulate_grace_period_expiry`
*since 0.8.0* 

```elixir
@spec simulate_grace_period_expiry(
  module(),
  struct()
) :: struct()
```

Simulates grace period expiry by setting `scheduled_deletion_at` to a past timestamp.

Useful for testing the Oban deletion worker.

# `simulate_lockout`
*since 0.4.0* 

```elixir
@spec simulate_lockout(module(), struct(), keyword()) :: struct()
```

Simulate a locked out user by setting failed_login_attempts to threshold.

Returns the updated user struct. Requires a repo module that supports
`update!/1`.

## Options

  * `:threshold` - The lockout threshold to simulate. Default: `5`.

## Examples

    locked_user = Sigra.Testing.simulate_lockout(MyApp.Repo, user)
    assert Sigra.Lockout.locked?(locked_user)

# `simulate_mfa_lockout`
*since 0.6.0* 

```elixir
@spec simulate_mfa_lockout(struct(), keyword()) :: :ok
```

Simulates an MFA lockout by setting failed_attempts to threshold.

Sets `failed_attempts` to the lockout threshold and `locked_until`
to 15 minutes from now on the user's MFA credential.

## Options

  * `:config` - `%Sigra.Config{}` (required)
  * `:mfa_credential_schema` - MFA credential schema module (required)
  * `:threshold` - Lockout threshold (default: 5)
  * `:duration` - Lockout duration in seconds (default: 900)

# `trust_browser`
*since 0.6.0* 

```elixir
@spec trust_browser(Plug.Conn.t(), struct(), keyword()) :: Plug.Conn.t()
```

Sets the `_sigra_mfa_trust` cookie on the conn for the given user.

Simulates a trusted browser for MFA bypass in tests.

## Options

  * `:secret_key_base` - The app's secret key base (default: generates a test key)
  * `:trust_epoch` - The user's trust epoch (default: 0)
  * `:trust_ttl` - Trust cookie TTL in seconds (default: 2_592_000 = 30 days)

# `with_hook`
*since 0.8.0* 

```elixir
@spec with_hook(atom(), {module(), atom()}, (-&gt; term())) :: term()
```

Temporarily overrides a hook for a test block.

Swaps the hook configuration for the given operation, runs the test
function, and restores the original configuration afterward.

## Examples

    Sigra.Testing.with_hook(:on_delete, {MyApp.TestHooks, :on_delete}, fn ->
      # test code that exercises the hook
    end)

# `with_test_mailer`
*since 0.1.0* 

```elixir
@spec with_test_mailer((-&gt; term())) :: term()
```

Executes the given function with a test mailer configured.

> #### Stub {: .warning}
>
> This function is a stub that will be filled in by later phases
> when the Mox-based mailer testing infrastructure is implemented.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
