Sigra.Testing (Sigra v1.20.0)

Copy Markdown View Source

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

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

Summary

Functions

Asserts that the user account was permanently deleted or anonymized.

Asserts that an audit event matches the given map.

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

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

Asserts that the user account deletion was cancelled.

Asserts that the user account is scheduled for deletion.

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

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

Asserts that the user does not have MFA enabled.

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

Asserts that the user's password was recently changed.

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

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

Asserts that a conn received a 403 insufficient scope response.

Asserts that a scope has the expected active organization id.

Asserts that a session was created on the given conn.

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

Asserts that a token has been revoked.

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

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.

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

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

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

Creates an identity struct for testing.

Creates a user fixture in fully deleted/anonymized state.

Creates an expired API token fixture.

Generates an expired JWT for testing.

Extracts the confirmation token from a confirmation URL string.

Extracts the reset password token from a reset URL string.

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

Generates a JWT access token for testing.

Generates a valid TOTP code for the given raw secret.

Generates a JWT with specific scopes for testing.

Creates a mock OAuth callback result for testing.

Creates a user registered via OAuth for testing.

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

Creates a revoked API token fixture.

Creates a user fixture with scheduled deletion.

Creates a fully enrolled MFA credential for the user.

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

Simulate a locked out user by setting failed_login_attempts to threshold.

Simulates an MFA lockout by setting failed_attempts to threshold.

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

Temporarily overrides a hook for a test block.

Executes the given function with a test mailer configured.

Functions

assert_account_deleted(repo, user_schema, user_id)

(since 0.8.0)
@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(expected, opts)

(since 0.10.0)
@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(expected, opts)

(since 0.11.0)
@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(organization, opts)

(since 0.11.0)
@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(user)

(since 0.8.0)
@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(user)

(since 0.8.0)
@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(opts \\ [])

(since 0.3.0)
@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(membership, user, opts)

(since 0.11.0)
@spec assert_membership(map(), map(), keyword()) :: true

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

assert_mfa_disabled(user, opts \\ [])

(since 0.6.0)
@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(user, opts \\ [])

(since 0.6.0)
@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(user)

(since 0.8.0)
@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(user)

(since 0.1.0)
@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(conn)

(since 0.4.0)
@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(conn)

(since 0.7.0)
@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(scope, expected_org)

(since 0.11.0)
@spec assert_scope_has_org(map(), map() | binary()) :: true

Asserts that a scope has the expected active organization id.

assert_session_created(conn)

(since 0.1.0)
@spec assert_session_created(term()) :: true

Asserts that a session was created on the given conn.

Stub

This function is a stub that will be filled in by later phases when session management is fully implemented.

assert_sessions_invalidated(repo, user, opts \\ [])

(since 0.8.0)
@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(config, token_id)

(since 0.7.0)
@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(to, context)

(since 0.1.0)
@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(opts)

(since 0.10.0)
@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(conn)

(since 0.6.0)
@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(config, user, opts \\ [])

(since 0.7.0)
@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(user, opts \\ [])

(since 0.6.0)
@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(opts \\ [])

(since 0.5.0)
@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(repo, user)

(since 0.8.0)
@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(config, user, opts \\ [])

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

Creates an expired API token fixture.

expired_jwt(config, user, opts \\ [])

(since 0.7.0)
@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(url)

(since 0.3.0)
@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(url)

(since 0.3.0)
@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(repo, user)

(since 0.8.0)
@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(config, user, opts \\ [])

(since 0.7.0)
@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(secret)

(since 0.6.0)
@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(config, user, scopes)

(since 0.7.0)
@spec jwt_with_scopes(Sigra.Config.t(), struct(), [String.t()]) :: String.t()

Generates a JWT with specific scopes for testing.

mock_oauth_callback(opts \\ [])

(since 0.5.0)
@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(opts \\ [])

(since 0.5.0)
@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(conn, raw_token)

(since 0.7.0)
@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(conn, raw_token)

(since 0.7.0)
@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(config, user, opts \\ [])

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

Creates a revoked API token fixture.

scheduled_deletion_fixture(repo, user, opts \\ [])

(since 0.8.0)
@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(config, user, scopes, opts \\ [])

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

Creates a scoped API token fixture.

setup_totp(user, opts \\ [])

(since 0.6.0)
@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(repo, user)

(since 0.8.0)
@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(repo, user, opts \\ [])

(since 0.4.0)
@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(user, opts \\ [])

(since 0.6.0)
@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(conn, user, opts \\ [])

(since 0.6.0)
@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(operation, arg, test_fn)

(since 0.8.0)
@spec with_hook(atom(), {module(), atom()}, (-> 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(fun)

(since 0.1.0)
@spec with_test_mailer((-> term())) :: term()

Executes the given function with a test mailer configured.

Stub

This function is a stub that will be filled in by later phases when the Mox-based mailer testing infrastructure is implemented.