[AZ-581] [AZ-582] [AZ-583] [AZ-584] Sec+Res NFT tests

Batch 3 of test implementation cycle 1 (existing-code Step 6).

- AZ-581 AuthClaimsTests: NFT-SEC-01..06+04b (foreign-keypair, byte-flip,
  30s skew, iss/aud/perms, multi-value permissions array).
- AZ-582 CrossCutting/ErrorRedaction/JwksRotation/StartupConfig/CorsConfig:
  NFT-SEC-07..13 (alg pin, kid rotation grace window, env fail-fast, CORS
  Production gate).
- AZ-583 CascadeF3/CascadeF4/MigratorRestart: NFT-RES-01..04. CascadeF4
  pins current walk-order divergence with carry_forward AC-4.6.
- AZ-584 ConfigDbStartup/JwksRotationNoRestart/DefaultVehicleRace:
  NFT-RES-05..08. NFT-RES-08 pins current behaviour (unique-index closes
  the race) with carry_forward AC-1.4.

Mock contract: SignBody accepts permissions OR permissions_array (mutually
exclusive). TokenSigner validates kid_override against published keys so
NFT-SEC-11 can assert "mock refuses old kid post-grace".

Helpers added: ForeignKeypair (test-only ECDSA P-256),
MissionsContainerHelper (docker-run wrapper for startup-time scenarios),
DockerLogs.

7 of 22 new tests are Skippable, gated on COMPOSE_RESTART_ENABLED + docker
CLI in the e2e-consumer image (explicit skip reason; no silent pass).

