Files
admin/_docs/02_document/tests/blackbox-tests.md
T
Oleksandr Bezdieniezhnykh a77b3f8a59 [AZ-529] [AZ-530] Cycle-2 documentation refresh
Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.

Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
  /mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}

Step 13 (Update Docs) output for cycle 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:22:53 +03:00

1476 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Blackbox Tests
## Positive Scenarios
### FT-P-01: Successful Login
**Summary**: User with valid credentials receives a JWT token.
**Traces to**: AC-1
**Category**: Authentication
**Preconditions**:
- Seed user `admin@azaion.com` exists in database
**Input data**: Valid email/password for seed admin user
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with valid email and password | HTTP 200, body contains non-empty `token` string |
**Expected outcome**: HTTP 200 with JWT token in response body
**Max execution time**: 5s
---
### FT-P-02: Successful User Registration
**Summary**: ApiAdmin creates a new user account.
**Traces to**: AC-5, AC-6, AC-7
**Category**: User Management
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: `{"email":"newuser@test.com","password":"validpwd1","role":"Operator"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Login as admin to get JWT | HTTP 200, JWT token |
| 2 | POST /users with valid registration data and ApiAdmin JWT | HTTP 200 |
**Expected outcome**: HTTP 200, user created
**Max execution time**: 5s
---
### FT-P-03: JWT Token Structure Validation
**Summary**: JWT token contains correct issuer, audience, and lifetime claims.
**Traces to**: AC-4
**Category**: Authentication
**Preconditions**:
- Valid login completed
**Input data**: JWT token from login response
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Login to get JWT | HTTP 200, JWT token |
| 2 | Decode JWT payload (Base64) | Claims contain `iss`, `aud`, `exp` |
| 3 | Validate `iss` == "AzaionApi" | Match |
| 4 | Validate `aud` == "Annotators/OrangePi/Admins" | Match |
| 5 | Validate `exp` - `iat` ≈ 14400s (4 hours) | Within ± 60s |
**Expected outcome**: All JWT claims match expected values
**Max execution time**: 5s
---
### FT-P-04: First Hardware Check Stores Fingerprint
**Summary**: On first hardware check, the fingerprint is stored for the user.
**Traces to**: AC-10
**Category**: Hardware Binding
**Preconditions**:
- User exists with no hardware bound
**Input data**: `{"hardware":"test-hw-fingerprint-001"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Register new user, login to get JWT | HTTP 200 |
| 2 | POST /resources/check with hardware string | HTTP 200, body `true` |
**Expected outcome**: HTTP 200, hardware stored
**Max execution time**: 5s
---
### FT-P-05: Subsequent Hardware Check Matches
**Summary**: Same hardware fingerprint passes validation on subsequent calls.
**Traces to**: AC-11
**Category**: Hardware Binding
**Preconditions**:
- User with hardware already bound (from FT-P-04)
**Input data**: Same hardware string as initial binding
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /resources/check with same hardware | HTTP 200, body `true` |
**Expected outcome**: HTTP 200
**Max execution time**: 5s
---
### FT-P-06: List All Users
**Summary**: ApiAdmin retrieves the user list.
**Traces to**: AC-9
**Category**: User Management
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: GET /users with ApiAdmin JWT
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /users with ApiAdmin JWT | HTTP 200, JSON array with >= 1 user |
**Expected outcome**: HTTP 200, array containing at least seed users
**Max execution time**: 5s
---
### FT-P-07: Filter Users by Email
**Summary**: ApiAdmin filters users by email substring.
**Traces to**: AC-9
**Category**: User Management
**Preconditions**:
- Caller authenticated as ApiAdmin, seed users exist
**Input data**: GET /users?email=admin
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /users?email=admin with ApiAdmin JWT | HTTP 200, all returned emails contain "admin" |
**Expected outcome**: HTTP 200, filtered list
**Max execution time**: 5s
---
### FT-P-08: Upload Resource File
**Summary**: Authenticated user uploads a file to a resource folder.
**Traces to**: AC-13
**Category**: Resource Distribution
**Preconditions**:
- Caller authenticated
**Input data**: Multipart form upload with 1 KB text file
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /resources/testfolder with multipart file | HTTP 200 |
**Expected outcome**: HTTP 200, file stored
**Max execution time**: 5s
---
### FT-P-09: Download Encrypted Resource — OBSOLETE (cycle 2, 2026-05-14)
The `POST /resources/get/{dataFolder?}` endpoint, the `Security.GetApiEncryptionKey` / `EncryptTo` helpers, the `ResourcesService.GetEncryptedResource` method, the `GetResourceRequest` DTO, and the e2e tests `Encrypted_download_returns_octet_stream_and_non_empty_body` (in `ResourceTests.cs`) and `Per_user_encryption_produces_distinct_ciphertext_for_same_file` (in `SecurityTests.cs`) were all removed. The endpoint now returns 404 — verified by FT-N-16 below.
ID retained for traceability stability; do not regenerate the spec body until a full `/test-spec` rerun.
---
### FT-P-10: Encryption Round-Trip Verification — OBSOLETE (cycle 2, 2026-05-14)
Same removal as FT-P-09. Additionally `Security.DecryptTo` and the e2e test `Encryption_round_trip_decrypt_matches_original_bytes` (in `ResourceTests.cs`) are gone. ID retained for traceability stability.
---
### FT-P-11: Change User Role
**Summary**: ApiAdmin changes a user's role.
**Traces to**: AC-9
**Category**: User Management
**Preconditions**:
- Target user exists, caller is ApiAdmin
**Input data**: `{"email":"testuser@test.com","role":"Admin"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PUT /users/role with ApiAdmin JWT | HTTP 200 |
**Expected outcome**: HTTP 200, role updated
**Max execution time**: 5s
---
### FT-P-12: Disable User Account
**Summary**: ApiAdmin disables a user account.
**Traces to**: AC-9
**Category**: User Management
**Preconditions**:
- Target user exists, caller is ApiAdmin
**Input data**: `{"email":"testuser@test.com","isEnabled":false}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PUT /users/enable with ApiAdmin JWT | HTTP 200 |
**Expected outcome**: HTTP 200, account disabled
**Max execution time**: 5s
---
### FT-P-13: Delete User
**Summary**: ApiAdmin deletes a user account.
**Traces to**: AC-9
**Category**: User Management
**Preconditions**:
- Target user exists, caller is ApiAdmin
**Input data**: DELETE /users?email=testuser@test.com
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /users?email=testuser@test.com with ApiAdmin JWT | HTTP 200 |
**Expected outcome**: HTTP 200, user deleted
**Max execution time**: 5s
---
## Negative Scenarios
### FT-N-01: Login with Unknown Email
**Summary**: Login attempt with non-existent email returns appropriate error.
**Traces to**: AC-2
**Category**: Authentication
**Preconditions**:
- Email does not exist in database
**Input data**: `{"email":"nonexistent@test.com","password":"anypass1"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with unknown email | HTTP 409, ExceptionEnum code 10 (NoEmailFound) |
**Expected outcome**: HTTP 409 with error code 10
**Max execution time**: 5s
---
### FT-N-02: Login with Wrong Password
**Summary**: Login attempt with correct email but wrong password returns error.
**Traces to**: AC-3
**Category**: Authentication
**Preconditions**:
- User exists in database
**Input data**: `{"email":"admin@azaion.com","password":"wrongpassword123"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with wrong password | HTTP 409, ExceptionEnum code 30 (WrongPassword) |
**Expected outcome**: HTTP 409 with error code 30
**Max execution time**: 5s
---
### FT-N-03: Register with Short Email
**Summary**: Registration with email shorter than 8 characters is rejected.
**Traces to**: AC-5
**Category**: User Management
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: `{"email":"short","password":"validpwd1","role":"Operator"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users with short email | HTTP 400, validation error |
**Expected outcome**: HTTP 400 with email length validation error
**Max execution time**: 5s
---
### FT-N-04: Register with Invalid Email Format
**Summary**: Registration with invalid email format (>= 8 chars but not email) is rejected.
**Traces to**: AC-6
**Category**: User Management
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: `{"email":"notanemail","password":"validpwd1","role":"Operator"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users with invalid email format | HTTP 400, validation error |
**Expected outcome**: HTTP 400 with email format validation error
**Max execution time**: 5s
---
### FT-N-05: Upload Empty File
**Summary**: Upload request with no file attached returns error.
**Traces to**: AC-16
**Category**: Resource Distribution
**Preconditions**:
- Caller authenticated
**Input data**: POST /resources/testfolder with no file
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /resources/testfolder with empty request | HTTP 409, ExceptionEnum code 70 (NoFileProvided) |
**Expected outcome**: HTTP 409 with error code 70
**Max execution time**: 5s
---
### FT-N-06: Hardware Mismatch
**Summary**: Hardware check with different fingerprint after binding returns error.
**Traces to**: AC-12
**Category**: Hardware Binding
**Preconditions**:
- User has hardware already bound to a different fingerprint
**Input data**: `{"hardware":"different-hardware-xyz"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /resources/check with different hardware | HTTP 409, ExceptionEnum code 40 (HardwareIdMismatch) |
**Expected outcome**: HTTP 409 with error code 40
**Max execution time**: 5s
---
### FT-N-07: Register Duplicate Email
**Summary**: Registration with already-existing email returns error.
**Traces to**: AC-8
**Category**: User Management
**Preconditions**:
- User with target email already exists
**Input data**: `{"email":"admin@azaion.com","password":"validpwd1","role":"Operator"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users with existing email | HTTP 409, ExceptionEnum code 20 (EmailExists) |
**Expected outcome**: HTTP 409 with error code 20
**Max execution time**: 5s
---
### FT-N-08: Register with Short Password
**Summary**: Registration with password shorter than 8 characters is rejected.
**Traces to**: AC-7
**Category**: User Management
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: `{"email":"newuser@test.com","password":"short","role":"Operator"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users with short password | HTTP 400, validation error |
**Expected outcome**: HTTP 400 with password length validation error
**Max execution time**: 5s
---
## Cycle 1 Additions (2026-05-13)
The scenarios below were appended during the existing-code cycle 1 Test-Spec Sync (autodev Step 12) for tasks AZ-513, AZ-196, AZ-183, AZ-197. Numbering continues from the legacy IDs above; existing IDs are preserved.
### Cycle 1 Obsoletion Note
The following legacy entries describe behaviour removed by AZ-197 (admin-side hardware-binding cleanup). Their bodies are intentionally left intact to preserve traceability IDs per the cycle-update rule "preserve existing traceability IDs"; they should be treated as obsolete and superseded by FT-N-15 below:
- FT-P-04 (First Hardware Check Stores Fingerprint) — superseded; the `POST /resources/check` endpoint and the hardware-store side-effect were removed.
- FT-P-05 (Subsequent Hardware Check Matches) — superseded; same endpoint removed.
- FT-N-06 (Hardware Mismatch) — superseded; the `HardwareIdMismatch` / error code 40 path no longer exists in `ExceptionEnum`.
- FT-P-09 / FT-P-10 — fully obsolete after the cycle-2 cleanup; the endpoint, support code, and corresponding e2e tests are gone (see the FT-P-09 / FT-P-10 stubs above and FT-N-16 below).
See `_docs/03_implementation/batch_06_report.md` for the full AZ-197 implementation rationale and the wire-compat policy decision (drop entirely).
---
### Cycle-2 Cleanup (2026-05-14) — Obsolete Resource Endpoints Removed
#### FT-N-16: Removed Resource Endpoints Return 404
**Summary**: After the cycle-2 cleanup, the three obsolete resource endpoints are no longer routed and return 404.
**Traces to**: Cycle-2 AC-1, Cycle-2 AC-2, Cycle-2 AC-3
**Category**: Negative — Removed Endpoints
**Preconditions**:
- Caller authenticated as any user (404 must precede any auth check, since the route is gone)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /resources/get` (with or without body) | HTTP 404 |
| 2 | `POST /resources/get/somefolder` | HTTP 404 |
| 3 | `GET /resources/get-installer` | HTTP 404 |
| 4 | `GET /resources/get-installer/stage` | HTTP 404 |
**Expected outcome**: each request returns HTTP 404 (not 401, not 405); no `Security.GetApiEncryptionKey` / `EncryptTo` invocation observable in logs.
**Notes**: this is a parallel to FT-N-15 (which covers the AZ-197 endpoint removals). Together they enumerate every route that has been retired in cycles 1 and 2.
---
### Detection Classes CRUD (AZ-513)
#### FT-P-14: POST /classes Creates Detection Class
**Summary**: ApiAdmin creates a new detection class and the response includes the assigned id.
**Traces to**: AZ-513 AC-1
**Category**: Detection Classes CRUD
**Preconditions**:
- Caller authenticated as ApiAdmin
- `detection_classes` table exists
**Input data**: `{"name":"Tank","shortName":"T","color":"#FF0000","maxSizeM":5.0}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /classes with valid body and ApiAdmin JWT | HTTP 200/201 with body containing assigned `id` and the submitted fields |
**Expected outcome**: HTTP 200 or 201, response body has integer `id` and matches input fields
**Max execution time**: 5s
---
#### FT-P-15: PATCH /classes/{id} Full Body Update
**Summary**: Updating a detection class with a full body replaces the changed fields.
**Traces to**: AZ-513 AC-3
**Category**: Detection Classes CRUD
**Preconditions**:
- A detection class with id `7` exists with `name: "Tank"`
**Input data**: `{"name":"Heavy Tank","shortName":"T","color":"#FF0000","maxSizeM":5.0}` to PATCH /classes/7
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/7 with full body and ApiAdmin JWT | HTTP 200, response body shows `name: "Heavy Tank"` |
**Expected outcome**: HTTP 200, updated entity reflects the changed field
**Max execution time**: 5s
---
#### FT-P-16: PATCH /classes/{id} Partial Body Update
**Summary**: PATCH with only the changed field updates that field and leaves others intact.
**Traces to**: AZ-513 AC-4
**Category**: Detection Classes CRUD
**Preconditions**:
- A detection class with id `7` exists with `name: "Tank", color: "#FF0000", maxSizeM: 5.0`
**Input data**: `{"color":"#00FF00"}` to PATCH /classes/7
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/7 with partial body and ApiAdmin JWT | HTTP 200, response body shows `color: "#00FF00"`; other fields unchanged |
**Expected outcome**: HTTP 200, partial-merge semantics confirmed
**Max execution time**: 5s
---
#### FT-P-17: DELETE /classes/{id} Removes Class
**Summary**: ApiAdmin deletes a detection class and it disappears from the DB.
**Traces to**: AZ-513 AC-7
**Category**: Detection Classes CRUD
**Preconditions**:
- A detection class with id `7` exists
**Input data**: DELETE /classes/7
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /classes/7 with ApiAdmin JWT | HTTP 200 or 204 |
| 2 | GET the class list (or PATCH the same id) | id 7 no longer present |
**Expected outcome**: HTTP 200/204; class removed from DB
**Max execution time**: 5s
---
#### FT-N-09: POST /classes Without ApiAdmin JWT
**Summary**: POST /classes requires the same `apiAdminPolicy` as `/users`; non-admin / unauthenticated calls are rejected.
**Traces to**: AZ-513 AC-2
**Category**: Detection Classes CRUD
**Preconditions**: None (negative path)
**Input data**: Valid body, but caller has no JWT or a non-ApiAdmin JWT
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /classes without JWT | HTTP 401 |
| 2 | POST /classes with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 (no JWT) or 403 (non-admin)
**Max execution time**: 5s
---
#### FT-N-10: PATCH /classes/{id} Unknown id Returns 404
**Summary**: PATCH against a non-existent id returns 404.
**Traces to**: AZ-513 AC-5
**Category**: Detection Classes CRUD
**Preconditions**: No detection class with id `9999`
**Input data**: PATCH /classes/9999 with any valid body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/9999 with ApiAdmin JWT | HTTP 404 |
**Expected outcome**: HTTP 404
**Max execution time**: 5s
---
#### FT-N-11: PATCH /classes/{id} Without ApiAdmin JWT
**Summary**: PATCH /classes/{id} requires `apiAdminPolicy`.
**Traces to**: AZ-513 AC-6
**Category**: Detection Classes CRUD
**Input data**: Any valid body to PATCH /classes/{id}
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PATCH /classes/{id} without JWT | HTTP 401 |
| 2 | PATCH /classes/{id} with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 or 403
**Max execution time**: 5s
---
#### FT-N-12: DELETE /classes/{id} Unknown id Returns 404
**Summary**: DELETE against a non-existent id returns 404 (matching `/users` semantics — non-idempotent).
**Traces to**: AZ-513 AC-8
**Category**: Detection Classes CRUD
**Preconditions**: No detection class with id `9999`
**Input data**: DELETE /classes/9999
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /classes/9999 with ApiAdmin JWT | HTTP 404 |
**Expected outcome**: HTTP 404
**Max execution time**: 5s
---
#### FT-N-13: DELETE /classes/{id} Without ApiAdmin JWT
**Summary**: DELETE /classes/{id} requires `apiAdminPolicy`.
**Traces to**: AZ-513 AC-9
**Category**: Detection Classes CRUD
**Input data**: DELETE /classes/{id}
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | DELETE /classes/{id} without JWT | HTTP 401 |
| 2 | DELETE /classes/{id} with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 or 403
**Max execution time**: 5s
---
### Device Auto-Registration (AZ-196)
#### FT-P-18: POST /devices Returns Serial / Email / Password
**Summary**: First call to POST /devices returns the next serial in the `azj-NNNN` sequence with a generated email and 32-char hex password.
**Traces to**: AZ-196 AC-1
**Category**: Device Provisioning
**Preconditions**:
- Caller authenticated as ApiAdmin
- No (or known-prior) CompanionPC users in DB
**Input data**: POST /devices with no body, ApiAdmin JWT
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices with ApiAdmin JWT | HTTP 200 with `serial` matching `^azj-\d{4}$`, `email` = `{serial}@azaion.com`, `password` = 32 lowercase hex chars |
**Expected outcome**: HTTP 200, all three fields shaped per spec
**Max execution time**: 5s
---
#### FT-P-19: Sequential Device Serials
**Summary**: Repeated calls to POST /devices yield strictly increasing serial numbers.
**Traces to**: AZ-196 AC-2
**Category**: Device Provisioning
**Preconditions**:
- Most recent CompanionPC user has a known serial `azj-NNNN`
**Input data**: POST /devices twice in succession
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices → record serial `S1` | HTTP 200 |
| 2 | POST /devices → record serial `S2` | HTTP 200 |
| 3 | Parse the numeric suffix of both | numeric(S2) == numeric(S1) + 1 |
**Expected outcome**: HTTP 200, suffix increments by exactly 1
**Max execution time**: 5s
---
#### FT-P-20: Returned Device Credentials Can Login
**Summary**: The plaintext password returned by POST /devices succeeds against POST /login (and the persisted hash is therefore correct).
**Traces to**: AZ-196 AC-3, AZ-196 AC-4
**Category**: Device Provisioning
**Preconditions**:
- Caller authenticated as ApiAdmin
**Input data**: Use the response from POST /devices as `{Email, Password}` to POST /login
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices with ApiAdmin JWT | HTTP 200, `{Serial, Email, Password}` returned |
| 2 | POST /login with the returned `Email` and `Password` | HTTP 200 with non-empty JWT |
**Expected outcome**: HTTP 200 on login; persisted user has Role=CompanionPC, IsEnabled=true (verified by AdminApi behaviour rather than direct DB inspection)
**Max execution time**: 5s
---
#### FT-N-14: POST /devices Without ApiAdmin JWT
**Summary**: POST /devices requires `apiAdminPolicy`.
**Traces to**: AZ-196 AC-5
**Category**: Device Provisioning
**Input data**: POST /devices
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /devices without JWT | HTTP 401 |
| 2 | POST /devices with non-ApiAdmin JWT | HTTP 403 |
**Expected outcome**: HTTP 401 or 403
**Max execution time**: 5s
---
### Resources OTA Update Check (AZ-183) — REVERTED post-cycle-1
The OTA update check & publish feature shipped in cycle 1 was reverted later the same day after the security audit (finding F-1: `/get-update` disclosed plaintext per-resource encryption keys to any authenticated caller). The OTA delivery model itself was deemed obsolete in the target architecture.
The scenarios `FT-P-21`, `FT-P-22`, `FT-P-23` are retained here as ID placeholders so previously-cited references resolve. Their bodies are intentionally collapsed because the underlying endpoints, service, entity, table, and the e2e test class `ResourceUpdateTests.cs` were all removed. See `_docs/02_document/system-flows.md` (Flow F10) and `_docs/05_security/security_report.md` (finding F-1) for context.
| Removed Test ID | Was tracing | Disposition |
|-----------------|-------------|-------------|
| FT-P-21 | AZ-183 AC-2 | Removed — endpoint and test deleted |
| FT-P-22 | AZ-183 AC-3 | Removed — endpoint and test deleted |
| FT-P-23 | AZ-183 AC-5 | Removed — endpoint and test deleted |
---
### Hardware-Binding Removal (AZ-197)
#### FT-N-15: Hardware Endpoints Removed
**Summary**: The legacy `PUT /users/hardware/set` endpoint and the `POST /resources/check` endpoint have been removed and now return 404.
**Traces to**: AZ-197 AC-2
**Category**: Authorization & Routing
**Preconditions**:
- Updated admin API build (post-AZ-197)
**Input data**: PUT /users/hardware/set and POST /resources/check
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | PUT /users/hardware/set with ApiAdmin JWT | HTTP 404 |
| 2 | POST /resources/check with ApiAdmin JWT | HTTP 404 |
**Expected outcome**: HTTP 404 on both routes
**Max execution time**: 5s
Note: AZ-197 AC-1 (resource download works without `Hardware`) is implicitly covered by the existing FT-P-09 / FT-P-10 scenarios once their request bodies are aligned with the new wire shape. AZ-197 AC-3..AC-8 are internal-signature / build-system invariants and are verified at build/CI time, not via a blackbox HTTP scenario.
---
## Cycle 2 Additions (2026-05-14) — Auth Modernization (AZ-529 + AZ-530)
The scenarios below were appended during the existing-code cycle 2 Test-Spec Sync (autodev Step 12) for the eight tasks under AZ-529 (Auth Mechanism Modernization) and AZ-530 (CMMC Compliance Hardening): AZ-531 (refresh-token flow), AZ-532 (asymmetric signing + JWKS), AZ-533 (mission-token UAV), AZ-534 (TOTP 2FA), AZ-535 (logout + revocation), AZ-536 (Argon2id), AZ-537 (rate-limit + lockout), AZ-538 (CORS HTTPS-only + HSTS). Numbering continues from FT-P-23 / FT-N-16. Security-only ACs live in `security-tests.md`.
### Argon2id Password Hashing (AZ-536)
#### FT-P-24: Legacy SHA-384 Password Still Validates
**Summary**: A user whose `password_hash` is in the pre-AZ-536 unsalted SHA-384 format can still log in with the correct password.
**Traces to**: AZ-536 AC-2
**Category**: Authentication
**Preconditions**:
- Seed user `legacy@azaion.com` with `password_hash` set to `Convert.ToBase64String(SHA384.HashData("LegacyPwd1!"))` (the historical format)
**Input data**: `{"email":"legacy@azaion.com","password":"LegacyPwd1!"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with the legacy user's credentials | HTTP 200, dual-token body (per AZ-531) |
**Expected outcome**: HTTP 200, login succeeds against legacy hash format
**Max execution time**: 5s (note: Argon2id verify cost is incurred only on the post-login re-hash)
---
#### FT-P-25: Successful Legacy Login Re-Hashes to Argon2id
**Summary**: After FT-P-24 succeeds, the user's `password_hash` is silently upgraded to Argon2id PHC format and the same plaintext continues to validate.
**Traces to**: AZ-536 AC-3
**Category**: Authentication
**Preconditions**:
- FT-P-24 has just executed successfully for `legacy@azaion.com`
**Input data**: `{"email":"legacy@azaion.com","password":"LegacyPwd1!"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Read `users.password_hash` for `legacy@azaion.com` directly from DB | Value starts with `$argon2id$v=19$m=` and parses to m ≥ 65536, t ≥ 3, p ≥ 1 |
| 2 | POST /login with the same plaintext password again | HTTP 200, dual-token body |
**Expected outcome**: Hash format upgraded to Argon2id PHC; subsequent login still works
**Max execution time**: 5s
---
#### FT-N-17: Wrong Password Fails for Both Hash Formats
**Summary**: Wrong password is rejected with the same error (`WrongPassword`) regardless of whether the stored hash is legacy SHA-384 or Argon2id.
**Traces to**: AZ-536 AC-4
**Category**: Authentication
**Preconditions**:
- One user with legacy SHA-384 hash, one user with Argon2id hash already in DB
**Input data**: Wrong password against each user
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login (legacy user, wrong pwd) | HTTP 409, ExceptionEnum=WrongPassword (code 30) |
| 2 | POST /login (Argon2id user, wrong pwd) | HTTP 409, ExceptionEnum=WrongPassword (code 30) |
**Expected outcome**: Same error code on both code paths; no information leak about hash format
**Max execution time**: 5s per attempt (Argon2id cost incurred regardless of success/failure)
---
### /login Rate Limit + Account Lockout (AZ-537)
#### FT-P-26: Successful Login Resets the Failed-Attempt Counter
**Summary**: After some wrong-password attempts (within budget), a successful login zeros `failed_login_count` and clears `lockout_until`.
**Traces to**: AZ-537 AC-4
**Category**: Authentication
**Preconditions**:
- User `alice@azaion.com` exists with Argon2id-hashed password
**Input data**: 5 wrong-password attempts followed by 1 correct attempt
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with wrong pwd × 5 (within rate-limit budget) | HTTP 409 each (WrongPassword) |
| 2 | Read `users.failed_login_count` for alice | Value = 5 |
| 3 | POST /login with correct pwd | HTTP 200, dual-token body |
| 4 | Read `users.failed_login_count` and `lockout_until` for alice | `failed_login_count = 0`, `lockout_until IS NULL` |
**Expected outcome**: Counter reset on success
**Max execution time**: 30s (5× Argon2id verifies)
---
#### FT-P-27: Lockout Auto-Expires After Configured Duration
**Summary**: A locked account becomes loginable again automatically once `lockout_until < now()`.
**Traces to**: AZ-537 AC-5
**Category**: Authentication
**Preconditions**:
- `Auth:Lockout:DurationMinutes` set to a small value (e.g. 1 minute) in the test env so the test does not have to wait 15 min
- User `bob@azaion.com` exists with Argon2id hash
**Input data**: 10 wrong attempts to trigger lockout, then a correct attempt after the duration window
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with wrong pwd × 10 | first 9 → 409 WrongPassword; the 10th → 423 Locked OR 409 followed by lockout flag |
| 2 | POST /login with correct pwd immediately | HTTP 423 Locked (account is locked) |
| 3 | Wait `Auth:Lockout:DurationMinutes + 1s` | — |
| 4 | POST /login with correct pwd | HTTP 200, dual-token body |
**Expected outcome**: 423 → 200 transition once the lockout window expires
**Max execution time**: 90s (depends on configured lockout duration in test env)
---
### CORS HTTPS-Only + HSTS (AZ-538)
#### FT-P-28: HTTPS Origin Preflight Succeeds
**Summary**: The CORS allow-list still admits the canonical `https://admin.azaion.com` origin and echoes the credentials flag.
**Traces to**: AZ-538 AC-2
**Category**: Cross-Origin
**Preconditions**:
- Admin API running with `AdminCorsPolicy` configured (post-AZ-538)
**Input data**:
- Method: OPTIONS
- Path: /login
- Header: `Origin: https://admin.azaion.com`
- Header: `Access-Control-Request-Method: POST`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | OPTIONS /login with the headers above | HTTP 204; `Access-Control-Allow-Origin: https://admin.azaion.com`; `Access-Control-Allow-Credentials: true` |
**Expected outcome**: HTTPS origin preflight succeeds with credentials flag
**Max execution time**: 5s
---
#### FT-P-29: Development Env — No HTTPS Redirect, No HSTS
**Summary**: When `ASPNETCORE_ENVIRONMENT=Development`, plain HTTP requests to localhost still serve 200 responses with no `Strict-Transport-Security` header.
**Traces to**: AZ-538 AC-5
**Category**: Cross-Origin
**Preconditions**:
- Admin API running with `ASPNETCORE_ENVIRONMENT=Development` (the default test container env)
**Input data**: GET http://localhost:8080/health/live
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET http://localhost:8080/health/live | HTTP 200; no `Strict-Transport-Security` header; no 307 redirect |
**Expected outcome**: Dev workflow preserved — no redirect, no HSTS
**Max execution time**: 5s
---
### Refresh-Token Flow (AZ-531)
#### FT-P-30: /login Returns Dual Tokens
**Summary**: Successful login returns both a short-lived access token (≈15 min) and an opaque refresh token; a `sessions` row is created.
**Traces to**: AZ-531 AC-1
**Category**: Authentication
**Preconditions**:
- Seed user without MFA enabled
**Input data**: Valid email + password
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login | HTTP 200; body has `access_token` (JWT), `access_exp` ≈ now+15m ±60s, `refresh_token` (opaque ≥43 chars), `refresh_exp` |
| 2 | Decode `access_token` payload | Contains `sub`, `iss`, `aud`, `exp`, `jti`, `sid` claims |
| 3 | Query `sessions` table by `user_id` | Exactly one row with non-null `refresh_hash`, non-null `family_id`, `revoked_at IS NULL` |
**Expected outcome**: Dual tokens issued, session row persisted, access token has short TTL
**Max execution time**: 5s
---
#### FT-P-31: /token/refresh Rotates the Refresh Token
**Summary**: A valid refresh token is exchanged for a new access + new refresh; the previous refresh is invalidated; the session chain extends via `parent_session_id`.
**Traces to**: AZ-531 AC-2
**Category**: Authentication
**Preconditions**:
- FT-P-30 just produced refresh token R1
**Input data**: `{"refresh_token":"<R1>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /token/refresh with R1 | HTTP 200; body has new `access_token`, new `refresh_token` (R2 ≠ R1), new `access_exp`, new `refresh_exp` |
| 2 | POST /token/refresh with R1 again (same call) | HTTP 401 (R1 has been rotated; see AC-3 reuse-detection in NFT-SEC-08) |
| 3 | Inspect `sessions` table | Original row's `refresh_hash` rotated; new row has `parent_session_id` chained to the previous row |
**Expected outcome**: Rotation succeeds; old refresh dies; chain is preserved
**Max execution time**: 5s
---
#### FT-P-32: Refresh Sliding + Absolute Expiry
**Summary**: Refresh tokens slide on use up to the per-family absolute cap (12 h since the family's first issue); after the absolute cap, refresh fails.
**Traces to**: AZ-531 AC-4
**Category**: Authentication
**Preconditions**:
- A `sessions` family with `family_first_issued_at` set to `now() - 11h59m` (verified via DB seed) and a current valid refresh token R-current
**Input data**: `{"refresh_token":"<R-current>"}`, called near and past the absolute cap
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /token/refresh at family-age 11h59m | HTTP 200, rotation succeeds; sliding window extended |
| 2 | Seed another family with `family_first_issued_at = now() - 12h01s` | — |
| 3 | POST /token/refresh on that family | HTTP 401, body indicates absolute-expiry violation |
**Expected outcome**: Sliding works inside 12 h; absolute cap rejects beyond
**Max execution time**: 5s
---
### Asymmetric Signing + JWKS (AZ-532)
#### FT-P-33: GET /.well-known/jwks.json Serves the Active Public Key
**Summary**: The JWKS endpoint is anonymous, cacheable, and returns a well-formed JWKS containing the active EC P-256 public key with `kid`.
**Traces to**: AZ-532 AC-2
**Category**: Cryptography / Discovery
**Preconditions**:
- Admin running with an ES256 keypair loaded from `secrets/jwt_signing_key.pem`
**Input data**: None (anonymous GET)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /.well-known/jwks.json (no JWT) | HTTP 200; `Content-Type: application/json`; `Cache-Control: public, max-age=3600` |
| 2 | Parse body | `{"keys":[{"kty":"EC","crv":"P-256","kid":<non-empty>,"x":<base64url>,"y":<base64url>,"alg":"ES256","use":"sig"}, …]}` |
**Expected outcome**: JWKS shape matches RFC 7517; cache headers present
**Max execution time**: 5s
---
#### FT-P-34: Two-Key Overlap During Rotation
**Summary**: When two signing keys are configured (`kid-A` active + `kid-B` standby), JWKS exposes both; tokens signed with the active key continue to verify; switching the active flag to `kid-B` produces `kid-B`-stamped tokens that also verify.
**Traces to**: AZ-532 AC-3
**Category**: Cryptography / Rotation
**Preconditions**:
- Two keys configured in `secrets/`: `jwt_signing_key_a.pem` (active), `jwt_signing_key_b.pem` (standby)
**Input data**: Sequenced login + rotation toggle
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /.well-known/jwks.json | Both `kid-A` and `kid-B` appear in `keys` array |
| 2 | POST /login | Returned access token has `kid: kid-A` in header |
| 3 | Toggle active key → `kid-B` (test-only admin endpoint or env reload) | — |
| 4 | POST /login again | Returned access token has `kid: kid-B` in header |
| 5 | Use either token against any protected endpoint | HTTP 200 (both verify against their respective public keys in JWKS) |
**Expected outcome**: Overlap window allows both keys; verifiers can keep working through rotation
**Max execution time**: 10s
---
### Mission-Token Issuance for UAV (AZ-533)
#### FT-P-35: POST /sessions/mission Issues a Long-Lived Mission Token
**Summary**: An authenticated pilot session can mint a mission-class access token with a duration ≈ `planned_duration_h + 1h` and no refresh token.
**Traces to**: AZ-533 AC-1
**Category**: Mission Sessions
**Preconditions**:
- Pilot user with valid (post-AZ-531) access token; MFA already proven within the session (post-AZ-534)
- Aircraft user `UAV-117` with `Role=CompanionPC` exists
**Input data**: `{"mission_id":"M-2026-05-14-042","aircraft_id":"UAV-117","planned_duration_h":9,"requested_scope":["GPS"]}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/mission with the body above + pilot access token | HTTP 200; body has `access_token`, no `refresh_token`, `exp` ≈ now + 10h ±60s |
| 2 | Decode token payload | `token_class = "mission"` |
| 3 | Query `sessions` table | Row with `class='mission'`, `aircraft_id='UAV-117'`, `revoked_at IS NULL` |
**Expected outcome**: Long-lived mission token issued; session persisted with class marker
**Max execution time**: 5s
---
#### FT-P-36: Mission Token Carries Scope Claims
**Summary**: The mission token's payload exposes `mission_id`, `aircraft_id`, `aud`, `permissions`, `sid`, `jti`.
**Traces to**: AZ-533 AC-3
**Category**: Mission Sessions
**Preconditions**:
- FT-P-35 just produced a mission token
**Input data**: The mission token from FT-P-35
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Decode mission token payload | `mission_id == "M-2026-05-14-042"`, `aircraft_id == "UAV-117"`, `aud == "satellite-provider"`, `permissions` contains `"GPS"`, `sid` non-empty, `jti` non-empty |
**Expected outcome**: All scope claims present and correctly populated
**Max execution time**: 5s
---
#### FT-P-37: Mission Token Auto-Revoked on Aircraft Reconnect
**Summary**: When the aircraft user behind a mission session calls `/login` or `/token/refresh` again, every open mission session for that aircraft is marked `revoked_reason='post_flight_reconnect'` and the mission token stops working.
**Traces to**: AZ-533 AC-4
**Category**: Mission Sessions
**Preconditions**:
- Open mission session for `UAV-117` from FT-P-35 (token MT)
**Input data**: A `/login` from the `UAV-117` companion PC user
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login as `UAV-117` (CompanionPC creds) | HTTP 200, dual tokens (per AZ-531) |
| 2 | Query `sessions` row for the original mission MT | `revoked_at` set; `revoked_reason = 'post_flight_reconnect'` |
| 3 | Use MT against any protected endpoint | HTTP 401 |
**Expected outcome**: Reconnect implicitly revokes outstanding mission sessions for the same aircraft
**Max execution time**: 10s
---
#### FT-N-18: POST /sessions/mission Requires Authentication
**Summary**: Without an Authorization header, mission-token issuance is rejected at the gateway.
**Traces to**: AZ-533 AC-5
**Category**: Mission Sessions
**Preconditions**: None
**Input data**: Same body as FT-P-35, no Authorization header
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/mission with no JWT | HTTP 401 |
**Expected outcome**: Unauthenticated mission requests are rejected
**Max execution time**: 5s
---
#### FT-N-19: POST /sessions/mission Rejects Over-Cap Duration
**Summary**: A request for `planned_duration_h > 12` is rejected with HTTP 400 and a descriptive error message.
**Traces to**: AZ-533 AC-2
**Category**: Mission Sessions
**Preconditions**:
- Authenticated pilot session (with MFA `amr=mfa`)
**Input data**: `{"mission_id":"M-2026-05-14-099","aircraft_id":"UAV-117","planned_duration_h":15,"requested_scope":["GPS"]}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/mission with the over-cap body | HTTP 400; response body contains `"planned_duration_h must be ≤ 12"` |
**Expected outcome**: 400 with cap-violation message; no session row created
**Max execution time**: 5s
---
### TOTP-Based 2FA at Login (AZ-534)
#### FT-P-38: POST /users/me/mfa/enroll Returns Usable Secret + Recovery Codes
**Summary**: A user without MFA can begin enrollment and receives a 32-char base32 TOTP secret, an `otpauth://` URL, a base64 PNG QR, and 10 recovery codes (≥12 chars each).
**Traces to**: AZ-534 AC-1
**Category**: MFA Enrollment
**Preconditions**:
- Authenticated user `mfauser@azaion.com`, `mfa_enabled = false`
**Input data**: `{"password":"<plaintext>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users/me/mfa/enroll with the body above | HTTP 200; body has `secret` (32-char base32), `otpauth_url` (matches `^otpauth://totp/`), `qr_png_base64` (non-empty), `recovery_codes` (length = 10, each ≥ 12 chars, base32) |
| 2 | Read `users.mfa_enabled` for the user | Value still `false` (only flips after `confirm`) |
**Expected outcome**: Enrollment package returned; `mfa_enabled` not yet flipped
**Max execution time**: 5s
---
#### FT-P-39: POST /users/me/mfa/confirm Activates MFA
**Summary**: Submitting a valid TOTP code from the just-issued secret completes enrollment and flips `mfa_enabled = true`.
**Traces to**: AZ-534 AC-2
**Category**: MFA Enrollment
**Preconditions**:
- FT-P-38 just executed for the same user; the test holds the returned `secret`
**Input data**: `{"code":"<TOTP code computed from secret at current time>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Compute current 6-digit TOTP from `secret` (RFC 6238, 30 s window) | 6 digits |
| 2 | POST /users/me/mfa/confirm with the code | HTTP 200 |
| 3 | Read `users.mfa_enabled` and `users.mfa_enrolled_at` | `mfa_enabled = true`, `mfa_enrolled_at` non-null |
**Expected outcome**: MFA activated; subsequent /login goes through the two-step flow
**Max execution time**: 5s
---
#### FT-P-40: Two-Step Login With TOTP
**Summary**: When a user has MFA enabled, `/login` returns an MFA-required envelope with a short-lived `mfa_token`; calling `/login/mfa` with the `mfa_token` + a valid TOTP code yields the real access + refresh; the access token's `amr` claim contains both `pwd` and `mfa`.
**Traces to**: AZ-534 AC-3
**Category**: Authentication / MFA
**Preconditions**:
- User from FT-P-39 (MFA enabled)
**Input data**: Valid email + password, then `mfa_token` + TOTP code
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login with email + password | HTTP 200; body = `{ "mfa_required": true, "mfa_token": "<short-lived JWT>", "expires_in": 300 }`; no access/refresh present |
| 2 | POST /login/mfa with `{ "mfa_token": "<from step 1>", "code": "<TOTP>" }` | HTTP 200; body has access + refresh tokens |
| 3 | Decode access token | `amr` claim = `["pwd","mfa"]` |
**Expected outcome**: Two-step flow completes; access token's `amr` reflects both factors
**Max execution time**: 10s
---
#### FT-P-41: Recovery Code Substitutes for TOTP and Burns On Use
**Summary**: A recovery code may be used in place of a TOTP code at `/login/mfa`. The same code on a subsequent attempt fails (single-use). The successful access token's `amr` claim records `recovery`.
**Traces to**: AZ-534 AC-4
**Category**: Authentication / MFA
**Preconditions**:
- User from FT-P-39; the test holds the `recovery_codes` array from FT-P-38
**Input data**: First recovery code, then re-use of the same code
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /login → get `mfa_token` | HTTP 200, MFA-required envelope |
| 2 | POST /login/mfa with `{ "mfa_token", "code": "<recovery_codes[0]>" }` | HTTP 200, access + refresh issued; `amr` = `["pwd","mfa","recovery"]` |
| 3 | POST /login → get a new `mfa_token` | HTTP 200, MFA-required envelope |
| 4 | POST /login/mfa with the SAME recovery code | HTTP 401 (recovery code burned) |
**Expected outcome**: Recovery code works once, then is rejected
**Max execution time**: 10s
---
#### FT-P-42: POST /users/me/mfa/disable Removes MFA
**Summary**: Submitting password + a valid TOTP code disables MFA; subsequent `/login` returns access + refresh directly without the two-step flow.
**Traces to**: AZ-534 AC-5
**Category**: MFA Enrollment
**Preconditions**:
- User from FT-P-39
**Input data**: `{"password":"<plaintext>","code":"<TOTP>"}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /users/me/mfa/disable | HTTP 200 |
| 2 | Read `users.mfa_enabled` | `false` |
| 3 | POST /login with email + password | HTTP 200; body has access + refresh directly (no `mfa_required`) |
**Expected outcome**: MFA disabled, single-step login restored
**Max execution time**: 5s
---
### Logout + Revocation Surface (AZ-535)
#### FT-P-43: POST /logout Revokes the Current Session
**Summary**: A POST /logout with a valid access token marks the session row revoked and disables the paired refresh token.
**Traces to**: AZ-535 AC-1
**Category**: Session Lifecycle
**Preconditions**:
- Active session from a prior /login (access token A, refresh token R)
**Input data**: Authorization header `Bearer <A>`, empty body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /logout with bearer A | HTTP 200 |
| 2 | Query the session row | `revoked_at` set; `revoked_reason = 'user_logout'` |
| 3 | POST /token/refresh with R | HTTP 401 |
**Expected outcome**: Session revoked, refresh dies immediately
**Max execution time**: 5s
---
#### FT-P-44: POST /logout/all Revokes Every Session for the User
**Summary**: A user with multiple active sessions can sign out of all of them in one call.
**Traces to**: AZ-535 AC-2
**Category**: Session Lifecycle
**Preconditions**:
- User with three active sessions S1/S2/S3 (each from a separate /login)
**Input data**: Authorization header `Bearer <A from S1>`, empty body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /logout/all from S1 | HTTP 200 |
| 2 | Query `sessions` for the user | All three rows have `revoked_at` set |
| 3 | POST /token/refresh with the refresh tokens of S1/S2/S3 | All three return HTTP 401 |
**Expected outcome**: Every session for the user is revoked
**Max execution time**: 10s
---
#### FT-P-45: POST /sessions/{sid}/revoke Lets Admin Kill Any Session
**Summary**: An Admin-role JWT can revoke any other user's session by id; the revoked row records the admin's user id.
**Traces to**: AZ-535 AC-3
**Category**: Admin Session Management
**Preconditions**:
- Admin user with valid (post-AZ-531) access token
- Target user with active session SID-X
**Input data**: Authorization header `Bearer <admin access>`, path `/sessions/<SID-X>/revoke`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /sessions/SID-X/revoke as admin | HTTP 200 |
| 2 | Query the SID-X row | `revoked_at` set; `revoked_by_user_id` = admin's user id |
| 3 | POST /token/refresh with SID-X's refresh | HTTP 401 |
**Expected outcome**: Admin-driven revocation works and records actor
**Max execution time**: 5s
---
#### FT-P-46: GET /sessions/revoked?since=… Returns Recent, Non-Expired Revocations
**Summary**: A verifier identity (`Role=Service`) polls the snapshot endpoint and gets the recently-revoked, still-valid sessions; expired entries are auto-pruned.
**Traces to**: AZ-535 AC-4
**Category**: Verifier Snapshot
**Preconditions**:
- 5 sessions revoked in the last hour, 2 of which already have `exp < now()`
- Verifier identity (Service role) with valid bearer
**Input data**: Authorization header `Bearer <verifier access>`, query `?since=<unix-ts 1h ago>`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | GET /sessions/revoked?since=<ts> with verifier bearer | HTTP 200; `Cache-Control: no-cache`; body is JSON array of length 3 |
| 2 | Inspect each entry | `{ jti, sid, exp }` shape; no expired entries present |
**Expected outcome**: 3 non-expired revocations returned; expired ones pruned
**Max execution time**: 5s
---
#### FT-P-47: POST /logout Is Idempotent
**Summary**: Logging out a session that is already revoked returns 200 with `already_revoked: true` and does not write to the DB.
**Traces to**: AZ-535 AC-5
**Category**: Session Lifecycle
**Preconditions**:
- Already-revoked session from FT-P-43
**Input data**: Authorization header `Bearer <still-valid-but-stale access>`, empty body
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | POST /logout again | HTTP 200; body `{ "already_revoked": true }` |
| 2 | Query the session row's `updated_at` (or equivalent audit column) | Unchanged from before step 1 |
**Expected outcome**: Idempotent — no second DB mutation
**Max execution time**: 5s