mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 22:41:06 +00:00
chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful
ci/woodpecker/push/build-arm Pipeline was successful
Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
This commit is contained in:
@@ -75,14 +75,14 @@
|
||||
|
||||
---
|
||||
|
||||
### FT-P-04: Vehicle list returns plain JSON array (no pagination)
|
||||
### FT-P-04: Vehicle list returns plain JSON array (no pagination), ordered by Name ASC
|
||||
|
||||
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions`.
|
||||
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions` — and that results are ordered alphabetically by `Name` ASC (per AircraftService.GetVehicles `OrderBy(a => a.Name)`).
|
||||
**Traces to**: AC-1.5
|
||||
**Category**: Vehicle CRUD
|
||||
|
||||
**Preconditions**:
|
||||
- `seed_3_vehicles_2_default`
|
||||
- `seed_3_vehicles_2_default` containing `BR-01`, `BR-02`, `MQ-9` (any insert order)
|
||||
|
||||
**Input data**: `GET /vehicles`
|
||||
|
||||
@@ -90,16 +90,16 @@
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | `GET /vehicles` | `200`; body parses as JSON array (NOT object); `body.length == 3`; each element has PascalCase keys per FT-P-01 |
|
||||
| 1 | `GET /vehicles` | `200`; body parses as JSON array (NOT object); `body.length == 3`; each element has PascalCase keys per FT-P-01; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) |
|
||||
|
||||
**Expected outcome**: results_report.md AC-1 row 1.5.
|
||||
**Max execution time**: 2s.
|
||||
|
||||
---
|
||||
|
||||
### FT-P-05: Vehicle filter by name + isDefault
|
||||
### FT-P-05: Vehicle filter by name + isDefault (case-INSENSITIVE name)
|
||||
|
||||
**Summary**: Verifies query-string filter (case-sensitive substring on `name`, exact on `isDefault`).
|
||||
**Summary**: Verifies query-string filter — **case-INSENSITIVE** substring on `name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`), exact on `isDefault`.
|
||||
**Traces to**: AC-1.6
|
||||
**Category**: Vehicle CRUD
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | `GET /vehicles?name=BR&isDefault=true` | `200`; `body.length == 1`; `body[0].Name == "BR-01"` |
|
||||
| 2 | `GET /vehicles?name=br&isDefault=true` (lowercase) | `200`; `body.length == 1`; `body[0].Name == "BR-01"` (case-INSENSITIVE match) |
|
||||
|
||||
**Expected outcome**: results_report.md AC-1 row 1.6.
|
||||
**Max execution time**: 2s.
|
||||
@@ -165,14 +166,14 @@
|
||||
|
||||
---
|
||||
|
||||
### FT-P-08: Mission list paginated default page
|
||||
### FT-P-08: Mission list paginated default page, ordered by CreatedDate DESC
|
||||
|
||||
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20.
|
||||
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20, ordered by `CreatedDate` DESC (newest first per `FlightService.GetMissions` `OrderByDescending(f => f.CreatedDate)`).
|
||||
**Traces to**: AC-2.3, AC-8.7
|
||||
**Category**: Mission CRUD
|
||||
|
||||
**Preconditions**:
|
||||
- `seed_25_missions`
|
||||
- `seed_25_missions` with deterministic `CreatedDate` values spanning January-February 2026
|
||||
|
||||
**Input data**: `GET /missions`
|
||||
|
||||
@@ -180,7 +181,8 @@
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | `GET /missions` | `200`; body parses as object with PascalCase keys `Items, TotalCount, Page, PageSize`; `Page==1`; `PageSize==20`; `TotalCount==25`; `Items.length==20` |
|
||||
| 1 | `GET /missions` | `200`; body parses as object with PascalCase keys `Items, TotalCount, Page, PageSize`; `Page==1`; `PageSize==20`; `TotalCount==25`; `Items.length==20`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC ordering) |
|
||||
| 2 | `GET /missions?name=re` (lowercase) against missions containing `"Recon-*"` names | `200`; `body.TotalCount > 0` — case-INSENSITIVE name filter matches Mission Name `"Recon-*"` |
|
||||
|
||||
**Expected outcome**: results_report.md AC-2 row 2.3.
|
||||
**Max execution time**: 2s.
|
||||
@@ -421,24 +423,26 @@
|
||||
|
||||
## Negative Scenarios
|
||||
|
||||
### FT-N-01: Vehicle name filter is case-sensitive
|
||||
### FT-N-01: Vehicle name filter returns empty when no row matches case-insensitively
|
||||
|
||||
**Summary**: Verifies `name=br` does NOT match `BR-01` (case sensitivity).
|
||||
**Summary**: Verifies that `?name=` returns an empty body when no row's `Name` contains the substring (case-insensitive). This is the "no-match" half of AC-1.6 — distinct from FT-P-05 which asserts that lowercase input DOES match `BR-01`.
|
||||
**Traces to**: AC-1.6
|
||||
**Category**: Vehicle CRUD (negative)
|
||||
|
||||
**Preconditions**:
|
||||
- `seed_3_vehicles_2_default` (only contains `BR-*` names — no `br-*`)
|
||||
- `seed_3_vehicles_2_default` (`BR-01`, `BR-02`, `MQ-9`)
|
||||
|
||||
**Input data**: `GET /vehicles?name=br`
|
||||
**Input data**: `GET /vehicles?name=ZZ` (substring `ZZ` is absent from every name)
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected System Response |
|
||||
|------|----------------|------------------------|
|
||||
| 1 | request as above | `200`; `body.length == 0` |
|
||||
| 1 | `GET /vehicles?name=ZZ` | `200`; `body.length == 0` |
|
||||
| 2 | `GET /vehicles?name=zz` (lowercase) | `200`; `body.length == 0` (still no match) |
|
||||
|
||||
**Expected outcome**: results_report.md AC-1 row 1.7.
|
||||
**Note (drift, 2026-05-14)**: this test was previously titled "Vehicle name filter is case-sensitive" and asserted `?name=br → length 0`. That assertion was WRONG against the actual code (`a.Name.ToLower().Contains(query.Name.ToLower())` — case-insensitive). The test is rewritten to assert the genuine no-match case.
|
||||
**Max execution time**: 2s.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,19 +20,19 @@ The side-channel DB access is allowed because the AC catalogue (AC-1.2, AC-1.4,
|
||||
|---------|--------------|---------|-------|
|
||||
| `missions` | build context `./` (`Dockerfile`); image tag `azaion/missions:test` | System under test | `5002:8080` |
|
||||
| `postgres-test` | `postgres:16-alpine` | Owned PostgreSQL for test isolation. Started fresh per test class via Testcontainers OR via `docker compose down -v && docker compose up -d` between scenarios that mutate startup-sensitive state (AC-6.5 legacy drop, AC-6.6 idempotency) | `5433:5432` |
|
||||
| `e2e-consumer` | build context `tests/Azaion.Missions.E2E.Tests/`; runs `dotnet test` | xUnit test runner; produces `report.csv` | — |
|
||||
| `jwks-mock` | build context `tests/Azaion.Missions.JwksMock/`; image tag `azaion/jwks-mock:test` | **In-process stand-in for the `admin` service's JWKS endpoint.** Holds a fixed ECDSA P-256 keypair (in-memory), serves the public key as JWKS at `https://jwks-mock:8443/.well-known/jwks.json` (HTTPS-only, self-signed CA mounted into `missions` and `e2e-consumer`), and signs tokens for the consumer via `POST /sign`. Supports `POST /rotate-key` for NFT-RES-07 (JWKS rotation). The mock's `Cache-Control: max-age` is set to 60s in tests (vs admin's 3600s) so rotation completes within the 15-min CI gate. | — (internal only) |
|
||||
| `e2e-consumer` | build context `tests/Azaion.Missions.E2E.Tests/`; runs `dotnet test` | xUnit test runner; produces `report.csv`. Fetches signed test tokens from `jwks-mock` instead of minting locally — the private key never leaves `jwks-mock`, eliminating the class of bugs where the consumer signs with a key that doesn't match the published JWKS. | — |
|
||||
| `pg-side` (optional) | reused `postgres-test` connection on a side port | Side-channel DB connection for fixture seeding + post-call assertions | shares `postgres-test` |
|
||||
|
||||
No external mock services are required:
|
||||
- `admin` (JWT issuer): the test runner mints HS256 tokens itself using a known `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`.
|
||||
- `annotations`, `detection`, `autopilot`: their tables (`media`, `annotations`, `detection`, `map_objects`) are seeded directly by the side-channel for cascade tests; the services themselves are not running.
|
||||
- `flight-gate`, Watchtower, suite reverse proxy: not required for service-level e2e.
|
||||
The only external services not running are the sibling backend services (`annotations`, `detection`, `autopilot`, `flight-gate`, Watchtower, suite reverse proxy) — their tables (`media`, `annotations`, `detection`, `map_objects`) are seeded directly by the side-channel for cascade tests; the services themselves are out of scope for service-level e2e.
|
||||
|
||||
The `jwks-mock` service replaces the pre-2026-05-14 "consumer mints HS256 tokens with a shared secret" pattern. The current code path (per `Auth/JwtExtensions.cs`) is ECDSA-SHA256 + JWKS — there is no shared secret to mint with anymore. See `test-data.md` § External Dependency Mocks for the mock's contract.
|
||||
|
||||
### Networks
|
||||
|
||||
| Network | Services | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `e2e-net` | `missions`, `postgres-test`, `e2e-consumer` | Isolated bridge network; no host network access |
|
||||
| `e2e-net` | `missions`, `postgres-test`, `jwks-mock`, `e2e-consumer` | Isolated bridge network; no host network access |
|
||||
|
||||
### Volumes
|
||||
|
||||
@@ -43,8 +43,9 @@ No external mock services are required:
|
||||
|
||||
### docker-compose structure
|
||||
|
||||
The canonical compose file is `docker-compose.test.yml` at the repo root. Below is the abbreviated structural outline — see the actual file for the full healthchecks, depends_on, and volume mounts.
|
||||
|
||||
```yaml
|
||||
# Outline only — not runnable code (the actual scripts/run-tests.sh wires this up)
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
@@ -52,39 +53,51 @@ services:
|
||||
POSTGRES_DB: azaion
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres-test
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d azaion"]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 30
|
||||
# healthcheck: pg_isready
|
||||
|
||||
jwks-mock:
|
||||
build: { context: tests/Azaion.Missions.JwksMock }
|
||||
environment:
|
||||
JWT_ISSUER: https://admin-test.azaion.local
|
||||
JWT_AUDIENCE: azaion-edge
|
||||
OLD_KEY_GRACE_SECONDS: 5
|
||||
# healthcheck: GET /.well-known/jwks.json over HTTPS (--no-check-certificate)
|
||||
|
||||
missions:
|
||||
build:
|
||||
context: .
|
||||
build: { context: . }
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
|
||||
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
|
||||
JWT_ISSUER: https://admin-test.azaion.local
|
||||
JWT_AUDIENCE: azaion-edge
|
||||
JWT_JWKS_URL: https://jwks-mock:8443/.well-known/jwks.json
|
||||
ASPNETCORE_ENVIRONMENT: Test # NOT Production -- CORS falls back to permissive with a warning log line
|
||||
volumes:
|
||||
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
postgres-test: { condition: service_healthy }
|
||||
jwks-mock: { condition: service_healthy }
|
||||
|
||||
e2e-consumer:
|
||||
build:
|
||||
context: tests/Azaion.Missions.E2E.Tests
|
||||
build: { context: tests/Azaion.Missions.E2E.Tests }
|
||||
environment:
|
||||
MISSIONS_BASE_URL: http://missions:8080
|
||||
DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test
|
||||
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
|
||||
depends_on:
|
||||
missions:
|
||||
condition: service_started
|
||||
JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
|
||||
JWT_ISSUER: https://admin-test.azaion.local
|
||||
JWT_AUDIENCE: azaion-edge
|
||||
volumes:
|
||||
- ./e2e-results:/app/results
|
||||
- ./test-results:/app/results
|
||||
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
|
||||
depends_on:
|
||||
missions: { condition: service_healthy }
|
||||
jwks-mock: { condition: service_healthy }
|
||||
```
|
||||
|
||||
**Production-gate (E9) variant**: for the CORS production-gate lock test (E9), the test runner spawns `missions` with `ASPNETCORE_ENVIRONMENT=Production` and an empty `CorsConfig:AllowedOrigins` and asserts startup THROWS `InvalidOperationException`. This variant runs OUTSIDE the main compose stack via `docker run` to avoid disturbing the rest of the suite.
|
||||
|
||||
## Consumer Application
|
||||
|
||||
**Tech stack**: xUnit 2.x + `Microsoft.AspNetCore.Mvc.Testing` (HttpClient via `IClassFixture`) OR plain `HttpClient` against the dockerized service. Bogus 35.x for synthetic data. JWT minting via `System.IdentityModel.Tokens.Jwt`. PostgreSQL side-channel via Npgsql (NOT linq2db — keep the consumer free of system-under-test runtime libs).
|
||||
**Tech stack**: xUnit 2.x + plain `HttpClient` against the dockerized `missions` service. Bogus 35.x for synthetic data. JWT acquisition via HTTPS `POST jwks-mock:8443/sign` (no in-process JWT library on the consumer side — the consumer treats tokens as opaque bearer strings). PostgreSQL side-channel via Npgsql (NOT linq2db — keep the consumer free of system-under-test runtime libs).
|
||||
|
||||
**Entry point**: `dotnet test tests/Azaion.Missions.E2E.Tests/Azaion.Missions.E2E.Tests.csproj --logger "trx;LogFileName=results.trx"` followed by a small post-processor that converts trx → `report.csv`.
|
||||
|
||||
@@ -92,18 +105,21 @@ services:
|
||||
|
||||
| Interface | Protocol | Endpoint | Authentication |
|
||||
|-----------|----------|----------|----------------|
|
||||
| Vehicle API | HTTP/1.1 JSON | `http://missions:8080/vehicles[?name=&isDefault=]` and `/vehicles/{id}[/setDefault]` | `Authorization: Bearer <HS256, permissions=FL>` |
|
||||
| Vehicle API | HTTP/1.1 JSON | `http://missions:8080/vehicles[?name=&isDefault=]` and `/vehicles/{id}[/setDefault]` | `Authorization: Bearer <ECDSA-SHA256, iss=$JWT_ISSUER, aud=$JWT_AUDIENCE, permissions=FL>` |
|
||||
| Mission API | HTTP/1.1 JSON | `http://missions:8080/missions[?name=&fromDate=&toDate=&page=&pageSize=]` | same |
|
||||
| Waypoint API | HTTP/1.1 JSON | `http://missions:8080/missions/{id}/waypoints[/{wpId}]` | same |
|
||||
| Health | HTTP/1.1 JSON | `http://missions:8080/health` | anonymous |
|
||||
| DB side-channel (assertions only) | TCP/Postgres wire | `postgres-test:5432` | `postgres:postgres-test` |
|
||||
| JWKS mock sign endpoint | HTTPS/1.1 JSON | `https://jwks-mock:8443/sign` body `{ "iss":..., "aud":..., "exp":..., "permissions":..., ... }` returns signed JWT | none (test-network internal only) |
|
||||
| JWKS mock JWKS endpoint | HTTPS/1.1 JSON | `https://jwks-mock:8443/.well-known/jwks.json` | none (consumed by `missions` itself, not by the test consumer) |
|
||||
| JWKS mock rotate-key endpoint | HTTPS/1.1 JSON | `POST https://jwks-mock:8443/rotate-key` body `{}` returns `{ "newKid": "..." }` and starts the `OldKeyGraceSeconds` window | none |
|
||||
|
||||
### What the consumer does NOT have access to
|
||||
|
||||
- No `using Azaion.Missions.*;` — the consumer is a separate csproj with no project reference to the system under test.
|
||||
- No `AppDataConnection` instantiation; the side-channel uses raw Npgsql `NpgsqlCommand` only.
|
||||
- No file-system overlap; `e2e-consumer` is a separate container.
|
||||
- No environment variable shared in process; the system-under-test's `JWT_SECRET` is supplied through compose env, the consumer mints with the same value via its own env.
|
||||
- No JWT signing key in the consumer process — the consumer requests signed tokens from `jwks-mock` via HTTPS `POST /sign`. The ECDSA private key never leaves the mock container. This guarantees the test setup cannot drift away from "consumer-signed token matches missions-cached JWKS public key".
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
@@ -125,4 +141,50 @@ Categories: `BLACKBOX`, `PERF`, `RES`, `SEC`, `RES_LIM`. `Traces` is a comma-sep
|
||||
|
||||
## Hardware Assessment
|
||||
|
||||
To be filled by `phases/hardware-assessment.md` between Phase 3 and Phase 4. Today's expected outcome: no GPU, no specialised hardware, no model inference — this is a CRUD service. Test execution requires only a Postgres-capable container and the .NET 10 SDK image. AMD64 + ARM64 both supported (matches H2). Resource ceiling: 2 GB RAM total for `missions + postgres-test + e2e-consumer` is sufficient.
|
||||
> Filled by autodev `/test-spec` Hardware Assessment phase (2026-05-14).
|
||||
|
||||
### Decision: Docker execution
|
||||
|
||||
The project is **NOT hardware-dependent**. Test execution is fully containerised; no local-mode runner is needed.
|
||||
|
||||
### Hardware dependencies found: NONE
|
||||
|
||||
Documentation scan (`restrictions.md`, `solution.md`, `architecture.md`, `components/*/description.md`):
|
||||
- H1–H6 cover edge-device deployment shape (Jetson Orin / OrangePI / operator-PC, multi-arch ARM64+AMD64, vertical scale only) but none of those concerns require hardware code paths inside the test runner — the suite-level CI matrix builds for both arches separately (per O4 + H2).
|
||||
- No GPU / model inference / sensor / camera / GPIO / V4L2 mention in any docs.
|
||||
- `solution.md` § 2.1 explicitly classifies every component as standard ASP.NET Core + linq2db over Postgres — no hardware adapter.
|
||||
|
||||
Code scan (`*.csproj`, `Dockerfile`, all `.cs` source):
|
||||
- `Azaion.Flights.csproj` (post-B5: `Azaion.Missions.csproj`) packages: `linq2db 6.2.0`, `Npgsql 10.0.2`, `Microsoft.AspNetCore.Authentication.JwtBearer 10.0.5`, `Swashbuckle.AspNetCore 10.1.5`. None is hardware-specific.
|
||||
- `Dockerfile`: stock `mcr.microsoft.com/dotnet/sdk:10.0` build stage + `mcr.microsoft.com/dotnet/aspnet:10.0` runtime stage. Multi-arch via `--platform=$BUILDPLATFORM` + `dotnet publish --os linux --arch $arch`. No `runtime: nvidia`, no GPU device mounts.
|
||||
- No `RuntimeInformation.IsOSPlatform`, no `coreml` / `cuda` / `gpio` / `v4l2` / `opencl` / `vulkan` / `tpu` / `fpga` references in any production source file (verified via grep — matches were only in skill templates and docs, not in `Controllers/`, `Services/`, `Database/`, `Auth/`, `Middleware/`, `Program.cs`).
|
||||
|
||||
Multi-arch matters for the **production deploy** (per H2), but it does NOT affect the test runner: tests exercise the API black-box, identical on both architectures. The suite CI matrix (`.woodpecker/build-arm.yml` + the future `.woodpecker/build-amd.yml`) tests each arch in its own container.
|
||||
|
||||
### Execution instructions — Docker mode (the only mode)
|
||||
|
||||
**Prerequisites** on the test host:
|
||||
- Docker Engine ≥ 24.0 with Docker Compose v2 plugin.
|
||||
- 2.5 GB free RAM (1 GB for `missions`, 256 MB for `postgres-test`, 256 MB for `jwks-mock`, 512 MB for `e2e-consumer`, 512 MB headroom).
|
||||
- 2 CPU cores recommended for the test wall-clock to fit under the 15-minute CI gate.
|
||||
- Free TCP ports `5002` (host → `missions:8080`) and `5433` (host → `postgres-test:5432`) — used only when running outside compose. The `jwks-mock` HTTPS port (`8443` inside the container) is NOT mapped to the host; only `missions` and `e2e-consumer` reach it via the internal `e2e-net` network.
|
||||
|
||||
**Run command** (from repo root):
|
||||
|
||||
```bash
|
||||
./scripts/run-tests.sh
|
||||
```
|
||||
|
||||
The runner script (Phase 4) wires up `docker compose up`, waits for `missions` health, executes `dotnet test` inside `e2e-consumer`, collects `report.csv`, and tears down the compose stack. See `scripts/run-tests.sh` for the canonical command sequence.
|
||||
|
||||
**Performance tests**:
|
||||
|
||||
```bash
|
||||
./scripts/run-performance-tests.sh
|
||||
```
|
||||
|
||||
Same compose stack, but with the perf seed (1000 missions for NFT-PERF-04, 100 minimal missions for NFT-PERF-01) and the `[Trait("Category","Perf")]` filter. See `scripts/run-performance-tests.sh` (Phase 4).
|
||||
|
||||
### Resource ceiling
|
||||
|
||||
Total RAM: ≤ 2.5 GB for `missions + postgres-test + jwks-mock + e2e-consumer` together. Test wall-clock budget: ≤ 15 minutes (CI gate). Storage: ephemeral (the `pg-test-data` tmpfs is recreated per scenario class via `docker compose down -v` for the bootstrap-sensitive scenarios — NFT-RES-03, NFT-RES-04, NFT-RES-05, NFT-RES-06, NFT-RES-07).
|
||||
|
||||
@@ -110,27 +110,29 @@
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-05: DB unreachable at startup — process exits non-zero
|
||||
### NFT-RES-05: Required configuration missing → fail-fast at startup
|
||||
|
||||
**Summary**: Verifies AC-6.7 — DB unreachability causes process exit, NOT silent retry-forever.
|
||||
**Traces to**: AC-6.7
|
||||
**Summary**: Verifies AC-6.1 / AC-6.2 / E3 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException` when any of the four required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) is missing or whitespace-only. Also verifies AC-6.7 — DB unreachability (after config resolution succeeds) still causes process exit. The legacy "silent dev fallback boot" failure mode is structurally eliminated.
|
||||
**Traces to**: AC-6.1, AC-6.2, AC-6.7, E3, E4
|
||||
|
||||
**Preconditions**:
|
||||
- `missions` NOT running
|
||||
- `missions` NOT running.
|
||||
- This scenario uses `docker run` outside the main compose to isolate env-var manipulation.
|
||||
|
||||
**Fault injection**: stop `postgres-test` (`docker compose stop postgres-test`) then start `missions`.
|
||||
|
||||
**Steps**:
|
||||
**Steps** (each row is a separate `docker run` invocation; each times out at 30s):
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `docker compose stop postgres-test` | |
|
||||
| 2 | `docker compose up -d missions` | |
|
||||
| 3 | Poll `docker inspect --format '{{.State.ExitCode}}' missions` every 1s for ≤ 30s | At some point within 30s, the container has exited with non-zero exit code |
|
||||
| 4 | `docker logs missions` | Contains an Npgsql connection error message (e.g., `Connection refused`) |
|
||||
| 1 | `docker run --rm azaion/missions:test` with ALL four required env vars unset | container exits non-zero within 5s; logs contain `InvalidOperationException`; logs mention at least one of the four required keys |
|
||||
| 2 | `docker run` with `DATABASE_URL` unset; the three JWT vars set correctly | same shape; logs mention `DATABASE_URL` or `Database:Url` |
|
||||
| 3 | `docker run` with `JWT_ISSUER=""` (whitespace-only); other three set | same shape; logs mention `JWT_ISSUER` or `Jwt:Issuer` |
|
||||
| 4 | `docker run` with `JWT_AUDIENCE` unset; others set | same shape; logs mention `JWT_AUDIENCE` or `Jwt:Audience` |
|
||||
| 5 | `docker run` with `JWT_JWKS_URL` unset; others set | same shape; logs mention `JWT_JWKS_URL` or `Jwt:JwksUrl` |
|
||||
| 6 | `docker compose stop postgres-test`, then start `missions` with all four env vars set correctly — config resolution succeeds, then DB-connect fails | container exits non-zero within 30s; logs contain a recognisable Npgsql connection error (e.g., `Connection refused`) — NOT an `InvalidOperationException` from the resolver (this differentiates "config missing" from "config valid but DB down") |
|
||||
|
||||
**Pass criteria**: container exits with non-zero code within 30s; logs contain a recognisable Npgsql error.
|
||||
**Max execution time**: 60s.
|
||||
**Pass criteria**: rows 1–5 → fail-fast at config resolution; row 6 → fail at DB-connect AFTER config resolution succeeded.
|
||||
**Note**: this test now exercises BOTH the fail-fast resolver (rows 1–5) AND the DB-unreachable case (row 6). Pre-revision, only row 6 was tested under the assumption of hardcoded dev fallbacks.
|
||||
**Max execution time**: 180s (6 docker-run cycles).
|
||||
|
||||
---
|
||||
|
||||
@@ -158,30 +160,34 @@
|
||||
|
||||
---
|
||||
|
||||
### NFT-RES-07: JWT_SECRET rotation invalidates existing tokens
|
||||
### NFT-RES-07: JWKS key rotation — no missions restart required
|
||||
|
||||
**Summary**: Verifies AC-5.7 — restarting the service with a different `JWT_SECRET` causes previously-valid tokens to fail validation.
|
||||
**Summary**: Verifies AC-5.7 — rotating the signing key on `admin` (via `jwks-mock POST /rotate-key`) propagates to `missions` on the JWKS cache refresh tick **without restarting `missions`**. This is the primary operational win over the legacy shared-HMAC model, which required coordinated re-deploy across every backend on the device.
|
||||
**Traces to**: AC-5.7
|
||||
|
||||
**Preconditions**:
|
||||
- `missions` running with `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`
|
||||
- Token `T1` minted with the same secret, valid for 1h
|
||||
- `missions` running with warm JWKS cache (any previous protected request succeeded).
|
||||
- `jwks-mock` running with `Cache-Control: max-age=60` and `OldKeyGraceSeconds=5`.
|
||||
- Token `T1` requested via `POST /sign` with the CURRENT `kid` (`kid_v1`), valid for 1h.
|
||||
|
||||
**Fault injection**: restart `missions` with `JWT_SECRET=rotated-secret-32-chars-min!!!!!`.
|
||||
**Fault injection**: `POST https://jwks-mock:8443/rotate-key {}` — generates `kid_v2`, retains `kid_v1` in the JWKS for `OldKeyGraceSeconds`, then evicts `kid_v1`.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Action | Expected Behavior |
|
||||
|------|--------|------------------|
|
||||
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` |
|
||||
| 2 | `docker compose stop missions` | |
|
||||
| 3 | `docker compose run -e JWT_SECRET=rotated-secret-32-chars-min!!!!! -d missions` | |
|
||||
| 4 | Wait for `GET /health` 200 | |
|
||||
| 5 | `GET /vehicles` with `Authorization: Bearer T1` (same token as step 1) | `401` |
|
||||
| 6 | Mint token `T2` with the new secret, `GET /vehicles` with `T2` | `200` |
|
||||
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` (cached JWKS knows `kid_v1`) |
|
||||
| 2 | `POST https://jwks-mock:8443/rotate-key {}` → returns `kid_v2` | jwks-mock now publishes BOTH `kid_v1` and `kid_v2` in its JWKS for `OldKeyGraceSeconds=5` |
|
||||
| 3 | Immediately request token `T2` signed with `kid_v2` via `POST /sign {}` | mock returns JWT with header `kid: kid_v2` |
|
||||
| 4 | Immediately `GET /vehicles` with `Authorization: Bearer T2` (BEFORE `missions` JWKS cache refresh) | `401` (cache still only has `kid_v1`) |
|
||||
| 5 | Wait up to 90s for `missions`'s `ConfigurationManager<JsonWebKeySet>` to refresh against the new JWKS (the mock's `max-age=60` triggers a refresh on the next request after that interval) | — |
|
||||
| 6 | `GET /vehicles` with `Authorization: Bearer T2` again | `200` (cache now contains `kid_v2`) |
|
||||
| 7 | `GET /vehicles` with `Authorization: Bearer T1` (still has unexpired lifetime, signed with `kid_v1`) | `200` IF the JWKS refresh happened BEFORE the mock's `OldKeyGraceSeconds=5` window closed (the JWKS still had `kid_v1`); `401` AFTER the grace window when `missions` refreshes and `kid_v1` is no longer in the JWKS. Test asserts the eventual `401` |
|
||||
| 8 | Verify `missions` was NEVER restarted during this scenario (`docker inspect --format '{{.State.StartedAt}}' missions` is unchanged from before step 1) | startup timestamp unchanged |
|
||||
|
||||
**Pass criteria**: `T1` works pre-rotation, fails post-rotation; `T2` works post-rotation.
|
||||
**Max execution time**: 90s.
|
||||
**Pass criteria**: rotation propagates without restart; `T2` (new kid) eventually accepted; `T1` (old kid) eventually rejected; `missions` startup timestamp unchanged.
|
||||
**Note**: this test replaces the pre-revision "shared-secret rotation requires coordinated redeploy" scenario. The pre-revision test asserted that ALL services on the device had to restart together; the post-revision test asserts the opposite — they do NOT have to restart.
|
||||
**Max execution time**: 180s (longest wait is the JWKS refresh tick).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Security Tests
|
||||
|
||||
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
|
||||
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14), revised in cycle-update mode (drift findings Phase 2) for the ECDSA+JWKS JWT model.
|
||||
> **Naming**: post-rename target. Security tests focus on the JWT bearer + Authz boundary defined in AC-5 and AC-9.
|
||||
> **Out-of-scope (suite-tracked)**: the `iss` / `aud` validation gap (AC-5.3, CMMC L2 row 3, AZ-487 / AZ-494) is documented but NOT enforced today. Tests assert today's behaviour (AC-5.3 returns 200) — when the suite-wide remediation lands, update NFT-SEC-04.
|
||||
> **Auth model**: ECDSA-SHA256 with JWKS retrieved from `admin` (mocked via `jwks-mock` in tests), iss + aud + alg-pin validated, 30s clock skew. The CMMC L2 row 3 (`iss`/`aud`) finding is now structurally fixed in code; the corresponding NFT-SEC scenarios now assert REJECTION, not acceptance.
|
||||
|
||||
---
|
||||
|
||||
@@ -26,51 +26,70 @@
|
||||
|
||||
### NFT-SEC-02: Invalid signature → 401
|
||||
|
||||
**Summary**: Verifies AC-5.5 — token signed with a different secret is rejected.
|
||||
**Summary**: Verifies AC-5.5 — token whose ECDSA signature doesn't verify against any cached JWKS public key is rejected.
|
||||
**Traces to**: AC-5.5
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint token `T_bad` with `WRONG_SECRET=other-secret-32-chars-min!!!!!!`, otherwise valid (`exp = now + 1h`, `permissions=FL`) | |
|
||||
| 1 | Acquire valid signed token `T_good` from `jwks-mock POST /sign`, then flip a single byte in the token's signature segment to produce `T_bad` | — |
|
||||
| 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` |
|
||||
| 3 | Acquire token from a SEPARATE ECDSA keypair not present in the mock's published JWKS (via an out-of-band test helper) and call `GET /vehicles` | `401` |
|
||||
|
||||
**Pass criteria**: `401`.
|
||||
**Pass criteria**: both bad-signature cases return `401`.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-03: Expired token outside skew → 401; inside skew → 200
|
||||
|
||||
**Summary**: Verifies AC-5.6 + AC-5.2 (1-min skew tighter than .NET's 5-min default).
|
||||
**Summary**: Verifies AC-5.6 + AC-5.2 (**30s** clock skew, tighter than .NET's 5-min default and the legacy 1-min setting).
|
||||
**Traces to**: AC-5.2, AC-5.6
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint token `T_exp` with `exp = now - 120s` (outside 60s skew); `permissions=FL` | |
|
||||
| 1 | Request token via `POST /sign { "exp_offset_seconds": -60 }` (exp = now - 60s; outside the 30s skew) | mock returns signed JWT |
|
||||
| 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` |
|
||||
| 3 | Mint token `T_skew` with `exp = now - 30s` (inside 60s skew); `permissions=FL` | |
|
||||
| 3 | Request token via `POST /sign { "exp_offset_seconds": -15 }` (exp = now - 15s; inside the 30s skew) | mock returns signed JWT |
|
||||
| 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` |
|
||||
|
||||
**Pass criteria**: `T_exp` rejected; `T_skew` accepted.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-04: Missing `iss` and `aud` claims accepted (today's behavior, AC-5.3)
|
||||
### NFT-SEC-04: Token with `iss` ≠ `JWT_ISSUER` → 401
|
||||
|
||||
**Summary**: Verifies the `ValidateIssuer = false` and `ValidateAudience = false` configuration. This test will FAIL once the suite-wide remediation (AZ-487 / AZ-494) lands — that's good news; update the test then.
|
||||
**Traces to**: AC-5.3
|
||||
**Summary**: Verifies AC-5.11 — `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`. This is the structural fix for CMMC L2 row 3 row (issuer validation half).
|
||||
**Traces to**: AC-5.3, AC-5.11
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint token with NO `iss` and NO `aud` claim, valid signature + lifetime, `permissions=FL` | |
|
||||
| 2 | `GET /vehicles` with that token | `200` |
|
||||
| 1 | Request token via `POST /sign { "iss": "https://attacker.example.com" }` (every other claim valid) | mock returns signed JWT — the signature is correct, only `iss` is wrong |
|
||||
| 2 | `GET /vehicles` with that token | `401` |
|
||||
| 3 | Request token via `POST /sign { }` (iss defaults to the mock's `JWT_ISSUER` env, which matches `missions`'s configured `ValidIssuer`) | mock returns signed JWT |
|
||||
| 4 | `GET /vehicles` with that token | `200` |
|
||||
|
||||
**Pass criteria**: `200` today; will become `401` post-remediation.
|
||||
**Pass criteria**: wrong-`iss` rejected with 401; matching-`iss` accepted.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-04b: Token with `aud` ≠ `JWT_AUDIENCE` → 401
|
||||
|
||||
**Summary**: Verifies AC-5.12 — `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. This is the structural fix for CMMC L2 row 3 (audience validation half).
|
||||
**Traces to**: AC-5.3, AC-5.12
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Request token via `POST /sign { "aud": "wrong-audience" }` (every other claim valid) | mock returns signed JWT |
|
||||
| 2 | `GET /vehicles` with that token | `401` |
|
||||
|
||||
**Pass criteria**: wrong-`aud` rejected with 401.
|
||||
|
||||
---
|
||||
|
||||
@@ -90,23 +109,25 @@
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-06: Wrong `permissions` claim value → 403
|
||||
### NFT-SEC-06: Wrong `permissions` claim value → 403; multi-permission token accepted
|
||||
|
||||
**Summary**: Verifies AC-9.2 — the policy is exact-string match, hardcoded.
|
||||
**Traces to**: AC-9.2
|
||||
**Summary**: Verifies AC-9.1 + AC-9.2 — the policy is `RequireClaim("permissions", "FL")` (contains-match, not exact body-match). Wrong values → 403; a multi-permission token where one value equals `"FL"` → 200.
|
||||
**Traces to**: AC-9.1, AC-9.2
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Mint token with `permissions="ADMIN"`, valid otherwise | |
|
||||
| 1 | Acquire token with `permissions="ADMIN"`, valid otherwise | mock returns signed JWT |
|
||||
| 2 | `GET /vehicles` | `403` |
|
||||
| 3 | Mint token with `permissions="fl"` (lowercase), valid otherwise | |
|
||||
| 3 | Acquire token with `permissions="fl"` (lowercase) | mock returns signed JWT |
|
||||
| 4 | `GET /vehicles` | `403` |
|
||||
| 5 | Mint token with `permissions="FLight"`, valid otherwise | |
|
||||
| 5 | Acquire token with `permissions="FLight"` | mock returns signed JWT |
|
||||
| 6 | `GET /vehicles` | `403` |
|
||||
| 7 | Acquire token with `permissions: ["FL", "ADMIN"]` (multi-permission array) | mock returns signed JWT |
|
||||
| 8 | `GET /vehicles` | `200` (contains-match accepts `"FL"` among the values) |
|
||||
|
||||
**Pass criteria**: `403` for every wrong-value case.
|
||||
**Pass criteria**: rows 2, 4, 6 → 403; row 8 → 200.
|
||||
|
||||
---
|
||||
|
||||
@@ -159,8 +180,94 @@
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-10: Algorithm-pin defends against HS256-confusion → 401
|
||||
|
||||
**Summary**: Verifies AC-5.1 + AC-5.10 — `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` defends against the HS256-confusion attack where an attacker who learns a JWKS public key (which is, by definition, public) attempts to forge a token signed with that public key as the HMAC secret under `alg: HS256`.
|
||||
**Traces to**: AC-5.1, AC-5.10
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | Acquire the published JWKS public key bytes from `GET https://jwks-mock:8443/.well-known/jwks.json` (or use the mock's helper that returns those bytes for the test) | — |
|
||||
| 2 | Acquire token via `POST /sign { "alg_override": "HS256" }` — the mock signs the JWT body with the public-key bytes as the HMAC secret (mimicking an attack) | mock returns HS256-signed JWT |
|
||||
| 3 | `GET /vehicles` with `Authorization: Bearer T_hs256` | `401` |
|
||||
| 4 | Acquire token via `POST /sign { "alg_override": "none" }` (mock emits unsigned JWT) | mock returns unsigned JWT |
|
||||
| 5 | `GET /vehicles` with that token | `401` |
|
||||
|
||||
**Pass criteria**: both HS256-confusion attack and unsigned token are rejected with `401`.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-11: Unknown `kid` (rotation lag) → 401 until JWKS refresh
|
||||
|
||||
**Summary**: Verifies AC-5.7 — a token signed with a key whose `kid` is not in the cached JWKS is rejected; once the JWKS refreshes and includes the new `kid`, the same `kid` becomes accepted.
|
||||
**Traces to**: AC-5.7
|
||||
|
||||
**Preconditions**:
|
||||
- `missions` has a warm JWKS cache (any previous protected request succeeded).
|
||||
- `jwks-mock` `OldKeyGraceSeconds = 5`.
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `POST https://jwks-mock:8443/rotate-key {}` | mock returns `{ "newKid": "new-kid" }` |
|
||||
| 2 | Immediately request token via `POST /sign {}` (signs with NEW kid, before `missions` JWKS cache refreshes) | mock returns signed JWT with header `kid: new-kid` |
|
||||
| 3 | `GET /vehicles` with that token | `401` (cached JWKS doesn't yet contain `new-kid`) |
|
||||
| 4 | Wait for `missions`'s JWKS cache to refresh (≤ 90s; the mock sets `Cache-Control: max-age=60` so the refresh tick is at most ~60s) | — |
|
||||
| 5 | `GET /vehicles` with the same token (still valid lifetime) | `200` |
|
||||
| 6 | Request token signed with the PREVIOUS `kid` within the `OldKeyGraceSeconds=5` window | mock signs with the old key |
|
||||
| 7 | `GET /vehicles` with that token | `200` (both keys are in the JWKS during grace) |
|
||||
| 8 | Wait > 5s, then request token signed with the OLD `kid` — mock should refuse (key already evicted from its sign-pool) | mock returns 400/410 |
|
||||
|
||||
**Pass criteria**: rotation completes transparently within the cache-refresh window; tokens minted with the new `kid` are rejected during the lag, accepted after.
|
||||
**Max execution time**: 120s.
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-12: Missing `JWT_JWKS_URL`/`JWT_ISSUER`/`JWT_AUDIENCE` → startup throws
|
||||
|
||||
**Summary**: Verifies AC-6.1 / AC-6.2 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` fail-fast for each of the four required env vars eliminates the legacy "silent dev fallback" failure mode.
|
||||
**Traces to**: AC-6.1, AC-6.2, E1, E3
|
||||
|
||||
**Steps** (run as four separate `docker run` invocations):
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `docker run` `missions` with `DATABASE_URL` unset and the three JWT vars set | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `DATABASE_URL` (or `Database:Url`) |
|
||||
| 2 | `docker run` with `JWT_ISSUER` unset | container exits non-zero; logs mention `JWT_ISSUER` (or `Jwt:Issuer`) |
|
||||
| 3 | `docker run` with `JWT_AUDIENCE` unset | container exits non-zero; logs mention `JWT_AUDIENCE` (or `Jwt:Audience`) |
|
||||
| 4 | `docker run` with `JWT_JWKS_URL` unset | container exits non-zero; logs mention `JWT_JWKS_URL` (or `Jwt:JwksUrl`) |
|
||||
| 5 | `docker run` with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS) and the other three set | container STARTS (config resolution passes); first protected request returns 500 with a log line mentioning HTTPS / `RequireHttps` |
|
||||
|
||||
**Pass criteria**: rows 1–4 → process exits before HTTP server binds; row 5 → process starts but the first protected request fails at JWKS fetch time.
|
||||
**Max execution time**: 60s (4 docker-run cycles).
|
||||
|
||||
---
|
||||
|
||||
### NFT-SEC-13: CORS Production-gate fail-fast (E9 lock test)
|
||||
|
||||
**Summary**: Verifies AC-6.11 — in `ASPNETCORE_ENVIRONMENT=Production` with empty `CorsConfig:AllowedOrigins` and `CorsConfig:AllowAnyOrigin != true`, `CorsConfigurationValidator.EnsureSafeForEnvironment` throws and the process exits non-zero.
|
||||
**Traces to**: AC-6.11, E9
|
||||
|
||||
**Steps**:
|
||||
|
||||
| Step | Consumer Action | Expected Response |
|
||||
|------|----------------|------------------|
|
||||
| 1 | `docker run` `missions` with `ASPNETCORE_ENVIRONMENT=Production` and **no** `CorsConfig` env vars | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `CorsConfig` / `AllowedOrigins` / Production |
|
||||
| 2 | Same as 1 but with `CorsConfig__AllowAnyOrigin=true` set | container starts; logs contain a warning that CORS is permissive in Production (recommend listing explicit origins) but NO throw |
|
||||
| 3 | Same as 1 but with `CorsConfig__AllowedOrigins__0=https://operator.example.com` set | container starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo |
|
||||
| 4 | Same as 3, preflight from `https://attacker.example.com` | preflight responds without the allow-origin echo (the policy refuses the disallowed origin) |
|
||||
| 5 | `docker run` with `ASPNETCORE_ENVIRONMENT=Development` (or unset → defaults to `Production`-no, actually unset defaults to `Production` per ASP.NET Core; use `Test` here) and no `CorsConfig` | container starts; logs contain `PermissiveDefaultWarning` |
|
||||
|
||||
**Pass criteria**: row 1 → fail-fast; rows 2–4 → start with the expected CORS posture; row 5 → start with the documented permissive fallback + warning.
|
||||
**Max execution time**: 90s.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests.
|
||||
- The CMMC L2 row 3 (`iss` / `aud`) gap is acknowledged but NOT remediated in this Epic; NFT-SEC-04 documents today's permissive behavior so a future enforcement change is detected.
|
||||
- NFT-SEC-04 / NFT-SEC-04b / NFT-SEC-10 / NFT-SEC-11 / NFT-SEC-12 / NFT-SEC-13 are NEW scenarios added in the 2026-05-14 drift re-verification cycle. They replace / extend the old "permissive iss/aud" + "shared-secret rotation" + "dev-fallback footgun" assumptions of the pre-revision spec.
|
||||
- No fuzz testing today (recommended follow-up under a separate refactor cycle).
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
Three isolation tiers, by scenario type:
|
||||
|
||||
- **Class-scoped DB reset** (`IClassFixture<DbResetFixture>`): for scenarios that share the same seed within a test class but must not leak to other classes. Used for AC-1, AC-2, AC-4 read paths.
|
||||
- **Scenario-scoped container restart** (`docker compose down -v && up -d`): for scenarios that assert startup-time behavior or migrator side-effects. Used for AC-6.3, AC-6.4 (idempotency), AC-6.5 (legacy drop), AC-6.6 (idempotent re-run), AC-6.7 (DB unreachable), AC-5.7 (JWT_SECRET rotation).
|
||||
- **Scenario-scoped container restart** (`docker compose down -v && up -d`): for scenarios that assert startup-time behavior or migrator side-effects. Used for AC-6.3, AC-6.4 (idempotency), AC-6.5 (legacy drop), AC-6.6 (idempotent re-run), AC-6.7 (DB unreachable), AC-6.11 (CORS Production-gate lock test — separate `docker run` invocation), AC-5.7 (JWKS key rotation).
|
||||
- **Per-test transaction roll-back is NOT used** — the system under test is a separate process and its `DataConnection` is not in the test transaction.
|
||||
|
||||
## Input Data Mapping
|
||||
@@ -41,15 +41,15 @@ Three isolation tiers, by scenario type:
|
||||
| FT-P-01 | `POST /vehicles` body per `data_parameters.md` § 2.1 | `201 Created`, `Vehicle` body, DB row exists | exact + db_query | N/A | `results_report.md` AC-1 row 1.1 |
|
||||
| FT-P-02 | `POST /vehicles` body with `IsDefault:true` against `seed_one_default_vehicle` | `201`; new row default; prior row not default; default count == 1 | exact + db_query | N/A | AC-1 row 1.2 |
|
||||
| FT-P-03 | `POST /vehicles/{id}/setDefault {IsDefault:true}` | `200`; default count == 1; only target row default | exact + db_query | N/A | AC-1 row 1.4 |
|
||||
| FT-P-04 | `GET /vehicles` against `seed_3_vehicles_2_default` | `200`; body length == 3; PascalCase keys | exact + schema | N/A | AC-1 row 1.5 |
|
||||
| FT-P-04 | `GET /vehicles` against `seed_3_vehicles_2_default` | `200`; body length == 3; PascalCase keys; **ordered by `Name` ASC** | exact + schema + ordering | N/A | AC-1 row 1.5 |
|
||||
| FT-P-05 | `GET /vehicles?name=BR&isDefault=true` | `200`; body length == 1; `body[0].Name == "BR-01"` | exact | N/A | AC-1 row 1.6 |
|
||||
| FT-N-01 | `GET /vehicles?name=br` (case mismatch) | `200`; body length == 0 | exact | N/A | AC-1 row 1.7 |
|
||||
| FT-N-01 | `GET /vehicles?name=br` (lowercase filter against `BR-01`) | `200`; body length == 1 (filter is **case-INSENSITIVE** per AC-1.6) | exact | N/A | AC-1 row 1.7 |
|
||||
| FT-N-02 | `GET /vehicles/{random uuid}` | `404`; envelope `{ statusCode:404, message }` | exact + schema | N/A | AC-1 row 1.8 |
|
||||
| FT-N-03 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | `409`; row not deleted | exact + db_query | N/A | AC-1 row 1.9 |
|
||||
| FT-P-06 | `DELETE /vehicles/{id}` against vehicle with 0 missions | `204`; row deleted | exact + db_query | N/A | AC-1 row 1.10 |
|
||||
| FT-P-07 | `POST /missions` body per `data_parameters.md` § 2.2 | `201`; `CreatedDate ± 5s` of `now` | exact + numeric_tolerance | ±5s | AC-2 row 2.1 |
|
||||
| FT-N-04 | `POST /missions {VehicleId:<random>}` | `400` (today, divergent from spec's 404) | exact | N/A | AC-2 row 2.2 |
|
||||
| FT-P-08 | `GET /missions` against `seed_25_missions` | `200`; `PaginatedResponse<Mission>`; Page=1, PageSize=20, TotalCount=25, Items.length=20 | exact + schema | N/A | AC-2 row 2.3 |
|
||||
| FT-P-08 | `GET /missions` against `seed_25_missions` | `200`; `PaginatedResponse<Mission>`; Page=1, PageSize=20, TotalCount=25, Items.length=20; **ordered by `CreatedDate` DESC** (newest first); `Items[0].CreatedDate >= Items[1].CreatedDate >= ...` | exact + schema + ordering | N/A | AC-2 row 2.3 |
|
||||
| FT-P-09 | `GET /missions?page=2&pageSize=20` | `Page=2, Items.length=5` | exact | N/A | AC-2 row 2.4 |
|
||||
| FT-P-10 | `GET /missions?fromDate=...&toDate=...` | `TotalCount=3` against the 5-row seed | exact | N/A | AC-2 row 2.5 |
|
||||
| FT-P-11 | `PUT /missions/{id} {Name, VehicleId:null}` | `200`; Name updated, VehicleId preserved | exact | N/A | AC-2 row 2.7 |
|
||||
@@ -70,13 +70,33 @@ NFT-* mappings (perf, resilience, security, resource-limit) are inline in the re
|
||||
|
||||
| External Service | Mock/Stub | How Provided | Behavior |
|
||||
|-----------------|-----------|-------------|----------|
|
||||
| `admin` (JWT issuer) | In-process token mint | `System.IdentityModel.Tokens.Jwt` in the consumer using `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256 | Mints valid / expired / wrong-secret / claim-missing / claim-typo tokens on demand for AC-5 + AC-9 scenarios |
|
||||
| `admin` (JWT issuer + JWKS) | `jwks-mock` container | Built from `tests/Azaion.Missions.JwksMock/`, runs in the same `e2e-net` Docker network. Self-signed TLS cert; CA mounted into `missions` so it trusts the mock's JWKS HTTPS endpoint. Exposes `GET /.well-known/jwks.json` (consumed by `missions`'s `ConfigurationManager<JsonWebKeySet>`), `POST /sign` (returns ECDSA-signed JWTs to the test consumer for AC-5 + AC-9 scenarios), `POST /rotate-key` (generates a new `kid`, retains the previous public key for `OldKeyGraceSeconds` to support NFT-RES-07 transition assertions). The mock's private key never leaves its container. | Mints valid / expired / wrong-`kid` / wrong-`iss` / wrong-`aud` / wrong-`alg` (HS256 confusion) / claim-missing / claim-typo tokens on demand. Rotation tests trigger key roll without restarting `missions`. |
|
||||
| `annotations` table owner | DB-only stub | Side-channel `CREATE TABLE annotations (id text PRIMARY KEY, media_id text)` then `INSERT` | Provides rows the cascade walk reads + deletes; no service running |
|
||||
| `detection` table owner | DB-only stub | Side-channel `CREATE TABLE detection (id uuid PRIMARY KEY, annotation_id text)` + INSERT | Same as above |
|
||||
| `media` table owner | DB-only stub | Side-channel `CREATE TABLE media (id text PRIMARY KEY, waypoint_id uuid)` + INSERT | Same |
|
||||
| `autopilot` writer of `map_objects` | DB-only stub + race injector | Side-channel; for AC-3.4 race, a parallel goroutine-equivalent inserts a `map_objects` row immediately after the service's first `SELECT` (instrumented via test-only proxy) | One scenario only |
|
||||
| `flight-gate`, Watchtower, suite reverse proxy, suite UI | NOT mocked | n/a | Out of scope for service-level e2e |
|
||||
|
||||
**JWKS mock token-minting contract** (consumed by the e2e test runner):
|
||||
|
||||
```http
|
||||
POST https://jwks-mock:8443/sign HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
Response: `{ "token": "<encoded JWT>", "kid": "<key id>" }`.
|
||||
|
||||
The mock signs every token with the current private key by default. `alg_override: "HS256"` is the only way to obtain an HS256 token in tests — used to verify the algorithm-pin defense (NFT-SEC-10).
|
||||
|
||||
## Data Validation Rules
|
||||
|
||||
| Data Type | Validation today (per AC-* notes) | Invalid Examples | Expected System Behavior |
|
||||
|-----------|-----------------------------------|-----------------|------------------------|
|
||||
| Vehicle.Name | NONE (per data_parameters.md § 2.1 note) | `""` (empty) | accepted; row created; tests assert this is the current state, not the desirable one (carry-forward) |
|
||||
| Vehicle.BatteryCapacity | NONE | `-1` | accepted; carry-forward |
|
||||
| Vehicle.Type | NONE (any int accepted) | `99` | accepted; carry-forward |
|
||||
| Mission.Page / PageSize | NONE | `-1`, `999999` | accepted by binding; carry-forward |
|
||||
| Waypoint.GeoPoint | NONE; all-null accepted | `{Lat:null, Lon:null, Mgrs:null}` | accepted (`OrderNum + Height` still required-by-shape) |
|
||||
| JWT lifetime | `ValidateLifetime=true` with **30s** clock skew | `exp = now-60s` | `401` |
|
||||
| JWT signature | ECDSA-SHA256 + admin's JWKS | tampered payload / signed with non-JWKS key | `401` |
|
||||
@@ -86,8 +106,16 @@ NFT-* mappings (perf, resilience, security, resource-limit) are inline in the re
|
||||
| JWT `kid` (header) | must resolve in cached JWKS | unknown `kid` | `401` (until next JWKS refresh tick when rotation publishes it) |
|
||||
| JWT claim `permissions` | contains-match `"FL"` (multi-permission tokens accepted if one entry == `"FL"`) | `"fl"`, `"ADMIN"`, `"FLight"`, missing | `403` |
|
||||
| `Authorization` header | required on all `/vehicles/*`, `/missions/*` | absent | `401` |
|
||||
| JWT lifetime | `ValidateLifetime=true` with 1-min skew | `exp = now-2min` | `401` |
|
||||
| JWT signature | HS256 + shared secret | wrong secret / tampered payload | `401` |
|
||||
| JWT claim `permissions` | exact string match `"FL"` | `"fl"`, `"ADMIN"`, missing | `403` |
|
||||
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
|
||||
| `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | each required at startup | any of them missing or whitespace-only | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
|
||||
| `JWT_JWKS_URL` scheme | HTTPS-only via `HttpDocumentRetriever { RequireHttps = true }` | `http://...` | passes startup config resolution, but first protected request fails 500 when JWKS fetch rejects the URL |
|
||||
| `CorsConfig:AllowedOrigins` in `Production` | non-empty OR `AllowAnyOrigin == true` | empty AND `AllowAnyOrigin != true` AND `ASPNETCORE_ENVIRONMENT=Production` | `CorsConfigurationValidator.EnsureSafeForEnvironment` throws `InvalidOperationException`; startup aborts |
|
||||
| `CorsConfig` in non-Production | empty allow-list permitted | n/a | falls back to `AllowAnyOrigin/Method/Header` AND logs `PermissiveDefaultWarning` |
|
||||
| JWT `kid` (header) | must resolve in cached JWKS | unknown `kid` | `401` (until next JWKS refresh tick when rotation publishes it) |
|
||||
| JWT claim `permissions` | contains-match `"FL"` (multi-permission tokens accepted if one entry == `"FL"`) | `"fl"`, `"ADMIN"`, `"FLight"`, missing | `403` |
|
||||
| `Authorization` header | required on all `/vehicles/*`, `/missions/*` | absent | `401` |
|
||||
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | process exits with error before HTTP server binds |
|
||||
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
|
||||
| `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | each required at startup | any of them missing or whitespace-only | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
|
||||
| `JWT_JWKS_URL` scheme | HTTPS-only via `HttpDocumentRetriever { RequireHttps = true }` | `http://...` | passes startup config resolution, but first protected request fails 500 when JWKS fetch rejects the URL |
|
||||
| `CorsConfig:AllowedOrigins` in `Production` | non-empty OR `AllowAnyOrigin == true` | empty AND `AllowAnyOrigin != true` AND `ASPNETCORE_ENVIRONMENT=Production` | `CorsConfigurationValidator.EnsureSafeForEnvironment` throws `InvalidOperationException`; startup aborts |
|
||||
| `CorsConfig` in non-Production | empty allow-list permitted | n/a | falls back to `AllowAnyOrigin/Method/Header` AND logs `PermissiveDefaultWarning` |
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Traceability Matrix
|
||||
|
||||
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
|
||||
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14); re-issued in cycle-update mode after the targeted re-verification of 2026-05-14 (drift findings Phase 2).
|
||||
> **Naming**: post-rename target. Tests written for the post-rename API surface — RED-status until B5–B8 land. The traceability matrix below treats the documented spec as the source of truth.
|
||||
> **Drift correction**: rows for AC-5, AC-6, AC-9, AC-1.5/1.6, AC-2.3, E1/E3/E4/E9 are updated below to reflect the ECDSA+JWKS JWT model, fail-fast configuration resolver, and CORS production-gate validator. Several `NOT COVERED` items in the pre-revision matrix are now `Covered` thanks to the new NFT-SEC-10..13 + NFT-RES-05 rewrite + the inverted FT-N-01.
|
||||
|
||||
## Acceptance Criteria Coverage
|
||||
|
||||
@@ -13,8 +14,8 @@
|
||||
| AC-1.2 | Default-clear on create/update/setDefault | FT-P-02, FT-P-03 | Covered |
|
||||
| AC-1.3 | "Exactly one default" stricter than spec (B12 pending) | covered indirectly via FT-P-02, FT-P-03 (assertions on `count == 1`) | Covered (carry-forward) |
|
||||
| AC-1.4 | Default-clear NOT transaction-wrapped → race | NFT-RES-08 | Covered (probabilistic) |
|
||||
| AC-1.5 | GET /vehicles is plain array (NO pagination) | FT-P-04 | Covered |
|
||||
| AC-1.6 | Filter case-sensitive on `name`, exact on `isDefault` | FT-P-05, FT-N-01 | Covered |
|
||||
| AC-1.5 | GET /vehicles is plain array (NO pagination), ordered by `Name` ASC | FT-P-04 | Covered |
|
||||
| AC-1.6 | Filter **case-INSENSITIVE** on `name`, exact on `isDefault` | FT-P-05 (positive + lowercase), FT-N-01 (no-match negative) | Covered |
|
||||
| AC-1.7 | GET /vehicles/{id} 404 | FT-N-02 | Covered |
|
||||
| AC-1.8 | DELETE /vehicles/{id} 409 if referenced | FT-N-03 | Covered |
|
||||
| AC-1.9 | All `/vehicles/*` require `Policy="FL"` | NFT-SEC-01, NFT-SEC-05, NFT-SEC-06 | Covered |
|
||||
@@ -25,12 +26,12 @@
|
||||
|-------|------------------------------|----------|----------|
|
||||
| AC-2.1 | Create mission, default `CreatedDate = UtcNow` | FT-P-07 | Covered |
|
||||
| AC-2.2 | Non-existent VehicleId → 400 (today; spec wants 404) | FT-N-04 | Covered (carry-forward) |
|
||||
| AC-2.3 | GET /missions paginated `PaginatedResponse<Mission>` | FT-P-08, FT-P-09, FT-P-10, NFT-PERF-04 | Covered |
|
||||
| AC-2.3 | GET /missions paginated `PaginatedResponse<Mission>`, ordered by `CreatedDate` DESC, name filter case-INSENSITIVE | FT-P-08 (ordering + case-INSENSITIVE), FT-P-09, FT-P-10, NFT-PERF-04 | Covered |
|
||||
| AC-2.4 | GET /missions/{id} 404 | FT-N-05 | Covered |
|
||||
| AC-2.5 | PUT partial update (Name update only) | FT-P-11 | Covered |
|
||||
| AC-2.6 | LinqToDB does NOT eager-load `[Association]` | covered indirectly via FT-P-07/FT-P-11 (body shape assertion checks `Vehicle == null`, `Waypoints == null/[]`) | Covered |
|
||||
| AC-2.7 | All `/missions/*` require `Policy="FL"` | NFT-SEC-01 | Covered |
|
||||
| AC-2.8 | TOCTOU on FK → 500 | NOT directly covered as a separate test (deterministic reproduction is hard); falls under NFT-RES-08-style probabilistic family | NOT COVERED — see Uncovered Items §1 |
|
||||
| AC-2.8 | TOCTOU on FK between existence check and insert — now PARTLY mitigated by DB-level FK (PG `23503`); surface today is 500 | NOT directly covered as a separate test (deterministic reproduction still requires controllable concurrency); the FK mitigation is observable indirectly via 6.10 startup-schema test asserting `REFERENCES vehicles(id)` exists | NOT COVERED — see Uncovered Items §1 |
|
||||
|
||||
### AC-3 — Mission cascade delete F3 (most critical)
|
||||
|
||||
@@ -60,22 +61,25 @@
|
||||
|
||||
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|
||||
|-------|------------------------------|----------|----------|
|
||||
| AC-5.1 | HS256 + `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | covered indirectly via NFT-SEC-02 (different secret rejected) and NFT-SEC-03 (correct secret accepted) | Covered |
|
||||
| AC-5.2 | `ValidateLifetime=true`, `ClockSkew=1min` | NFT-SEC-03 | Covered |
|
||||
| AC-5.3 | `ValidateIssuer=false`, `ValidateAudience=false` (today) | NFT-SEC-04 | Covered (locks today's behavior) |
|
||||
| AC-5.1 | **ECDSA-SHA256** with `ValidAlgorithms = [EcdsaSha256]` (algorithm pin) | NFT-SEC-02 (signature reject), NFT-SEC-10 (HS256-confusion defense) | Covered |
|
||||
| AC-5.2 | `ValidateLifetime=true`, `ClockSkew=30s` | NFT-SEC-03 | Covered |
|
||||
| AC-5.3 | `ValidateIssuer=true` + `ValidateAudience=true` (CMMC L2 row 3 structurally fixed in this service) | NFT-SEC-04, NFT-SEC-04b | Covered |
|
||||
| AC-5.4 | Missing header → 401 | NFT-SEC-01 | Covered |
|
||||
| AC-5.5 | Invalid signature → 401 | NFT-SEC-02 | Covered |
|
||||
| AC-5.6 | Expired token (outside skew) → 401 | NFT-SEC-03 | Covered |
|
||||
| AC-5.7 | Old `JWT_SECRET` after rotation → 401 | NFT-RES-07 | Covered |
|
||||
| AC-5.5 | Invalid signature / no matching public key → 401 | NFT-SEC-02 | Covered |
|
||||
| AC-5.6 | Expired token (outside 30s skew) → 401 | NFT-SEC-03 | Covered |
|
||||
| AC-5.7 | **JWKS key rotation without restart** — old kid eventually rejected, new kid eventually accepted | NFT-RES-07, NFT-SEC-11 | Covered |
|
||||
| AC-5.8 | Missing `permissions=FL` claim → 403 | NFT-SEC-05 | Covered |
|
||||
| AC-5.9 | Local validator never calls `admin` | NOT directly observable from outside the process; covered indirectly by `admin` not running in the test env (NFT-SEC-* still pass) | Partially covered |
|
||||
| AC-5.9 | Request-path validation local after JWKS cached; cold-start synchronously fetches JWKS | NFT-SEC-* all pass with `admin` not running (only `jwks-mock` runs); the cold-start failure mode is testable by stopping `jwks-mock` and restarting `missions` then issuing the first protected request | Covered (covered-cold-start case under results_report row 5.10) |
|
||||
| AC-5.10 | Algorithm pin (`alg ∉ [EcdsaSha256]` → 401) | NFT-SEC-10 | Covered |
|
||||
| AC-5.11 | `iss` validation (`iss != JWT_ISSUER` → 401) | NFT-SEC-04 | Covered |
|
||||
| AC-5.12 | `aud` validation (`aud != JWT_AUDIENCE` → 401) | NFT-SEC-04b | Covered |
|
||||
|
||||
### AC-6 — Startup + migration
|
||||
|
||||
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|
||||
|-------|------------------------------|----------|----------|
|
||||
| AC-6.1 | DATABASE_URL URL form converted via `ConvertPostgresUrl` | covered indirectly via every test that depends on a working DB connection (compose env uses URL form) | Covered |
|
||||
| AC-6.2 | DATABASE_URL raw form accepted | NOT directly covered — the test environment uses URL form; can be added by an extra startup scenario with the raw form | NOT COVERED — see Uncovered Items §3 |
|
||||
| AC-6.1 | Four required env vars resolved via `ResolveRequiredOrThrow` (env-first, then `IConfiguration`, else throw); URL form converted via `ConvertPostgresUrl` | results_report 6.1, 6.1b, 6.1c; NFT-SEC-12; NFT-RES-05 | Covered |
|
||||
| AC-6.2 | DATABASE_URL raw form accepted; no `JWT_SECRET` legacy env consulted | results_report 6.2; NFT-SEC-12 row 4 (asserts `JWT_JWKS_URL` is consulted, not `JWT_SECRET`) | Covered |
|
||||
| AC-6.3 | Migrator runs ONCE at startup, inside scope | NFT-RES-03 (idempotency assertion implies single-run + safe-restart) | Partially covered |
|
||||
| AC-6.4 | 4 owned tables + 3 indexes created | NFT-RES-03 (asserts schema via `\d+` after first start) | Covered |
|
||||
| AC-6.5 | Post-B9 one-shot legacy `DROP TABLE IF EXISTS` | NFT-RES-04 | Covered |
|
||||
@@ -84,6 +88,8 @@
|
||||
| AC-6.8 | DB missing (3D000) → process exits | NFT-RES-06 | Covered |
|
||||
| AC-6.9 | `ErrorHandlingMiddleware` registered FIRST | covered indirectly via FT-N-08 + NFT-SEC-08 (any unhandled exception produces the documented envelope) | Covered |
|
||||
| AC-6.10 | Listens on port 8080; edge maps host `5002:8080` | covered by every test that connects to port 5002→8080 | Covered |
|
||||
| AC-6.11 | CORS Production-gate fail-fast (empty allow-list + `AllowAnyOrigin != true` → throw) | NFT-SEC-13; results_report 6.10–6.13 | Covered |
|
||||
| AC-6.12 | `JWT_JWKS_URL` HTTPS-only at fetch time (passes startup config) | NFT-SEC-12 row 5; results_report 6.1c | Covered |
|
||||
|
||||
### AC-7 — Health probe
|
||||
|
||||
@@ -110,8 +116,8 @@
|
||||
|
||||
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|
||||
|-------|------------------------------|----------|----------|
|
||||
| AC-9.1 | Policy `"FL"` registered, satisfied by `permissions == "FL"` | every protected-endpoint test | Covered |
|
||||
| AC-9.2 | Hardcoded string mismatch ("fl", "FLight") → 403 | NFT-SEC-06 | Covered |
|
||||
| AC-9.1 | Policy `"FL"` registered as `RequireClaim("permissions", "FL")` — **contains-match**: a multi-permission token containing `"FL"` is accepted | every protected-endpoint test + NFT-SEC-06 step 7-8 (multi-permission accepted) | Covered |
|
||||
| AC-9.2 | Hardcoded string mismatch ("fl", "FLight", "ADMIN") → 403 | NFT-SEC-06 steps 2/4/6 | Covered |
|
||||
| AC-9.3 | Policy NAME `"FL"` retains legacy wording (deferred) | not testable at runtime — documentation-only | Documentation only |
|
||||
| AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` | covered by NFT-SEC-01 + NFT-SEC-07 (every endpoint gets the same gate; health is the only exception) | Covered |
|
||||
|
||||
@@ -146,7 +152,7 @@
|
||||
| S1 | C# / .NET 10 | implicit in test environment | Implicitly covered |
|
||||
| S2 | ASP.NET Core | implicit | Implicitly covered |
|
||||
| S3–S5 | Library versions | csproj / lockfile concern; NOT a behavioral test | Out of scope (build-time check) |
|
||||
| S6 | Swagger NOT gated on `IsDevelopment()` | NOT directly tested; could add a single `GET /swagger` test that asserts 200 in production-like env. Carry-forward divergence | NOT COVERED — see Uncovered Items §5 |
|
||||
| S6 | Swagger NOT gated on `IsDevelopment()` | NOT directly tested; could add a single `GET /swagger` test that asserts 200 in production-like env. Carry-forward divergence | NOT COVERED — see Uncovered Items §4 |
|
||||
| S7 | PostgreSQL only | implicit | Implicitly covered |
|
||||
| S8 | One csproj, one root namespace | csproj structure; NOT a behavioral test | Out of scope (code organization) |
|
||||
| S9 | No `src/` directory | repo layout; NOT a behavioral test | Out of scope |
|
||||
@@ -161,16 +167,16 @@
|
||||
|
||||
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|
||||
|---------------|---------------------|----------|----------|
|
||||
| E1 | Two required env vars | implicit; NFT-RES-05 (DB unreachable) + NFT-SEC-* (JWT_SECRET behavior) | Covered |
|
||||
| E2 | DATABASE_URL accepts URL or raw form | URL form covered via NFT-RES-05 path; raw form NOT covered | NOT COVERED — see Uncovered Items §3 |
|
||||
| E3 | Hardcoded dev fallbacks NOT gated on IsDevelopment() | a startup test with NO env vars set could verify fallback boot — security risk gate; carry-forward | NOT COVERED — see Uncovered Items §6 |
|
||||
| E4 | JWT_SECRET shared across services | suite-level concern | Out of scope |
|
||||
| E1 | **Four** required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) — fail-fast via `ResolveRequiredOrThrow` | NFT-SEC-12 (all 4 rows), NFT-RES-05 (all 5 rows), results_report 6.1b | Covered |
|
||||
| E2 | DATABASE_URL accepts URL or raw form | URL form covered via every default test; raw form covered by results_report 6.2 | Covered |
|
||||
| E3 | **No hardcoded dev fallbacks** — `ResolveRequiredOrThrow` throws | NFT-SEC-12, NFT-RES-05 rows 1-5 | Covered |
|
||||
| E4 | Asymmetric ECDSA: no shared secret on this side; only public-key configuration | NFT-SEC-* all run against `jwks-mock` (the mock holds the private key, this service holds only public-key config) | Covered |
|
||||
| E5 | Container EXPOSE 8080; edge maps 5002:8080 | implicit | Implicitly covered |
|
||||
| E6 | Image tag post-B10 | build-time concern, not behavior | Out of scope |
|
||||
| E7 | Entrypoint post-B5 | build-time concern | Out of scope |
|
||||
| E8 | No appsettings env-specific overrides | code organization; NOT a behavioral test | Out of scope |
|
||||
| E9 | CORS `AllowAnyOrigin/Method/Header` | could add a single CORS preflight test that asserts the documented permissive behavior | NOT COVERED — see Uncovered Items §7 |
|
||||
| E10 | TLS termination is suite reverse proxy | suite-level concern | Out of scope |
|
||||
| E9 | CORS **gated by `CorsConfigurationValidator`** — Production throws on empty allow-list | NFT-SEC-13 (all 5 rows), results_report 6.10–6.13 | Covered |
|
||||
| E10 | TLS termination is suite reverse proxy; JWKS independently constrained to HTTPS | NFT-SEC-12 row 5 (JWKS HTTPS-only) | Covered (HTTPS half) |
|
||||
|
||||
### Operational (O1–O10)
|
||||
|
||||
@@ -195,34 +201,36 @@
|
||||
| AC-2 Mission CRUD | 8 | 7 | 0 | 1 (AC-2.8 TOCTOU) | 0 | 87% |
|
||||
| AC-3 Cascade F3 | 7 | 5 | 1 | 1 (AC-3.7 race) | 0 | 86% |
|
||||
| AC-4 Waypoint CRUD F4 | 7 | 7 | 0 | 0 | 0 | 100% |
|
||||
| AC-5 JWT | 9 | 8 | 1 | 0 | 0 | 100% |
|
||||
| AC-6 Startup + migration | 10 | 8 | 1 | 1 (AC-6.2 raw conn) | 0 | 90% |
|
||||
| AC-5 JWT | 12 | 12 | 0 | 0 | 0 | 100% |
|
||||
| AC-6 Startup + migration | 12 | 12 | 0 | 0 | 0 | 100% |
|
||||
| AC-7 Health | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
|
||||
| AC-8 Wire shape | 7 | 7 | 0 | 0 | 0 | 100% |
|
||||
| AC-9 Authz | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
|
||||
| AC-10 Operational | 6 | 1 | 0 | 0 | 5 | 100% (in-scope) |
|
||||
| Restrictions H | 6 | 1 | 2 | 0 | 3 | 100% (in-scope) |
|
||||
| Restrictions S | 15 | 4 | 2 | 0 | 9 | 100% (in-scope) |
|
||||
| Restrictions E | 10 | 1 | 1 | 3 (E2, E3, E9) | 5 | 60% (in-scope) |
|
||||
| Restrictions E | 10 | 7 | 1 | 0 | 2 | 100% (in-scope) |
|
||||
| Restrictions O | 10 | 4 | 2 | 0 | 4 | 100% (in-scope) |
|
||||
| **Total** | 112 | 67 | 11 | 6 | 28 | **93%** in-scope |
|
||||
| **Total** | 117 | 78 | 10 | 3 | 26 | **97%** in-scope |
|
||||
|
||||
## Uncovered Items Analysis
|
||||
|
||||
| # | Item | Reason Not Covered | Risk | Mitigation |
|
||||
|---|------|-------------------|------|-----------|
|
||||
| 1 | AC-2.8 — TOCTOU on FK between existence check and insert | Deterministic reproduction requires controllable concurrency primitive that doesn't exist today (instrumented test build with `pg_advisory_lock`) | Low — failure mode is well-documented and produces a 500 (loud failure, not silent corruption); occurs only when admin races with create | Add probabilistic test (similar to NFT-RES-08) under a follow-up ticket. Document as known carry-forward. |
|
||||
| 1 | AC-2.8 — TOCTOU on FK between existence check and insert | Deterministic reproduction requires controllable concurrency primitive (instrumented test build with `pg_advisory_lock`). Note: DB-level FK now produces PG error `23503` so the failure surface is consistent — only the timing of the race is hard to reproduce | Low — failure mode is well-documented and produces a 500 (loud failure, not silent corruption); occurs only when admin races with create | Add probabilistic test (similar to NFT-RES-08) under a follow-up ticket. Document as known carry-forward. |
|
||||
| 2 | AC-3.7 — autopilot orphan race on `map_objects` insert after step-1 read | Same as #1 — needs controllable concurrency | Low — leaves at most one orphan row per race; cleanup on next mission delete or via manual sweep | Same mitigation as #1; add to follow-up. |
|
||||
| 3 | AC-6.2 / E2 — `DATABASE_URL` raw form path | Test env uses URL form; raw form is the alternate adapter branch | Low — branch is small, well-localised in `ConvertPostgresUrl` | Add a single startup scenario with raw form. Single-line config change in test compose. |
|
||||
| 4 | AC-7.4 — TCP connect fails on container down (Watchtower restarts) | Container lifecycle outside service surface | None at service level — testable at suite e2e level | Cover at suite e2e (`monorepo-e2e` skill scope) |
|
||||
| 5 | S6 — Swagger NOT gated on `IsDevelopment()` | Carry-forward security finding; not part of AC | Medium — production deploy with Swagger exposed | Add a single test `GET /swagger/index.html` returns 200 in test env, with explicit comment that this LOCKS the carry-forward divergence (will fail when remediated). Suggest as follow-up. |
|
||||
| 6 | E3 — Hardcoded dev fallbacks NOT gated | Carry-forward security finding | Medium — production deploy without env vars boots with well-known secret | Add a startup test with NO env vars set, assert `JWT_SECRET` claim ladder still works (locks the divergence). Suggest as follow-up. |
|
||||
| 7 | E9 — CORS `AllowAnyOrigin/Method/Header` | Carry-forward; assumed safe behind reverse proxy | Low — assumed deployment topology mitigates | Add CORS preflight test that locks current behavior. Suggest as follow-up. |
|
||||
| 3 | AC-7.4 — TCP connect fails on container down (Watchtower restarts) | Container lifecycle outside service surface | None at service level — testable at suite e2e level | Cover at suite e2e (`monorepo-e2e` skill scope) |
|
||||
| 4 | S6 — Swagger NOT gated on `IsDevelopment()` (surviving branch of ADR-005) | Carry-forward security finding; not part of AC | Medium — production deploy with Swagger exposed | Add a single test `GET /swagger/index.html` returns 200 in test env, with explicit comment that this LOCKS the carry-forward divergence (will fail when remediated). Suggest as follow-up. |
|
||||
|
||||
**Recommendation**: items 1, 2 are deterministic-test improvements to land alongside a future `transaction-wrap` refactor (closes the carry-forward at the same time as the test improvement). Items 3, 5, 6, 7 are 1-test additions each — add them in Step 5 (Decompose Tests) under a "blackbox-lock-carry-forward" task.
|
||||
**Resolved by the 2026-05-14 re-verification**:
|
||||
- E3 (hardcoded dev fallbacks) — structurally fixed in code via `ResolveRequiredOrThrow`. Old "Uncovered §6" obsolete; now Covered by NFT-SEC-12 + NFT-RES-05.
|
||||
- E9 (CORS `AllowAnyOrigin/Method/Header` in all environments) — structurally fixed by `CorsConfigurationValidator`. Old "Uncovered §7" obsolete; now Covered by NFT-SEC-13.
|
||||
- AC-6.2 / E2 (`DATABASE_URL` raw form path) — covered by results_report row 6.2 as part of the cycle-update; no longer a gap.
|
||||
|
||||
**Recommendation**: items 1, 2 are deterministic-test improvements to land alongside a future `transaction-wrap` refactor (closes the carry-forward at the same time as the test improvement). Item 4 is a 1-test addition — add in Step 5 (Decompose Tests) under a "blackbox-lock-carry-forward" task.
|
||||
|
||||
## Phase 3 Coverage Gate
|
||||
|
||||
**Threshold**: ≥ 75% (per `cursor-meta.mdc` Quality Thresholds + `phases/03-data-validation-gate.md`).
|
||||
**Achieved**: 93% in-scope.
|
||||
**Verdict**: **PASS** — Phase 3 gate cleared on first iteration. The 6 uncovered items above are all low-medium risk with documented mitigations.
|
||||
**Achieved**: 97% in-scope (after the 2026-05-14 drift Phase 2 re-issue).
|
||||
**Verdict**: **PASS** — Phase 3 gate cleared. The 4 remaining uncovered items are all low-medium risk with documented mitigations; the previous E3 / E9 / AC-6.2 gaps were closed by the structural code fixes already in `Infrastructure/ConfigurationResolver.cs` and `Infrastructure/CorsConfigurationValidator.cs`.
|
||||
|
||||
Reference in New Issue
Block a user