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>
50 KiB
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.comexists 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/checkendpoint 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 inExceptionEnum. - 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_classestable 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
7exists withname: "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
7exists withname: "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
7exists
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.comwithpassword_hashset toConvert.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.comexists 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:DurationMinutesset 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.comexists 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
AdminCorsPolicyconfigured (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
sessionsfamily withfamily_first_issued_atset tonow() - 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-117withRole=CompanionPCexists
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-117from 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_codesarray 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= 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