Build green: test csproj + jwks-mock csproj.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 08:58:59 +03:00
parent 6b2c2d998e
commit 24c4561bef
24 changed files with 2240 additions and 3 deletions
@@ -0,0 +1,125 @@
# Security Tests — Auth & Claims
**Task**: AZ-581_test_security_auth_claims
**Name**: Security tests — auth & claims (NFT-SEC-01..06 + 04b)
**Description**: Implement xUnit blackbox tests for the 7 JWT authn/authz scenarios — missing/invalid header, invalid signature (single-byte flip + foreign-keypair), expired-outside-skew vs inside-30s-skew, wrong `iss`, wrong `aud`, missing `permissions`, wrong/multi-value `permissions` claim (contains-match accepts `["FL","ADMIN"]`).
**Complexity**: 5 points
**Dependencies**: AZ-576_test_infrastructure
**Component**: Blackbox Tests
**Tracker**: AZ-581
**Epic**: AZ-575
## Problem
JWT validation is the only thing standing between the open `e2e-net` and the protected `/vehicles` + `/missions` + `/missions/{id}/waypoints` surface. Six failure modes (no header / bad signature / expired / wrong iss / wrong aud / wrong perm) MUST all produce `401` or `403` deterministically — any drift means an attacker who learns the JWKS public bytes could shape a token that bypasses one rule and rides through. The drift re-verification of 2026-05-14 split AC-5.3 into two checks (`iss` AND `aud`) and tightened the clock skew from .NET's 5-min default to 30s; this task pins both. NFT-SEC-06 specifically asserts the `RequireClaim("permissions","FL")` is contains-match — a multi-permission token `["FL","ADMIN"]` must be accepted, while `"fl"` / `"FLight"` / `"ADMIN"` alone must be rejected.
## Outcome
- All seven NFT-SEC-01..06 + 04b scenarios run and pass against the dockerised `missions` service.
- Each test produces a CSV row with `Category=Sec`, `Traces=AC-5.x` or `AC-9.x`, `Result=pass`.
- NFT-SEC-02 covers BOTH the single-byte-flip case AND the foreign-keypair case (token signed by a separate ECDSA keypair never published in the JWKS).
- NFT-SEC-03 verifies the 30s skew BOTH ways — `exp_offset_seconds=-60` rejected, `exp_offset_seconds=-15` accepted.
- NFT-SEC-06 verifies multi-permission token acceptance — `permissions: ["FL","ADMIN"]``200`.
- NFT-SEC-01 asserts no DB side-effect on the `POST /vehicles` 401 path (side-channel count unchanged).
## Scope
### Included
- NFT-SEC-01 Missing `Authorization` header on `/vehicles` GET/POST, `/missions` GET, `/missions/{any}/waypoints` GET — all `401`, no DB row written on the POST.
- NFT-SEC-02 Invalid signature — single-byte-flipped signature segment AND foreign-keypair tokens.
- NFT-SEC-03 Expired token — `exp_offset_seconds=-60``401`; `exp_offset_seconds=-15``200` (inside 30s skew).
- NFT-SEC-04 Wrong `iss``POST /sign { "iss": "https://attacker.example.com" }``401`; default `iss``200`.
- NFT-SEC-04b Wrong `aud``POST /sign { "aud": "wrong-audience" }``401`.
- NFT-SEC-05 Missing `permissions` claim — `403`.
- NFT-SEC-06 Wrong `permissions` value AND multi-permission acceptance — `"fl"`, `"FLight"`, `"ADMIN"``403`; `["FL","ADMIN"]``200`.
### Excluded
- NFT-SEC-07 health-exempt-from-auth lives in Task 15.
- NFT-SEC-08 stacktrace-not-leaked overlaps with FT-N-08 in Task 13 (and lives in Task 15 for the security-shaped variant).
- NFT-SEC-09 SQL injection guard lives in Task 15.
- NFT-SEC-10 alg-pin lives in Task 15.
- NFT-SEC-11 unknown-kid rotation lag lives in Task 15.
- NFT-SEC-12 missing-env startup throw lives in Task 15.
- NFT-SEC-13 CORS Production-gate lives in Task 15.
## Acceptance Criteria
**AC-1: NFT-SEC-01 missing header rejects every protected endpoint with 401, no side-effect**
Given the running test stack
When the consumer issues `GET /vehicles`, `GET /missions`, `GET /missions/{any}/waypoints`, and `POST /vehicles` with a valid body — all without an `Authorization` header
Then each response is `401`, AND side-channel `SELECT COUNT(*) FROM vehicles` before and after the `POST` are equal
**AC-2: NFT-SEC-02 invalid signature rejects two attack shapes**
Given a valid signed token `T_good` from `jwks-mock POST /sign`
When the consumer flips a single byte in `T_good`'s signature segment producing `T_bad`, and separately mints `T_foreign` signed by an ECDSA keypair never published in the JWKS
Then `GET /vehicles` with `T_bad` returns `401` AND `GET /vehicles` with `T_foreign` returns `401`
**AC-3: NFT-SEC-03 30s clock skew is enforced on both sides**
Given the mock with default issuer/audience
When the consumer mints two tokens via `POST /sign { exp_offset_seconds: -60 }` and `POST /sign { exp_offset_seconds: -15 }`
Then `GET /vehicles` with the 60s token returns `401` AND `GET /vehicles` with the 15s token returns `200`
**AC-4: NFT-SEC-04 wrong `iss` rejected, matching `iss` accepted**
When the consumer mints a token via `POST /sign { iss: "https://attacker.example.com" }` and another via `POST /sign {}` (default iss)
Then `GET /vehicles` with the attacker-iss token returns `401` AND with the default-iss token returns `200`
**AC-5: NFT-SEC-04b wrong `aud` rejected**
When the consumer mints a token via `POST /sign { aud: "wrong-audience" }`
Then `GET /vehicles` returns `401`
**AC-6: NFT-SEC-05 missing `permissions` claim rejected with 403**
When the consumer mints a token with no `permissions` claim (mock body `{ permissions: "" }` or `{ permissions: null }` per the mock's contract)
Then `GET /vehicles` returns `403` (NOT 401 — signature is valid)
**AC-7: NFT-SEC-06 contains-match policy on `permissions`**
When the consumer mints tokens with `permissions` values `"ADMIN"`, `"fl"` (lowercase), `"FLight"`, AND `["FL","ADMIN"]` (multi-value array)
Then `GET /vehicles` returns `403` for the first three AND `200` for the multi-value `["FL","ADMIN"]` array (contains-match accepts `"FL"` among the values)
## Non-Functional Requirements
**Performance**
- NFT-SEC-01..06: ≤ 5s each. The Authorization-header failure paths are cheap (no DB round-trip on the 401/403 short-circuit).
**Reliability**
- NFT-SEC-02 requires an out-of-band ECDSA-keypair helper that lives inside the test project, NOT in `jwks-mock` (the mock must never publish a public key it does not control). The helper generates a P-256 keypair at test-start and signs a token directly using `System.Security.Cryptography.ECDsa` — the public key is never registered with `missions`.
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|-------------|-------------------|----------------|
| AC-1 | running stack | 4 endpoints w/o Authorization | all 401; POST no DB write | AC-5.4 |
| AC-2 | `T_good` from mock + foreign keypair | flipped signature; foreign-keypair token | both 401 | AC-5.5 |
| AC-3 | mock with default iss/aud | exp_offset 60s vs 15s | 401 / 200 | AC-5.2, AC-5.6 |
| AC-4 | mock | iss=attacker vs default | 401 / 200 | AC-5.3, AC-5.11 |
| AC-5 | mock | aud=wrong | 401 | AC-5.3, AC-5.12 |
| AC-6 | mock | permissions missing | 403 | AC-5.8, AC-9.1 |
| AC-7 | mock | permissions=ADMIN/fl/FLight/["FL","ADMIN"] | 403/403/403/200 | AC-9.1, AC-9.2 |
## Constraints
- HTTP only against `http://missions:8080`. Tokens minted via `https://jwks-mock:8443/sign` with parameterised overrides.
- NFT-SEC-02 foreign-keypair: a test-only helper inside `Azaion.Missions.E2E.Tests` MAY use `System.Security.Cryptography.ECDsa` directly for the attack-token construction; this is the ONLY in-test signing path allowed — every other test must use the mock.
- NFT-SEC-06 multi-permission token requires the mock's `POST /sign` body to accept `permissions` as either a string OR a JSON array; the test-infrastructure ticket (AZ-576) covers this in the mock's contract.
- AAA pattern with `// Arrange` / `// Act` / `// Assert` per test.
## Risks & Mitigation
**Risk 1: NFT-SEC-03 flaky due to wall-clock variability**
- *Risk*: A 15s offset could fail if Docker time skew between the mock and `missions` is large.
- *Mitigation*: Both containers run on the same host clock (no `--init` time isolation); test asserts only at offsets well clear of the 30s boundary (60s and 15s — 30s and 15s away from the boundary respectively).
**Risk 2: NFT-SEC-06 multi-permission shape varies between systems**
- *Risk*: If the spec for `permissions` claim later changes from "contains-match string" to "exact-array-membership", the multi-value assertion breaks.
- *Mitigation*: Test traces explicitly to AC-9.2 and references `Auth/JwtExtensions.cs` policy registration; any change there must update this test in the same commit.
**Risk 3: Foreign-keypair token validation might pass if the SUT silently trusts any well-formed ECDSA token**
- *Risk*: A regression that disables `IssuerSigningKeyResolver` would let the foreign-keypair token through.
- *Mitigation*: Mitigated by the structure of AC-2 — both bad-signature shapes (flipped byte AND foreign keypair) must return 401.
## System Under Test Boundary
- Tests drive the product through the public HTTP surface (`http://missions:8080/{vehicles,missions}*`) and acquire signed tokens via `https://jwks-mock:8443/sign` (with the test-only foreign-keypair helper for NFT-SEC-02). Expected outputs are the documented HTTP status codes from `_docs/00_problem/input_data/expected_results/results_report.md` AC-5 rows and AC-9 rows.
- Stubs are allowed ONLY for: the external `admin` JWT issuer (`jwks-mock` container).
- Stubs, fakes, deterministic fallbacks, monkeypatches, or direct imports are NOT allowed for any internal product module — including `JwtExtensions`, `Program.cs` (auth pipeline registration), the `[Authorize(Policy = "FL")]` filter, or `ErrorHandlingMiddleware`. If any of these is not implemented, the test MUST fail/block as missing product implementation — it must not pass by replacing the module with a test stub.