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 · P1Protection & access control
test_invite.py
Invite flow + scope boundary
IntegrationP1
Cases
owner creates invite→ 201, the link is sent by email (SMTP — mock)
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_mapping → user_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 S → identity_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
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
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
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.
MFATOTP (±1-step window, replay protection), recovery codes, a separate
MFA-step limit, encryption of mfa_secret
SSO / OIDCstate / PKCE / nonce, auto-provisioning from the IdP, auto-match only on
a verified email
OAuthinteractive token-issuance flow, headless sign-in via an
API key
Instant revocationjti in a Redis blacklist on deactivation — invalidation
without the 15-minute window
StructureTest file structure
Priority (P0–P2) is orthogonal to directories and set by markers
(pytest -m p0), not by separate folders.