← Auth / Security

Test cases

auth / security · workzone
Decisions made
Schemathesis → make fuzz-api A separate command, not part of make test. Run manually or as a dedicated CI step.
Stack and infrastructure
Have done pytest, pytest-asyncio, pytest-cov, httpx
Add testcontainers (PostgreSQL + Redis), factory_boy + Faker, time-machine, respx (httpx mock for HIBP)
Fuzz Schemathesis — fuzzes against the OpenAPI schema, surfaces 500 responses and contract violations
Test DB testcontainers-python — real PostgreSQL + Redis, session-scoped containers + per-test rollback
Markers @pytest.mark.unit, @pytest.mark.integration — filter by type
Unit No DB — cryptography and validation
test_jwt.py
JWT tokens — create / validate / edge cases
Cases
create — claimssub, role, exp, iat, jti, iss=achilles, aud=achilles-api
create → decodevalid token → correct payload
expired → rejectexp in the past → ExpiredSignatureError
wrong key → rejectsigned with a different SECRET_KEY → error
alg substitution → rejectRS256 when the server is HS256-only (hardcoded algorithms)
alg=none → rejectheader alg: none without a signature → rejected
kid in headerkey selected by kid from the registry
missing claim → rejectwithout sub or role → error
iss/aud mismatch → rejectforeign issuer/audience → reject
jti uniquenesstwo creates → different jti
test_password.py
Passwords and policy — argon2id, zxcvbn, HIBP
Cases
hash + verify OKhash(pw)verify = True
wrong password → Falsea different string doesn't pass
argon2 paramsmemory=19MB, iterations=2, parallelism=1 — in the hash
too short (<8)→ reject
too long (>128)→ reject
weak (zxcvbn)"password123456" below the threshold → reject
HIBP — compromisedrespx: found in a breach → reject
HIBP — cleanrespx: not found → accept
HIBP — unavailabletimeout/500 → fail open, warning
strong passwordpasses length, zxcvbn, HIBP
test_tokens.py
CSPRNG tokens — invite, refresh hash, messenger link code
Unit
Cases
invite token format≥32 bytes, URL-safe (base64url)
uniqueness100 generations → all unique
constant-time comparehmac.compare_digest(), not ==
refresh token — hashSHA-256 ≠ raw token
messenger link code — hashshort CSPRNG, in the DB code_hash SHA-256 ≠ raw
Integration · P0 Security invariants & core flows
test_setup.py
Setup Wizard — the first Owner
IntegrationP0
Cases
setup available0 users → GET /setup → 200
create owner OKPOST /setup → Owner in the DB, access + refresh
setup goneafter the Owner → 404 SETUP_UNAVAILABLE
race condition2 concurrent POSTs → one Owner, the second 409 CONFLICT
invalid email→ 422 VALIDATION_ERROR + errors[], Owner NOT created
weak password→ 422 VALIDATION_ERROR + errors[], NOT created
test_login.py
Login — token issuance, generic errors
IntegrationP0
Cases
successaccess (body) + refresh (httpOnly cookie)
wrong password→ 401 INVALID_CREDENTIALS generic
unknown email→ 401 same generic — we don't reveal existence
deactivated user→ 401 — we don't reveal status
cookie flagshttpOnly, Secure, SameSite=Strict, Path=/api/auth, name with the __Secure- prefix
remember-me offsession cookie without Max-Age — dies when the browser closes
remember-me onpersistent cookie, Max-Age = refresh TTL (30 d)
email case-insensitivesigning in as Alice@x for the alice@x account → success
token claimssub=user_id, role=actual
timing-safe (spy)nonexistent email → dummy argon2 verify (mock/spy)
test_refresh.py
Token rotation — reuse detection
Cases
valid refresh → new pairnew access + new refresh (rotation)
grace window (~10s)a repeat refresh with the same token within the window → the same new pair, family alive (tab race)
old token → rejectrotated, outside the window → 401 TOKEN_INVALID
reuse → kill familyrevoked → 401 TOKEN_INVALID, the whole family
expired (30d)time-machine +31d → 401 TOKEN_EXPIRED
absolute ceiling (90d)90d from first issue → 401 TOKEN_EXPIRED
invalid signatureforgery → 401 TOKEN_INVALID
test_logout.py
Logout — ending the session
IntegrationP0
Cases
standard logoutrefresh deleted from the DB, Set-Cookie clears it
post-logout refresh→ 401
logout allPOST /api/v1/auth/logout-all → all refresh deleted
access still validafter logout until expiry — stateless, 15 min
can't kill your own familyending the current session family via session management → the server rejects it (not merely hidden in the UI) — a server-side guard
test_must_change_password.py
Forced change of a temporary password
IntegrationP0
Cases
login with a temporary passwordmust_change_password=true → tokens issued, but gated: redirect to the password-change screen
server-side gateflag set → request to any protected endpoint → 403 PASSWORD_CHANGE_REQUIRED; password change and logout are allowed
change at the gatestrong new password → flag cleared, the user's other refresh tokens revoked (except the current one)
login again without changingflag still set → the password-change gate again
Integration · P1 Protection & access control
test_invite.py
Invite flow + scope boundary
IntegrationP1
Cases
owner creates invite→ 201, the link is sent by email (SMTP — mock)
admin creates invite→ 201 (if permitted)
admin invite → owner role→ 403 FORBIDDEN (scope boundary)
member → 403can't create an invite
SMTP unconfiguredcreating an invite → 409 SMTP_NOT_CONFIGURED
accept invitetoken + name + password → User with a role
expired (48h)+49h → 410 INVITE_EXPIRED
reuse invite→ 410 INVITE_USED
duplicate email→ 409 CONFLICT
duplicate (case)Bob@x for the bob@x account → 409 (lower(email))
test_bulk_invite.py
Bulk invite — partial success, duplicates, limit
IntegrationP1
Cases
partial successa batch mixing valid and broken rows → 207/200 with a per-row report: accepted rows create an invite, rejected ones carry a reason; valid rows aren't rolled back over neighboring errors
duplicates within the batcha repeated email within the batch and the email of an existing user/invite → the row is flagged a duplicate, no second invite is created
invalid rowbroken email / missing required field → the row is rejected with VALIDATION_ERROR in the report, not a blanket 422 on the whole batch
size limita batch over the row cap → 422, the batch is not partially processed
scope boundary in bulkthe inviter can't grant a role above their own on any row of the batch (the same perimeter as single invites); a violation → the row is rejected
re-upload idempotencyre-uploading the same file doesn't double already-created invites (by email), and already-accepted ones are skipped
test_messenger_link.py
Messenger linking — resolve, code, relay safety, brute force
IntegrationP1
Cases
known user → resolveDM from a linked messenger → identity_mappinguser_id, no code
auto-match by emailworkspace provisioned email == account → link automatic, no code
auto-match offtoggle off → even on an email match the bot issues a code
unknown user → codeno link and no auto-match → link/code, identity_mapping untouched
return code → bindcode from DM Sidentity_mapping(slack, S) ↔ the issuing account, used_at
relay-safetyaccount A's code, returned from Slack B → link B↔A; a foreign Slack can't be bound to A without its code
expired (15m)+16m → 410 LINK_EXPIRED
reuse codeused → 410 LINK_EXPIRED (single-use)
already linkedaccount already in identity_mapping → 409 ALREADY_LINKED
telegram → code-onlyTelegram doesn't expose email → no auto-match, always a code; return → identity_mapping(telegram, …)
mattermost → code-onlyevents carry no email → no auto-match, always a code; return → identity_mapping(mattermost, …)
code brute-force5 wrong codes per chat_id → the attempt is burned, a new code is needed
no self-registrationa code without an account grants no sign-in — it links only to an existing one
test_rbac.py
RBAC middleware — role matrix
IntegrationP1
Cases
no token → 401protected without Authorization → 401
owner → owner-only→ 200
admin → owner-only→ 403
member → admin-only→ 403
owner → all endpointsfull access (matrix)
admin → user mgmtusers, sources → 200
member → own data/me 200, /users 403
require(permission)via the ROLE_PERMISSIONS mapping, not the role
unknown permission → 403not in the mapping → 403 for any role
admin scope boundaryMembers 200, non-Owners/Admins 403
role escalation → 403can't assign above your own
invalid role in tokenrole="superadmin" → 403
deactivated + valid tokenv1 stateless → 200, 15-min window (trade-off, documented)
stale role — critical endpointtoken role=admin, DB member; user management reads the role from the DB → 403
stale status — critical endpointvalid token, DB status=deactivated; a critical operation reads status from the DB → 401
test_ownership.py
Resource ownership — IDOR protection
IntegrationP1
Cases
own resource → 200the owner edits their own resource — owner_id = user_id
foreign resource → 403Member A edits Member B's resource → 403 FORBIDDEN (IDOR)
permission yes, ownership noan owner_id check on top of permission → 403
admin's normal zoneuser management bypasses ownership; source data does not
depends on resourcesspecific endpoints arrive with Agent Engine
test_brute_force.py
Brute-force protection — rate limit, delays
Cases
IP rate limit21st / 15 min → 429 RATE_LIMITED + retry_after
exponential delayafter 3 failures → 1s / 2s / 4s
below threshold1–2 → no delay (threshold from the 3rd)
success resetsa successful login resets the counter
alert (layer 3)10 per-account failures → notify Owner/Admin
case doesn't resetbrute:account by lower(email) — changing case doesn't zero the counter
IP isolationIP-A exhausted → IP-B still works
delay cap 30sdelay ≤ 30 seconds
XFF spoofinga forged X-Forwarded-For from an untrusted source doesn't override the IP — the limit can't be bypassed
test_csrf.py
CSRF protection — Origin whitelist
IntegrationP1 → CSRF check
Cases
no cookie → 401/refresh without the httpOnly cookie → 401
origin mismatchforeign Origin → 403
valid origin → OKOrigin from the whitelist → let through
test_cors.py
CORS configuration
IntegrationP1
Cases
allowed origin → OKwhitelist → Access-Control-Allow-Origin
disallowed originnot whitelisted → no CORS headers
preflightOPTIONS → Allow-Credentials true, Allow-Methods, Max-Age 3600
test_error_envelope.py
Error envelope — Problem Details (RFC 9457)
IntegrationP1 → API error format
Cases
problem+json shapeany error → Content-Type: application/problem+json and a body with type·title·status·detail·code·request_id
request_id = headerrequest_id in the body matches the response's X-Request-Id — end-to-end tracing
422 in problem formatvalidation returns the same envelope (not the framework's default ValidationError shape), code=VALIDATION_ERROR + errors[] of { field, message }
429 carries retry_afterthe body contains retry_after (sec) + a Retry-After header
test_api_rate_limit.py
Per-user API rate limit — role tiers
Cases
bucket exhausted → 429exceeding the per-user cap → 429 RATE_LIMITED + Retry-After
tier by roleAdmin has a higher cap than Member — under equal load Member hits the limit sooner
X-RateLimit-Remainingthe response carries the remaining bucket in a header
≠ brute-force linethis limit is per-user after authentication; login brute force ⑤ is per-IP before sign-in (different counters, no overlap)
test_security_headers.py
Security headers — checked on the response
IntegrationP1
Cases
HSTSStrict-Transport-Security — max-age 1 year, includeSubDomains
CSPdefault-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
nosniffX-Content-Type-Options: nosniff
frame denyX-Frame-Options: DENY
COOP / CORPboth same-origin
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera / microphone / geolocation disabled
no-store on sensitiveCache-Control: no-store on responses with tokens / profile
fingerprint hiddenServer and X-Powered-By are not sent
Clear-Site-Data on logoutthe /api/v1/auth/logout response sends Clear-Site-Data (cookies, storage, cache); other responses don't
no-referrer on token routesinvite-accept, reset-password, and messenger-link send Referrer-Policy: no-referrer — the secret link won't leak; ordinary routes keep strict-origin-when-cross-origin
test_password_change.py
Password change + audit
IntegrationP1
Cases
change OKcurrent + strong new → 200, hash updated
wrong current→ 401 INVALID_CREDENTIALS
wrong current ×3+per-account delay grows — the shared brute-force counter with login
weak new→ 422 VALIDATION_ERROR + errors[]
same as current→ 422 VALIDATION_ERROR
other sessions killedall refresh except the current one deleted
audit entryaction=password_change
audit entry (failure)action=password_change, result=failure
test_cli.py
CLI commands — init-owner, reset-password
IntegrationP1
Cases
init-owner OK0 users → Owner created
init-owner existsusers exist → error, none created
init-owner weak pwpassword doesn't pass → error
reset-password OKtemporary password (CSPRNG), hash updated, all refresh deleted
reset-password unknownnonexistent email → error
test_user_lifecycle.py
Deactivation, password reset, scope
IntegrationP1
Cases
Owner deactivates Member→ 200, the user's refresh tokens and API keys revoked
Admin deactivates Member→ 200
Admin deactivates Admin→ 403 FORBIDDEN (Owner only)
self-deactivation→ 403 FORBIDDEN
reactivate → login OKold tokens not restored
Owner resets Member pw→ 200, reset link by email (without SMTP — a temporary password, refresh deleted); audit
Admin resets Owner pw→ 403 (scope boundary)
deactivation + reset → audituser_deactivated, password_reset
admin "terminate all"all the user's refresh tokens revoked → 200; access survives the ≤15-min window (stateless trade-off)
test_api_keys.py
Issuance, authentication, revocation
IntegrationP1
Cases
create · shown oncethe response contains the full key (ach_ + CSPRNG) exactly once; the DB holds only the SHA-256 hash and prefix
authentication by keya valid key in the header → user_id → role / ACL; wrong / nonexistent → 401
scope ≤ owner's rightsthe key is no broader than the owner's role and ACL; a source narrowing applies on top
read-onlya write attempt via the key → 403 (writes are Agent Engine's territory)
issued by an adminOwner / Admin issues a key for an employee; scope is the employee's rights, not the admin's
expired keyexpires_at in the past → 401 without a separate revocation; a non-expiring key (NULL) doesn't expire
revocation is instantis_revoked → the next request 401; revocable by the owner and by Owner / Admin
cascade on deactivationdeactivating the owner → all their keys revoked
rate limit60 req/min per key → over the limit 429; last_used_at is updated
auditkey creation and revocation are written to audit_log
test_email_change.py
Email change by an admin
IntegrationP1
Cases
admin changes email→ 200, email updated; the user's refresh tokens revoked
email takenthe new address already exists (including a different case, lower(email)) → 409 CONFLICT
audit entryaction=email_changed, metadata old/new, actor=admin_id, target
test_identity_bridge.py
Auto-link identity↔users by email (the bridge from the sign-in side)
Cases
accept invite → bridgeaccepting an invite for alice@x with an existing identity of the same email → identity.user_id = the new user
case-insensitiveinvite Alice@x with identity alice@x → link by lower(email)
no identity yetno matching identity → no-op, no error; it links later when Harvester upserts it
setup owner → bridgethe first Owner via /setup / init-owner links to an identity of the same email
email change → bridge movesadmin changes email → the bridge is re-established on the new address's identity, the previous link is removed
address didn't match → no bridgesign-in email ≠ source email → identity.user_id stays NULL, awaits manual linking in Admin
manual link not overwrittena link pinned by an admin () is not overwritten by auto-linking
test_password_reset.py
Reset via email link — "forgot password" and admin reset (SMTP — mock)
IntegrationP1
Cases
forgot known email→ 200, email with the link; the DB holds only the token hash
forgot unknown email→ 200, response indistinguishable from a known one (anti-enumeration)
resend rate-limitfrequent requests for one address → 429
set password OKhash updated, all refresh deleted, audit
token reusereuse → 410 RESET_EXPIRED (indistinguishable from expired)
token expiredTTL 1 h elapsed → 410 RESET_EXPIRED
new link kills olda resend invalidates the previous token
no sessions killed on sendsending the link doesn't touch refresh — they're revoked only when the password is set
SMTP unconfiguredself-service unavailable; admin reset falls back to a temporary password
Integration · P2 Business logic guards
test_last_owner.py
Last-Owner protection + hard delete
IntegrationP2 → Deletion
Cases
delete last ownerthe sole one → 403 LAST_OWNER_PROTECTED
deactivate last owner→ 403 LAST_OWNER_PROTECTED
downgrade last ownerOwner→Admin with 1 Owner → 403 LAST_OWNER_PROTECTED
delete non-last ownerwith 2+ → 200
delete — Owner onlyAdmin calls delete → 403 FORBIDDEN; deletion is irreversible, the right belongs to Owner only
hard-delete → auth cascadedeleting a non-last user physically removes the row + cascades their refresh tokens and API keys
source content intactafter deletion, source data and audit_log are untouched (see test_audit_log.pyactor_id without an FK)
test_audit_log.py
Audit log — append-only, owner-read
IntegrationP2 → Audit log
Cases
login success → entryactor, action=login, success, ip, ua
login failure → entrylogin_failed, failure, ip, ua, actor_id=NULL
user created → entryuser_created, target=new_user_id
role changed → entryrole_changed, metadata old/new
logout → entryaction=logout, actor=user_id
user deactivated → entryuser_deactivated, actor=admin_id, target
read access — owner onlyOwner 200; Admin/Member 403 FORBIDDEN
append-onlySQL UPDATE/DELETE → error (DB policy)
survives actor deletionhard-delete user → audit_log rows intact (actor_id without an FK)
Frontend React — auth flows
AuthProvider + ProtectedRoute + interceptor
state, redirect, deep-link, auto-refresh
Frontend
Cases
login → stateAuthProvider returns user + role
logout → redirectstate cleared, redirect /login
unauthenticated → redirectProtectedRoute without a token → /login
deep-link → ?next returna guest on a protected route → /login?next=… → after sign-in, return to the original
?next local onlyan external ?next=https://evil… / //evil is ignored → redirect to the role's home (open-redirect protection)
entry gate by rolewith a session: Owner/Admin → /admin, Member → /chat
wrong role → 403 pageMember on admin-only → 403
auto-refresh401 → /refresh → retry
refresh failed → logout/refresh 401 → full logout + redirect
concurrent 401sseveral 401s → one refresh → all retry
403 without refresha permission 403 is passed through — it triggers neither refresh nor logout
retry-once (anti-loop)_retry flag: a 401 on an already-retried request → logout without recursion
refresh timeout → logoutrefresh Promise timeout → logout instead of hanging
setup redirect0 users → any route → /setup
v2 Deferred coverage

The test plan above covers the first version's scope. The areas below are designed alongside their respective v2 features — only the coverage is fixed here, without detailed cases.

MFA TOTP (±1-step window, replay protection), recovery codes, a separate MFA-step limit, encryption of mfa_secret
SSO / OIDC state / PKCE / nonce, auto-provisioning from the IdP, auto-match only on a verified email
OAuth interactive token-issuance flow, headless sign-in via an API key
Instant revocation jti in a Redis blacklist on deactivation — invalidation without the 15-minute window
Structure Test file structure

Priority (P0–P2) is orthogonal to directories and set by markers (pytest -m p0), not by separate folders.

  • tests/backend — shared scaffolding
    • conftest.pyDB engine, session, HTTP client
    • factories/data factories, cross-module
    • auth/module directory
      • conftest.pymodule fixtures
      • unit/no DB — crypto and validation
      • integration/with DB — by domain
        • session/setup · login · logout · refresh
        • access/rbac · ownership · invite · brute-force · csrf · cors · security-headers
        • account/password change · CLI · lifecycle · identity-bridge
        • audit/last-owner · audit-log
  • frontend/…/auth/__tests__/next to the code, by module