mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 21:31:09 +00:00
a77b3f8a59
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>
1476 lines
50 KiB
Markdown
1476 lines
50 KiB
Markdown
# 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
|