refactor: enhance JWT authentication and CORS configuration

Updated JWT authentication to use configuration values instead of hardcoded secrets, improving security and flexibility. Enhanced CORS policy to conditionally allow origins based on configuration settings, with logging for permissive defaults. Updated README to reflect project renaming and clarify service context.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 19:48:25 +03:00
parent 2fe394d732
commit 7025f4d075
74 changed files with 8494 additions and 19 deletions
+608
View File
@@ -0,0 +1,608 @@
# Blackbox Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Pre-rename code path runs the same test against `/aircrafts`/`/flights`; tests will be RED until B5B8 land.
> **Cross-references**: every test traces to one or more AC IDs from `_docs/00_problem/acceptance_criteria.md`. Expected results are inline (quantifiable) with a pointer to the corresponding row in `_docs/00_problem/input_data/expected_results/results_report.md`.
## Positive Scenarios
### FT-P-01: Create non-default vehicle returns 201 with PascalCase body
**Summary**: Verifies vehicle CRUD create path and response wire shape.
**Traces to**: AC-1.1, AC-8.1
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_empty`
**Input data**: `data_parameters.md` § 2.1 — `POST /vehicles { Type:0, Model:"Bayraktar", Name:"BR-01", FuelType:1, BatteryCapacity:0, EngineConsumption:5, EngineConsumptionIdle:1, IsDefault:false }`, JWT `permissions=FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /vehicles` with body above | `201 Created`; body `Vehicle` with PascalCase keys `Id, Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault`; `Id` parses as UUID |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={returnedId}` | `count == 1` |
**Expected outcome**: results_report.md AC-1 row 1.1.
**Max execution time**: 5s.
---
### FT-P-02: Create default vehicle demotes prior default
**Summary**: Verifies the clear-then-set pattern in `VehicleService.CreateVehicle` when `IsDefault=true`.
**Traces to**: AC-1.2
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_one_default_vehicle` (prior row `Id=P1`, `is_default=true`)
**Input data**: `POST /vehicles { ..., IsDefault:true }` (other fields valid)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /vehicles { ..., IsDefault:true }` | `201`; new row has `IsDefault:true` |
| 2 | Side-channel `SELECT id, is_default FROM vehicles ORDER BY is_default DESC` | New row has `is_default=true`; row `P1` has `is_default=false`; total `is_default=true` count == 1 |
**Expected outcome**: results_report.md AC-1 row 1.2.
**Max execution time**: 5s.
---
### FT-P-03: setDefault promotes existing vehicle
**Summary**: Verifies `POST /vehicles/{id}/setDefault` clear-then-set behavior.
**Traces to**: AC-1.2 (update branch), AC-1.4
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_one_default_vehicle` (default row `P1`); add a non-default row `P2`
**Input data**: `POST /vehicles/{P2}/setDefault { IsDefault:true }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /vehicles/{P2}/setDefault { IsDefault:true }` | `200`; body `Vehicle` with `Id==P2`, `IsDefault==true` |
| 2 | Side-channel `SELECT id, is_default FROM vehicles` | `P2.is_default==true`, `P1.is_default==false`, default count == 1 |
**Expected outcome**: results_report.md AC-1 row 1.4.
**Max execution time**: 5s.
---
### FT-P-04: Vehicle list returns plain JSON array (no pagination)
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions`.
**Traces to**: AC-1.5
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_3_vehicles_2_default`
**Input data**: `GET /vehicles`
**Steps**:
| 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 |
**Expected outcome**: results_report.md AC-1 row 1.5.
**Max execution time**: 2s.
---
### FT-P-05: Vehicle filter by name + isDefault
**Summary**: Verifies query-string filter (case-sensitive substring on `name`, exact on `isDefault`).
**Traces to**: AC-1.6
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_3_vehicles_2_default` containing `BR-01` (default), `BR-02` (non-default), `MQ-9` (default)
**Input data**: `GET /vehicles?name=BR&isDefault=true`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /vehicles?name=BR&isDefault=true` | `200`; `body.length == 1`; `body[0].Name == "BR-01"` |
**Expected outcome**: results_report.md AC-1 row 1.6.
**Max execution time**: 2s.
---
### FT-P-06: Delete vehicle with no references
**Summary**: Verifies `DELETE /vehicles/{id}` returns 204 and removes the row.
**Traces to**: AC-1.10
**Category**: Vehicle CRUD
**Preconditions**:
- One vehicle row exists, no missions reference it
**Input data**: `DELETE /vehicles/{id}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `DELETE /vehicles/{id}` | `204 No Content`; empty body |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={id}` | `count == 0` |
**Expected outcome**: results_report.md AC-1 row 1.10.
**Max execution time**: 2s.
---
### FT-P-07: Create mission with default CreatedDate
**Summary**: Verifies mission create assigns `CreatedDate = UtcNow` when null.
**Traces to**: AC-2.1
**Category**: Mission CRUD
**Preconditions**:
- `seed_one_default_vehicle` (use its id as `VehicleId`)
**Input data**: `POST /missions { Name:"Recon-01", VehicleId:<id>, CreatedDate:null }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Capture `t0 = DateTime.UtcNow` | |
| 2 | `POST /missions` with body above | `201`; `body.CreatedDate` parses as UTC; `abs(body.CreatedDate - t0) ≤ 5s` |
**Expected outcome**: results_report.md AC-2 row 2.1.
**Max execution time**: 5s.
---
### FT-P-08: Mission list paginated default page
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20.
**Traces to**: AC-2.3, AC-8.7
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions`
**Input data**: `GET /missions`
**Steps**:
| 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` |
**Expected outcome**: results_report.md AC-2 row 2.3.
**Max execution time**: 2s.
---
### FT-P-09: Mission list page 2
**Summary**: Verifies pagination skips correctly to page 2.
**Traces to**: AC-2.3
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions`
**Input data**: `GET /missions?page=2&pageSize=20`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /missions?page=2&pageSize=20` | `200`; `Page==2`; `Items.length==5`; ids in `Items` are disjoint from those returned by FT-P-08 (per UUID set check) |
**Expected outcome**: results_report.md AC-2 row 2.4.
**Max execution time**: 2s.
---
### FT-P-10: Mission list date range
**Summary**: Verifies `?fromDate=&toDate=` filter inclusivity.
**Traces to**: AC-2.3
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions` (5 January, 20 February)
**Input data**: `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `TotalCount==5`; every `Items[i].CreatedDate` falls within January 2026 UTC |
**Expected outcome**: results_report.md AC-2 row 2.5.
**Max execution time**: 2s.
---
### FT-P-11: Mission partial update preserves null fields
**Summary**: Verifies `PUT /missions/{id}` only overwrites non-null fields in the request body.
**Traces to**: AC-2.5
**Category**: Mission CRUD
**Preconditions**:
- One mission row with known `Name`, `VehicleId`
**Input data**: `PUT /missions/{id} { Name:"Renamed", VehicleId:null }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.Name == "Renamed"`; `body.VehicleId == previous_value` (preserved) |
**Expected outcome**: results_report.md AC-2 row 2.7.
**Max execution time**: 2s.
---
### FT-P-12: Mission cascade delete walks every dependency table
**Summary**: Verifies `DELETE /missions/{id}` deletes every row in the seeded chain across `map_objects`, `detection`, `annotations`, `media`, `waypoints`, `missions`.
**Traces to**: AC-3.1
**Category**: Mission Cascade Delete (F3) — most critical
**Preconditions**:
- `fixture_cascade_F3` applied (seeded `mid` mission with full chain)
**Input data**: `DELETE /missions/{mid}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `204` |
| 2 | Side-channel `SELECT COUNT(*) FROM map_objects WHERE mission_id={mid}` | `count == 0` |
| 3 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (seeded ids)` | `count == 0` |
| 4 | Side-channel `SELECT COUNT(*) FROM annotations WHERE id IN (seeded ids)` | `count == 0` |
| 5 | Side-channel `SELECT COUNT(*) FROM media WHERE id IN (seeded ids)` | `count == 0` |
| 6 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE mission_id={mid}` | `count == 0` |
| 7 | Side-channel `SELECT COUNT(*) FROM missions WHERE id={mid}` | `count == 0` |
| 8 | Compare per-table counts against `expected_results/cascade_F3_walk.json` | `json_diff` matches all expected fields |
**Expected outcome**: results_report.md AC-3 row 3.1.
**Max execution time**: 10s.
---
### FT-P-13: Waypoint list ordered by OrderNum
**Summary**: Verifies `GET /missions/{id}/waypoints` returns waypoints sorted by `OrderNum` ASC, regardless of insert order.
**Traces to**: AC-4.3
**Category**: Waypoint CRUD
**Preconditions**:
- `seed_5_waypoints_unordered` under one mission
**Input data**: `GET /missions/{id}/waypoints`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; body parses as JSON array; `body.length == 5`; `[w.OrderNum for w in body] == [1, 2, 3, 4, 5]` |
**Expected outcome**: results_report.md AC-4 row 4.2.
**Max execution time**: 2s.
---
### FT-P-14: Waypoint create echoes geo fields
**Summary**: Verifies `POST /missions/{id}/waypoints` creates a row with the supplied `GeoPoint` and DOES NOT auto-convert lat/lon ↔ mgrs.
**Traces to**: AC-4 (data_parameters.md § 2.3 spec divergence)
**Category**: Waypoint CRUD
**Preconditions**:
- One mission row exists
**Input data**: `POST /missions/{id}/waypoints { GeoPoint:{Lat:50.45, Lon:30.52, Mgrs:null}, WaypointSource:0, WaypointObjective:0, OrderNum:1, Height:120 }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `201`; `body.GeoPoint.Lat == 50.45`; `body.GeoPoint.Lon == 30.52`; `body.GeoPoint.Mgrs == null` (NO auto-conversion today) |
**Expected outcome**: results_report.md AC-4 row 4.3.
**Max execution time**: 2s.
---
### FT-P-15: Waypoint update is full overwrite
**Summary**: Verifies `PUT /missions/{id}/waypoints/{wpId}` overwrites every field even though the DTO looks "partial" (non-nullable enums/numerics).
**Traces to**: AC-4.4
**Category**: Waypoint CRUD
**Preconditions**:
- One waypoint exists with `Height=120`, `OrderNum=1`, `GeoPoint=(Lat:50.45, ...)`
**Input data**: `PUT /missions/{id}/waypoints/{wpId} { GeoPoint:null, WaypointSource:1, WaypointObjective:1, OrderNum:2, Height:0 }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.Height == 0` (overwritten from 120); `body.OrderNum == 2`; `body.GeoPoint == null` |
**Expected outcome**: results_report.md AC-4 row 4.4.
**Max execution time**: 2s.
---
### FT-P-16: Health is 200 anonymous
**Summary**: Verifies `GET /health` requires no auth.
**Traces to**: AC-7.1, AC-7.2
**Category**: Health probe
**Preconditions**: any
**Input data**: `GET /health` (no `Authorization` header)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /health` with no `Authorization` header | `200`; body == `{ "status": "healthy" }` exactly (case-sensitive key) |
**Expected outcome**: results_report.md AC-7 row 7.1.
**Max execution time**: 2s.
---
### FT-P-17: Health is 200 with Postgres stopped (process-liveness only)
**Summary**: Verifies the health probe does NOT ping the DB.
**Traces to**: AC-7.2, AC-7.3
**Category**: Health probe
**Preconditions**: `missions` running; `postgres-test` `docker compose stop postgres-test`
**Input data**: `GET /health`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /health` with PG stopped | `200`; body == `{ "status": "healthy" }` |
**Expected outcome**: results_report.md AC-7 row 7.2.
**Max execution time**: 5s (allow PG-stop time).
---
### FT-P-18: Waypoint cascade delete is scoped to one waypoint
**Summary**: Verifies `DELETE /missions/{mid}/waypoints/{wpId}` deletes only the chain rooted at `wpId`, leaves sibling waypoint chains intact.
**Traces to**: AC-4.5
**Category**: Waypoint Cascade Delete (F4)
**Preconditions**:
- `fixture_cascade_F4` (target waypoint `wp1` with chain; sibling waypoint `wp2` with chain)
**Input data**: `DELETE /missions/{mid}/waypoints/{wp1}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `204` |
| 2 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (wp1 chain)` | `count == 0` |
| 3 | Side-channel `SELECT COUNT(*) FROM annotations WHERE id IN (wp1 chain)` | `count == 0` |
| 4 | Side-channel `SELECT COUNT(*) FROM media WHERE id IN (wp1 chain)` | `count == 0` |
| 5 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp1}` | `count == 0` |
| 6 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp2}` | `count == 1` (sibling intact) |
| 7 | Side-channel counts on `media`, `annotations`, `detection` for `wp2` chain | all `> 0` (sibling intact) |
| 8 | Compare against `cascade_F4_walk.json` | `json_diff` matches |
**Expected outcome**: results_report.md AC-4 row 4.5.
**Max execution time**: 10s.
---
## Negative Scenarios
### FT-N-01: Vehicle name filter is case-sensitive
**Summary**: Verifies `name=br` does NOT match `BR-01` (case sensitivity).
**Traces to**: AC-1.6
**Category**: Vehicle CRUD (negative)
**Preconditions**:
- `seed_3_vehicles_2_default` (only contains `BR-*` names — no `br-*`)
**Input data**: `GET /vehicles?name=br`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.length == 0` |
**Expected outcome**: results_report.md AC-1 row 1.7.
**Max execution time**: 2s.
---
### FT-N-02: GET vehicle 404
**Summary**: Verifies `GET /vehicles/{random}` returns 404 with the documented envelope.
**Traces to**: AC-1.7, AC-8.2
**Category**: Vehicle CRUD (negative)
**Preconditions**: any
**Input data**: `GET /vehicles/{random uuid}`, JWT `FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `404`; body parses to JSON object with EXACTLY keys `statusCode, message` (camelCase by accidental match) |
**Expected outcome**: results_report.md AC-1 row 1.8.
**Max execution time**: 2s.
---
### FT-N-03: Delete vehicle in use returns 409
**Summary**: Verifies `DELETE /vehicles/{id}` returns 409 when at least one mission references the vehicle.
**Traces to**: AC-1.8, AC-8.5
**Category**: Vehicle CRUD (negative)
**Preconditions**:
- Vehicle row exists, ≥1 mission row references it
**Input data**: `DELETE /vehicles/{id}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `DELETE /vehicles/{id}` | `409`; envelope `{ statusCode:409, message:<non-empty> }` |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={id}` | `count == 1` (row NOT deleted) |
**Expected outcome**: results_report.md AC-1 row 1.9.
**Max execution time**: 2s.
---
### FT-N-04: Create mission with non-existent VehicleId returns 400 (today)
**Summary**: Verifies the existing `ArgumentException → 400` divergence (spec wants 404).
**Traces to**: AC-2.2 (carry-forward divergence), AC-8.5
**Category**: Mission CRUD (negative)
**Preconditions**:
- `seed_empty` (no vehicles exist)
**Input data**: `POST /missions { Name:"x", VehicleId:<random uuid>, CreatedDate:null }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `400`; envelope `{ statusCode:400, message:<non-empty> }` |
| 2 | Side-channel `SELECT COUNT(*) FROM missions` | `count == 0` (no row written) |
**Expected outcome**: results_report.md AC-2 row 2.2.
**Note**: this test will FAIL once the spec divergence is closed (status will become 404). When that work lands, update the expected status here.
**Max execution time**: 2s.
---
### FT-N-05: GET mission 404
**Summary**: Verifies `GET /missions/{random}` returns 404.
**Traces to**: AC-2.4, AC-8.2
**Category**: Mission CRUD (negative)
**Preconditions**: any
**Input data**: `GET /missions/{random uuid}`, JWT `FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `404`; envelope `{ statusCode:404, message:<non-empty> }` |
**Expected outcome**: results_report.md AC-2 row 2.6.
**Max execution time**: 2s.
---
### FT-N-06: Cascade delete short-circuits on missing mission (404 before any DELETE)
**Summary**: Verifies that mission existence is checked BEFORE any dependency-table DELETE runs (AC-3.2).
**Traces to**: AC-3.2
**Category**: Cascade delete F3 (negative)
**Preconditions**:
- `fixture_cascade_F3` applied (seeded chain rooted at `mid`); test targets a DIFFERENT random `mid'` not present in the DB
**Input data**: `DELETE /missions/{mid'}` (random uuid)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Enable Postgres `log_statement=all` (test orchestrator sets this on `postgres-test` startup) | |
| 2 | `DELETE /missions/{mid'}` | `404` |
| 3 | Side-channel `SELECT COUNT(*) FROM map_objects` | `count` unchanged from precondition |
| 4 | Side-channel inspect `pg_stat_statements` (or scrape `pg_log` lines logged after the request timestamp) | NO `DELETE FROM map_objects` / `... waypoints` / `... media` / `... annotations` / `... detection` issued by the request connection |
**Expected outcome**: results_report.md AC-3 row 3.2.
**Max execution time**: 5s.
---
### FT-N-07: Waypoint operation against missing mission returns 404
**Summary**: Verifies parent-mission existence check on every waypoint endpoint.
**Traces to**: AC-4.2
**Category**: Waypoint CRUD (negative)
**Preconditions**: any
**Input data**: `GET /missions/{random uuid}/waypoints`, JWT `FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `404`; envelope `{ statusCode:404, message:<non-empty> }` |
**Expected outcome**: results_report.md AC-4 row 4.1.
**Max execution time**: 2s.
---
### FT-N-08: Generic 500 returns redacted body, logs stack
**Summary**: Verifies `Exception` fallthrough — body is the generic message, full stack is in the log.
**Traces to**: AC-8.6, AC-10.3
**Category**: Wire shape (negative)
**Preconditions**:
- Inject a divide-by-zero or similar throw in any handler via a test-only middleware OR force the condition (e.g., AC-2.9 TOCTOU triggers `Npgsql.PostgresException` → 500). For deterministic execution, drop the `vehicles` table mid-test then call `GET /vehicles/{any}`.
**Input data**: as above
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Side-channel `DROP TABLE vehicles CASCADE` | |
| 2 | `GET /vehicles/{any uuid}`, JWT `FL` | `500`; body == `{ "statusCode":500, "message":"Internal server error" }` exactly |
| 3 | `docker logs missions \| grep "Unhandled exception"` | At least one matching log line emitted within `≤ 2s` of the request, containing the exception type name |
**Expected outcome**: results_report.md AC-8 row 8.7 + AC-10 row 10.1.
**Max execution time**: 5s.
---
## Notes
- Tests that depend on the DB side-channel reading `pg_stat_statements` or query logs (FT-N-06) require a one-time test bootstrap to enable those features on `postgres-test`. That bootstrap is part of the docker compose for the test environment (`environment.md` § Docker Environment).
- Every test enforces `Max execution time` via xUnit `[Fact(Timeout = N*1000)]`. Default suite timeout (CI gate) is 15 minutes (per `environment.md`).
+128
View File
@@ -0,0 +1,128 @@
# Test Environment
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target — `missions` service, `Azaion.Missions.*` namespace, `/vehicles` + `/missions` + `/missions/{id}/waypoints` routes. Until B5B8 land, the `missions` service image is built from the existing `Azaion.Flights.csproj` source — tests will be RED until the rename converges. This is the autodev-aligned path: Step 8 (Refactor) closes the gap.
> **Hardware Assessment** section is filled by `phases/hardware-assessment.md` between Phase 3 and Phase 4.
## Overview
**System under test**: the `missions` .NET 10 REST service exposed on `http://missions:8080` inside the test network. Public surface = the HTTP endpoints documented in `_docs/00_problem/input_data/data_parameters.md` § 7.
**Consumer app purpose**: a standalone xUnit test project (`tests/Azaion.Missions.E2E.Tests.csproj`) that exercises the running service through HTTP only. No `Azaion.Missions.*` types are referenced; the consumer never opens a `DataConnection` to the system-under-test's runtime DB except via a side-channel `postgres-test` connection used to (a) seed fixtures and (b) assert DB side-effects (cascade row counts, default-vehicle invariants).
The side-channel DB access is allowed because the AC catalogue (AC-1.2, AC-1.4, AC-3.1, AC-3.3, AC-10.2) explicitly defines DB state as the verifiable observable. It is NEVER used to mutate state under-test that the API would normally own — only to (1) seed fixtures and (2) assert.
## Docker Environment
### Services
| Service | Image / Build | Purpose | Ports (host:container) |
|---------|--------------|---------|-------|
| `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` | — |
| `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.
### Networks
| Network | Services | Purpose |
|---------|----------|---------|
| `e2e-net` | `missions`, `postgres-test`, `e2e-consumer` | Isolated bridge network; no host network access |
### Volumes
| Volume | Mounted to | Purpose |
|--------|-----------|---------|
| `pg-test-data` | `postgres-test:/var/lib/postgresql/data` | Ephemeral; recreated per scenario class (`docker compose down -v` between class boundaries when the test asserts startup behavior) |
| `e2e-results` | `e2e-consumer:/app/results` and host `./e2e-results/` | Output of `report.csv` |
### docker-compose structure
```yaml
# Outline only — not runnable code (the actual scripts/run-tests.sh wires this up)
services:
postgres-test:
image: postgres:16-alpine
environment:
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
missions:
build:
context: .
environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
depends_on:
postgres-test:
condition: service_healthy
e2e-consumer:
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
volumes:
- ./e2e-results:/app/results
```
## 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).
**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`.
### Communication with system under test
| 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>` |
| 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` |
### 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.
## CI/CD Integration
**When to run**: on every push to `dev` (Woodpecker pipeline `.woodpecker/test-arm.yml` and `.woodpecker/test-amd.yml` after the existing `build-arm.yml` job). Currently the repo has only `build-arm.yml` (per O4); the test runner pipeline is a follow-up artifact produced by Step 6 (Implement Tests) — see `scripts/run-tests.sh` (Phase 4).
**Pipeline stage**: post-build, pre-push (the test runner pulls the just-built `azaion/missions:test` tag).
**Gate behavior**: blocking on `dev` branch. Per O4, today's pipeline has no test stage; this gate is added by Step 6 implementation.
**Timeout**: max 15 minutes total wall-clock. Cascade-delete fixtures and the bootstrap-failure scenarios (AC-6.6, AC-6.7) dominate.
## Reporting
**Format**: CSV
**Columns**: `TestId, TestName, Category, Traces, ExecutionTimeMs, Result, ErrorMessage`
**Output path**: `./e2e-results/report.csv`
Categories: `BLACKBOX`, `PERF`, `RES`, `SEC`, `RES_LIM`. `Traces` is a comma-separated list of AC and restriction IDs from `traceability-matrix.md`.
## 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.
@@ -0,0 +1,103 @@
# Performance Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. The thresholds below are the documented expectations from `acceptance_criteria.md` and `architecture.md` § 6 — they reflect what the implemented design *aims* for on the edge devices (Jetson Orin / OrangePI / operator-PC). The test suite asserts these thresholds against the test environment defined in `environment.md` (Docker on developer laptop), with the caveat that production hardware may produce tighter numbers; CI tracks the test-environment numbers as the regression baseline.
> **Test execution mode**: every NFT-PERF-* runs `N` repetitions and computes the documented percentile (P50/P95). Cold-start passes are excluded — 5 warm-up calls precede every measured run.
---
### NFT-PERF-01: Mission cascade-delete latency target
**Summary**: Verifies the documented latency target for the F3 cascade walk against local PostgreSQL on the same device.
**Traces to**: AC-3.6
**Metric**: P50 wall-clock latency for `DELETE /missions/{id}` against a 1-waypoint, no-map_objects, no-media mission.
**Preconditions**:
- `missions` and `postgres-test` colocated on the same Docker network with no inter-host link
- `seed_one_default_vehicle` + 100 minimal missions (each with 1 waypoint, no media/annotations/detection/map_objects rows)
- 5 warm-up `DELETE` calls on missions outside the measured set (to warm Npgsql connection pool + JIT)
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 100 sequential `DELETE /missions/{id_i}` calls (one per seeded mission, 1 ≤ i ≤ 100) | Record per-call wall-clock latency in ms |
| 2 | Compute P50 across the 100 measurements | `median(latencies)` |
**Pass criteria**: `P50 ≤ 50ms`.
**Duration**: ~1030s of test wall-clock (each call <50ms on healthy local PG).
**Note**: P95 is *also* recorded for trend tracking but does NOT block — only P50 ≤ 50ms is the gate.
---
### NFT-PERF-02: Mission cascade-delete latency under full chain
**Summary**: Same as NFT-PERF-01 but with the full F3 chain (map_objects + media + annotations + detection rows present). No documented threshold; this test establishes a baseline that subsequent runs must not regress against by more than 50%.
**Traces to**: AC-3.1, AC-3.6 (related)
**Metric**: P50 wall-clock latency for `DELETE /missions/{id}` against a `fixture_cascade_F3`-shaped mission.
**Preconditions**:
- Same as NFT-PERF-01 but seed 50 missions each with the `fixture_cascade_F3` chain (3 map_objects, 2 waypoints, 2 media, 2 annotations, 2 detection)
- 5 warm-up calls on additional fixtures outside the measured set
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 50 sequential `DELETE /missions/{id_i}` calls | Record per-call wall-clock latency in ms |
| 2 | Compute P50, P95 | medians + 95th percentile |
**Pass criteria**: `P50 ≤ 200ms` (provisional baseline — 4× the minimal-chain target accounts for 3 extra DELETE statements + index updates). On first green run, lock the achieved P50 ± 50% as the regression gate for subsequent runs.
**Duration**: ~3060s of test wall-clock.
---
### NFT-PERF-03: Health endpoint latency
**Summary**: Verifies `GET /health` is the lightweight process-liveness probe.
**Traces to**: AC-7.3
**Metric**: P50 wall-clock latency.
**Preconditions**:
- `missions` running, no special seed required
- 5 warm-up `GET /health` calls
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 100 sequential `GET /health` calls (no `Authorization`) | Record per-call wall-clock latency in ms |
| 2 | Compute P50 | `median(latencies)` |
**Pass criteria**: `P50 ≤ 10ms`.
**Duration**: ~1s of test wall-clock.
---
### NFT-PERF-04: Mission list pagination throughput
**Summary**: No documented threshold for `GET /missions` list path — this test establishes a regression baseline so a future change cannot silently 10× the P95 latency.
**Traces to**: AC-2.3 (latency-related, no AC threshold)
**Metric**: P95 wall-clock latency for `GET /missions?page=1&pageSize=20` against a 1000-mission seed.
**Preconditions**:
- Seed 1000 missions referencing `seed_one_default_vehicle`
- 5 warm-up calls
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 100 sequential `GET /missions?page=1&pageSize=20` calls | Record per-call wall-clock latency in ms |
| 2 | Compute P95 | `percentile(latencies, 95)` |
**Pass criteria**: on first green run, lock the achieved P95 ± 50% as the regression gate. Initial provisional gate: `P95 ≤ 100ms`. If first run exceeds this, raise the gate AND open a follow-up ticket — do NOT silently accept.
**Duration**: ~10s.
---
## Notes
- All NFT-PERF tests run sequentially (no concurrent client) to remove HTTP/1.1 connection-reuse variance from the measurement. Concurrency is exercised separately under NFT-RES (resilience) when needed for race scenarios.
- Per `restrictions.md` H6, container-level resource limits are NOT enforced inside the container today. Tests assume the test host has ≥ 2 CPU cores and ≥ 2 GB free RAM — hardware-assessment will lock this requirement.
- Latencies measured against the test environment (developer laptop / CI runner) WILL diverge from production edge hardware. The CI gate is the regression baseline; the AC-3.6 / AC-7.3 numerical thresholds are documented production targets that the test environment also satisfies because the test environment is faster, not slower (no PG-on-Jetson penalty).
+215
View File
@@ -0,0 +1,215 @@
# Resilience Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Resilience scenarios use the side-channel + container-orchestration tools to inject faults; the system-under-test is treated as a black box.
> **Critical scenarios**: AC-3.3 (cascade NOT transaction-wrapped) and AC-3.4 (orphan-row race) are the highest-impact resilience invariants — they intentionally encode current (sub-optimal) behavior so tests catch any silent change. Several other resilience tests verify documented operational behaviors (idempotent migrator, DB-down crash, JWT secret rotation).
---
### NFT-RES-01: Cascade is NOT transaction-wrapped — partial deletes survive mid-walk failure
**Summary**: Verifies the documented ADR-006 carry-forward — when the cascade walk fails mid-way (e.g., `media` table absent), already-committed deletes remain.
**Traces to**: AC-3.3, AC-3.4 (related), AC-10.2
**Preconditions**:
- `fixture_cascade_F3` applied (chain rooted at `mid`)
- `missions` running
**Fault injection**: side-channel `DROP TABLE media CASCADE;` BEFORE the request — this turns the second sub-step of the cascade walk into a `relation does not exist` failure.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `DROP TABLE media CASCADE` | succeeds |
| 2 | `DELETE /missions/{mid}` (JWT `FL`) | `500`; envelope `{ statusCode:500, message:"Internal server error" }` |
| 3 | Side-channel `SELECT COUNT(*) FROM map_objects WHERE mission_id={mid}` | `count == 0` (work BEFORE the failure point committed — non-zero pre-fault, zero post-fault) |
| 4 | Side-channel `SELECT COUNT(*) FROM missions WHERE id={mid}` | `count == 1` (work AFTER the failure point did NOT run — row remains) |
| 5 | `docker logs missions \| grep "Unhandled exception"` | At least one matching log line containing `relation` and `media` |
**Pass criteria**: `map_objects` count is 0 (deleted before failure) AND `missions` count is 1 (not deleted because failure short-circuited the walk) AND the response is 500 with the redacted body.
**Note**: this test will FAIL once a transaction wrap is added (ADR-006 closure) — at that point ALL deletes will roll back and `map_objects` count will be `>0`. When the transaction wrap lands, update this test.
**Max execution time**: 10s.
---
### NFT-RES-02: Waypoint cascade is NOT transaction-wrapped (same invariant as F3)
**Summary**: Same invariant as NFT-RES-01, scoped to F4 (waypoint cascade).
**Traces to**: AC-4.6, AC-3.3 (same root cause), AC-10.2
**Preconditions**:
- `fixture_cascade_F4` applied (waypoint `wp1` with chain)
**Fault injection**: side-channel `DROP TABLE media CASCADE` BEFORE the request.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `DROP TABLE media CASCADE` | succeeds |
| 2 | `DELETE /missions/{mid}/waypoints/{wp1}` (JWT `FL`) | `500` |
| 3 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (wp1 chain)` | `count == 0` (deleted before failure) |
| 4 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp1}` | `count == 1` (not deleted) |
**Pass criteria**: same shape as NFT-RES-01.
**Max execution time**: 10s.
---
### NFT-RES-03: Idempotent migrator — second startup is a no-op
**Summary**: Verifies the migrator can run twice in a row without `relation already exists` errors.
**Traces to**: AC-6.6, AC-6.4
**Preconditions**:
- `seed_empty` (schema migrated once via first `missions` startup)
**Fault injection**: container restart (NOT volume reset).
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `docker compose restart missions` | container exits cleanly + restarts |
| 2 | Wait for `GET /health` to return `200` | within ≤ 30s |
| 3 | `docker logs missions \| grep -E "(error\|Error\|exception)"` | no NEW error / exception lines after the restart timestamp |
| 4 | Side-channel `\d+ vehicles` | schema unchanged from after the first migrate |
**Pass criteria**: second start completes; no new error log lines; schema unchanged.
**Max execution time**: 60s.
---
### NFT-RES-04: B9 one-shot legacy table drop runs once and is idempotent
**Summary**: Verifies that the post-B9 `DROP TABLE IF EXISTS orthophotos / gps_corrections` block in the migrator is destructive on the FIRST run against a legacy device, and a no-op on subsequent runs.
**Traces to**: AC-6.5, AC-10.5
**Preconditions**:
- `seed_legacy_gps_tables` (schema includes `orthophotos` + `gps_corrections` with 1 row each)
- `missions` NOT yet started for this scenario
**Fault injection**: none — purely observe migrator behavior.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `SELECT to_regclass('orthophotos'), to_regclass('gps_corrections')` | both NON-NULL (legacy tables present) |
| 2 | `docker compose up -d missions` | container starts |
| 3 | Wait for `GET /health` to return `200` | |
| 4 | Side-channel re-query | both NULL (dropped) |
| 5 | `docker compose restart missions` | |
| 6 | Side-channel re-query | both still NULL (idempotent — no error) |
| 7 | `docker logs missions \| grep -i "does not exist"` | NO log line (because of `IF EXISTS`) |
**Pass criteria**: legacy tables absent after first start; subsequent restarts produce no errors and leave them absent.
**Note**: this test only meaningfully runs on a **post-B9 build**. Before B9 lands, the migrator has no DROP block; gate this scenario on a build-time flag or by inspecting the migrator source.
**Max execution time**: 60s.
---
### NFT-RES-05: DB unreachable at startup — process exits non-zero
**Summary**: Verifies AC-6.7 — DB unreachability causes process exit, NOT silent retry-forever.
**Traces to**: AC-6.7
**Preconditions**:
- `missions` NOT running
**Fault injection**: stop `postgres-test` (`docker compose stop postgres-test`) then start `missions`.
**Steps**:
| 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`) |
**Pass criteria**: container exits with non-zero code within 30s; logs contain a recognisable Npgsql error.
**Max execution time**: 60s.
---
### NFT-RES-06: DB missing (database does not exist) — process exits with Npgsql 3D000
**Summary**: Verifies AC-6.8 — when the `azaion` database does not exist, process exits with the documented PostgreSQL error code.
**Traces to**: AC-6.8
**Preconditions**:
- `postgres-test` running with the `azaion` database NOT yet created (use `POSTGRES_DB=postgres` instead, or `DROP DATABASE azaion`)
**Fault injection**: same as preconditions.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `DROP DATABASE IF EXISTS azaion` | |
| 2 | `docker compose up -d missions` | |
| 3 | Poll exit code for ≤ 30s | non-zero |
| 4 | `docker logs missions \| grep "3D000"` | at least one match |
**Pass criteria**: container exits non-zero within 30s; logs contain `3D000`.
**Max execution time**: 60s.
---
### NFT-RES-07: JWT_SECRET rotation invalidates existing tokens
**Summary**: Verifies AC-5.7 — restarting the service with a different `JWT_SECRET` causes previously-valid tokens to fail validation.
**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
**Fault injection**: restart `missions` with `JWT_SECRET=rotated-secret-32-chars-min!!!!!`.
**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` |
**Pass criteria**: `T1` works pre-rotation, fails post-rotation; `T2` works post-rotation.
**Max execution time**: 90s.
---
### NFT-RES-08: TOCTOU on default-vehicle exclusivity (race window)
**Summary**: Verifies AC-1.4 — the clear-then-set is NOT transaction-wrapped, so a concurrent INSERT can leave 2+ defaults.
**Traces to**: AC-1.4 (carry-forward)
**Preconditions**:
- `seed_one_default_vehicle` (default `P1`)
**Fault injection**: a second concurrent client issues `INSERT INTO vehicles (..., is_default=true)` directly to the side-channel DB at the same moment as the API client issues `POST /vehicles { IsDefault:true }`. Synchronisation: the test orchestrator pauses at `pg_advisory_lock(1)` after the service's `UPDATE vehicles SET is_default=FALSE` and BEFORE the service's `INSERT` — implemented via a Postgres function that the test installs and the service path traverses (this requires an instrumented test build; if not available, the test is best-effort by issuing 100 parallel INSERTs).
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Run 100 parallel iterations of `(POST /vehicles { IsDefault:true } + side-channel INSERT (..., is_default=true))` | each iteration completes |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE is_default=true` | count is `≥ 2` in at least one iteration |
**Pass criteria**: at least one iteration produces `default_count ≥ 2`. If 0 iterations produce the race, the test FAILS — either the race window has been closed (good news; rewrite the test to assert `default_count == 1` and update AC-1.4 to remove the race carry-forward), OR the test concurrency primitive is wrong (investigate).
**Note**: this test is intentionally PROBABILISTIC — it asserts the RACE EXISTS, not that the system is broken. A `PASS` here is bad news for the system but means the test is correctly observing the documented behavior.
**Max execution time**: 30s.
---
## Notes
- Tests that drop tables (NFT-RES-01, NFT-RES-02, NFT-RES-08) run in a per-class `IClassFixture<DbResetFixture>` that recreates the schema after each scenario.
- NFT-RES-03 through NFT-RES-07 require container-orchestration via `docker compose` from inside the test runner. The `e2e-consumer` container needs the `docker` CLI + a mounted Docker socket — alternatively, the test runs from the host with `docker compose` available there. Hardware-assessment will lock this preference.
- NFT-RES-08 is intentionally probabilistic and may be flaky on slow runners. CI marks this as `[Trait("Stability","probabilistic")]` and tolerates ≤ 1 failed run per 5; deterministic implementation (advisory lock + instrumented build) is a follow-up.
@@ -0,0 +1,89 @@
# Resource Limit Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target.
> **Note on scope**: per `restrictions.md` H6, container-level resource limits are NOT enforced inside the container today — they are set at the suite level (`_infra/_compose/`) per device type. As a service-level test suite, the resource-limit tests below establish *baseline observations* that downstream suite-level deployment planning can use to size the device-level cgroup limits, plus one upper-bound regression gate so future changes don't silently 10× memory or file-handle usage.
---
### NFT-RES-LIM-01: Steady-state memory ceiling under sustained load
**Summary**: Establishes a steady-state RSS memory ceiling for the `missions` container under realistic load. Future runs must not exceed this ceiling by more than 50%.
**Traces to**: H1, H6, O10
**Preconditions**:
- `missions` running with no host-side memory limit
- `seed_25_missions` + `seed_3_vehicles_2_default`
**Monitoring**:
- `docker stats --no-stream missions` polled every 5s for `MEM USAGE / LIMIT`
- Resident set size (RSS) extracted from the polled samples
**Duration**: 5 minutes of sustained load (mixed `GET /vehicles`, `GET /missions`, `GET /missions/{id}/waypoints` at ~50 RPS from a single concurrent client)
**Pass criteria**:
- P95 RSS over the 5-minute window `≤ 250 MiB` (provisional gate; lock the achieved value ± 50% as the regression gate after the first green run)
- Final RSS at `t=5min` is within ± 20% of P95 RSS (no sustained leak — RSS does not climb monotonically)
**Note**: this is a single-process .NET 10 service with a small in-memory footprint. 250 MiB is a generous initial gate — refine on first measured run.
---
### NFT-RES-LIM-02: Connection pool steady-state under sustained load
**Summary**: Verifies Npgsql connection pool does not grow unbounded under sustained load.
**Traces to**: O10 (one-instance-per-device + no pool tuning)
**Preconditions**:
- Same as NFT-RES-LIM-01
**Monitoring**:
- Side-channel `SELECT count(*) FROM pg_stat_activity WHERE application_name LIKE 'Npgsql%' OR usename = 'postgres'`
- Polled every 5s for 5 minutes
**Duration**: 5 minutes (same load profile as NFT-RES-LIM-01)
**Pass criteria**:
- Connection count stays `≤ 100` throughout the window (Npgsql default `Maximum Pool Size` = 100; any value lower is fine)
- Connection count at `t=5min` is within ± 30% of the steady-state observed in the first minute (no unbounded growth)
---
### NFT-RES-LIM-03: File descriptor steady-state
**Summary**: Verifies file descriptor count does not climb unbounded.
**Traces to**: H6, O10
**Preconditions**:
- Same as NFT-RES-LIM-01
**Monitoring**:
- Inside the `missions` container: `ls /proc/<pid>/fd \| wc -l` polled every 5s for 5 minutes
**Duration**: 5 minutes
**Pass criteria**:
- FD count stays `≤ 1024` (typical default `ulimit -n` ceiling); on a healthy run, count should sit in the low tens
- Count at `t=5min` within ± 30% of count at `t=1min`
---
### NFT-RES-LIM-04: Cold-start memory budget
**Summary**: Verifies the service's cold-start RSS sits within a budget that allows multiple sibling services to coexist on a Jetson Orin (8 GB total RAM, ~6 services targeted).
**Traces to**: H1, H3 (one container per device with siblings)
**Preconditions**:
- `missions` not running
**Monitoring**:
- `docker stats --no-stream missions` 30s after `GET /health` first returns 200
**Duration**: single measurement
**Pass criteria**:
- RSS `≤ 200 MiB` cold (no requests yet handled). If exceeded, open a follow-up ticket — the suite assumes each .NET edge service stays under 200 MiB so 6 services + Postgres + UI fit in 8 GB.
---
## Notes
- All resource-limit tests rely on `docker stats` and `/proc` reads from inside the container. The `e2e-consumer` needs `docker` CLI access (mounted Docker socket) OR the test runs from the host.
- No GPU / temperature / disk-I/O monitoring — this is a pure CRUD service with no model inference, no large file I/O, no specialised hardware (`hardware-assessment.md` will lock this).
- Per H6, resource limits are NOT enforced inside the container; these tests OBSERVE behavior so the suite-level deployment planning can SET the right cgroup limits. A failed test here means "the service is using more than expected" — possibly a leak, possibly a legitimate change. Investigation always required.
- Provisional gates marked above must be locked-in based on first measured numbers. If first measurement exceeds the provisional gate, raise the gate AND open a follow-up ticket — do NOT silently accept.
+166
View File
@@ -0,0 +1,166 @@
# Security Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **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.
---
### NFT-SEC-01: Missing Authorization header → 401
**Summary**: Verifies AC-5.4 — every protected endpoint rejects requests without an `Authorization` header.
**Traces to**: AC-5.4
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /vehicles` with no `Authorization` header | `401` |
| 2 | `GET /missions` with no `Authorization` header | `401` |
| 3 | `GET /missions/{any}/waypoints` with no `Authorization` header | `401` |
| 4 | `POST /vehicles` with no `Authorization` header + valid body | `401` (no row written — verify via side-channel `count` unchanged) |
**Pass criteria**: every protected endpoint returns 401; no DB side-effect.
---
### NFT-SEC-02: Invalid signature → 401
**Summary**: Verifies AC-5.5 — token signed with a different secret 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`) | |
| 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` |
**Pass criteria**: `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).
**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` | |
| 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` |
| 3 | Mint token `T_skew` with `exp = now - 30s` (inside 60s skew); `permissions=FL` | |
| 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)
**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
**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` |
**Pass criteria**: `200` today; will become `401` post-remediation.
---
### NFT-SEC-05: Missing `permissions` claim → 403
**Summary**: Verifies AC-5.8 — valid signature + lifetime is not enough; the `permissions=FL` claim is required.
**Traces to**: AC-5.8, AC-9.1
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with no `permissions` claim, valid otherwise | |
| 2 | `GET /vehicles` | `403` |
**Pass criteria**: `403`.
---
### NFT-SEC-06: Wrong `permissions` claim value → 403
**Summary**: Verifies AC-9.2 — the policy is exact-string match, hardcoded.
**Traces to**: AC-9.2
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with `permissions="ADMIN"`, valid otherwise | |
| 2 | `GET /vehicles` | `403` |
| 3 | Mint token with `permissions="fl"` (lowercase), valid otherwise | |
| 4 | `GET /vehicles` | `403` |
| 5 | Mint token with `permissions="FLight"`, valid otherwise | |
| 6 | `GET /vehicles` | `403` |
**Pass criteria**: `403` for every wrong-value case.
---
### NFT-SEC-07: Health endpoint exempt from auth
**Summary**: Verifies AC-7.1, AC-9.4 (contrast) — `/health` is anonymous.
**Traces to**: AC-7.1, AC-9.4
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /health` with no `Authorization` | `200` |
| 2 | `GET /health` with `Authorization: Bearer <expired token>` | `200` (auth not evaluated) |
**Pass criteria**: `200` in both cases.
---
### NFT-SEC-08: Stack trace not leaked in 500 body
**Summary**: Verifies AC-8.6 + AC-10.3 — internal exception details stay in the log, not the HTTP body.
**Traces to**: AC-8.6, AC-10.3
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Force a `500` (drop `vehicles` table mid-test, then `GET /vehicles/{any}`) | |
| 2 | Inspect response body | `body == { "statusCode":500, "message":"Internal server error" }` exactly; NO key matching `stack`, `stackTrace`, `exception`, `inner`, `trace`; NO file path; NO type name in the body |
| 3 | `docker logs missions \| grep "Unhandled exception"` | At least one matching line; line contains the file path of the throw site OR the exception type name (the log-side info is private to operators) |
**Pass criteria**: response body contains only `statusCode`, `message`; log contains stack info.
---
### NFT-SEC-09: SQL injection guard via parameterised queries
**Summary**: Defensive — verifies linq2db's parameterised query path is in effect for filter strings.
**Traces to**: AC-1.6 (filter), AC-2.3 (filter), defensive
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /vehicles?name='%20OR%20'1'%3D'1` (URL-encoded `' OR '1'='1`) | `200`; `body.length == 0` (no row matches the literal `' OR '1'='1` string against `BR-01` etc.) |
| 2 | `GET /missions?name=%3B%20DROP%20TABLE%20vehicles%3B%20--` (URL-encoded `; DROP TABLE vehicles; --`) | `200`; `body.TotalCount == 0`; side-channel verifies `vehicles` table still exists |
**Pass criteria**: filter inputs are treated as literal strings; no SQL execution; no DDL side-effect.
---
## 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.
- No fuzz testing today (recommended follow-up under a separate refactor cycle).
+93
View File
@@ -0,0 +1,93 @@
# Test Data Management
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Today's API surface is `/aircrafts`/`/flights`; tests will be RED until B5B8 land. Fixture column names in `expected_results/fixture_cascade_*.sql` use post-rename names; pre-rename code path runs the same DDL via the `Azaion.Flights.Database.DatabaseMigrator` against the test DB before the fixture INSERT — the test orchestrator invokes the service container's startup, then runs fixture SQL via the side channel.
## Seed Data Sets
| Data Set | Description | Used by Tests | How Loaded | Cleanup |
|----------|-------------|---------------|-----------|---------|
| `seed_empty` | Schema migrated, no rows in any table | bootstrap, unauth, 404 scenarios | `docker compose down -v && docker compose up -d` then wait for `missions` startup (which runs the migrator) | `down -v` between scenarios |
| `seed_one_default_vehicle` | Schema + 1 row in `vehicles` with `is_default=true` | AC-1.2 (default-clear), AC-1.3 (TOCTOU race), AC-1.4 setDefault, AC-2.1 mission create | side-channel SQL `INSERT INTO vehicles ...` after `seed_empty` | Within scenario class via per-class `IClassFixture<DbResetFixture>` |
| `seed_3_vehicles_2_default` | 3 rows in `vehicles`, 2 with `is_default=true` (illegal under AC-1.2) | AC-1.5 list, AC-1.6 filter (with deterministic ordering) | side-channel SQL | per-class fixture |
| `seed_25_missions` | 25 `missions` rows (5 in January 2026, 20 in February 2026) referencing one vehicle | AC-2.3, AC-2.4, AC-2.5 pagination + date filter | side-channel SQL with deterministic UUIDs | per-class fixture |
| `fixture_cascade_F3` | One mission with full dependency chain: 1 mission → 2 waypoints → 2 media → 2 annotations → 2 detection rows + 3 map_objects | AC-3.1, AC-3.3, AC-3.4, AC-10.2 | `expected_results/fixture_cascade_F3.sql` (referenced from `results_report.md`) — applied via side-channel after `seed_empty` | `down -v` after each scenario in this class |
| `fixture_cascade_F4` | One mission with one waypoint that has 1 media → 1 annotation → 1 detection chain; sibling waypoint with NO chain (must remain after delete) | AC-4.5, AC-4.6 | `expected_results/fixture_cascade_F4.sql` | `down -v` after each scenario in this class |
| `seed_5_waypoints_unordered` | 5 waypoints with `order_num` `[3, 1, 2, 5, 4]` under one mission | AC-4.3 unpaginated ordering | side-channel SQL | per-class fixture |
| `seed_legacy_gps_tables` | Pre-B7 schema: `vehicles`, `missions`, `waypoints` PLUS `orthophotos` and `gps_corrections` populated with 1 row each | AC-3.5 (post-B7 absence), AC-6.5 (one-shot drop), AC-10.5 (legacy device migration) | side-channel SQL `CREATE TABLE` + `INSERT` in a fresh `seed_empty` | `down -v` between scenarios |
## Data Isolation Strategy
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).
- **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
| Input Data File | Source Location | Description | Covers Scenarios |
|-----------------|----------------|-------------|-----------------|
| `data_parameters.md` § 7 (HTTP table) | `_docs/00_problem/input_data/data_parameters.md` | Documentation of every endpoint + DTO shape; the consumer constructs requests from these shapes | every FT-* and NFT-* scenario |
| `fixture_cascade_F3.sql` | `_docs/00_problem/input_data/expected_results/fixture_cascade_F3.sql` | Cascade chain seed for AC-3 | FT-P-12, FT-N-04, NFT-RES-01, NFT-PERF-01 |
| `fixture_cascade_F4.sql` | `_docs/00_problem/input_data/expected_results/fixture_cascade_F4.sql` | Cascade chain seed for AC-4 | FT-P-18, NFT-RES-02 |
| `cascade_F3_walk.json` | `_docs/00_problem/input_data/expected_results/cascade_F3_walk.json` | Per-table delete-count expectations | FT-P-12 |
| `cascade_F4_walk.json` | `_docs/00_problem/input_data/expected_results/cascade_F4_walk.json` | Per-table delete-count expectations | FT-P-18 |
## Expected Results Mapping
| Test Scenario ID | Input Data | Expected Result | Comparison Method | Tolerance | Expected Result Source |
|-----------------|------------|-----------------|-------------------|-----------|----------------------|
| 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-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-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-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 |
| FT-N-05 | `GET /missions/{random}` | `404` | exact | N/A | AC-2 row 2.6 |
| FT-P-12 | `DELETE /missions/{id}` against `fixture_cascade_F3` | `204`; per-table counts == 0 per `cascade_F3_walk.json` | exact + db_query + file_reference | N/A | AC-3 row 3.1 |
| FT-N-06 | `DELETE /missions/{random}` | `404`; no DELETE issued against dependency tables (instrument SQL log) | exact + log_assertion | N/A | AC-3 row 3.2 |
| FT-P-13 | `GET /missions/{id}/waypoints` against `seed_5_waypoints_unordered` | `200`; ordered `OrderNum [1..5]` ASC | exact | N/A | AC-4 row 4.2 |
| FT-N-07 | `GET /missions/{random}/waypoints` | `404` | exact | N/A | AC-4 row 4.1 |
| FT-P-14 | `POST /missions/{id}/waypoints` body per `data_parameters.md` § 2.3 with non-null GeoPoint | `201`; `Lat,Lon` echoed; `Mgrs == null` (today, divergent — see §2.3 note) | exact | N/A | AC-4 row 4.3 |
| FT-P-15 | `PUT /missions/{id}/waypoints/{wpId}` body resetting Height to 0 | `200`; `Height==0` (full overwrite) | exact | N/A | AC-4 row 4.4 |
| FT-P-18 | `DELETE /missions/{id}/waypoints/{wpId}` against `fixture_cascade_F4` | `204`; only target waypoint's chain deleted; sibling chain intact | exact + db_query + file_reference | N/A | AC-4 row 4.5 |
| FT-P-16 | `GET /health` no auth | `200 { "status": "healthy" }` | exact | N/A | AC-7 row 7.1 |
| FT-P-17 | `GET /health` with PG stopped | `200 { "status": "healthy" }` (no DB ping) | exact | N/A | AC-7 row 7.2 |
NFT-* mappings (perf, resilience, security, resource-limit) are inline in the respective test files.
## External Dependency Mocks
| 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 |
| `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 |
## 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 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` |
| `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 |
@@ -0,0 +1,228 @@
# Traceability Matrix
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Tests written for the post-rename API surface — RED-status until B5B8 land. The traceability matrix below treats the documented spec as the source of truth.
## Acceptance Criteria Coverage
### AC-1 — Vehicle CRUD
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-1.1 | Create vehicle | FT-P-01 | Covered |
| 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.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 |
### AC-2 — Mission CRUD
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| 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.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-3 — Mission cascade delete F3 (most critical)
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-3.1 | Cascade walks `map_objects → detection → annotations → media → waypoints → missions` | FT-P-12 | Covered |
| AC-3.2 | Mission missing → 404 BEFORE any cascade DELETE | FT-N-06 | Covered |
| AC-3.3 | Cascade NOT transaction-wrapped → orphans | NFT-RES-01, NFT-RES-02 | Covered |
| AC-3.4 | `relation does not exist` → 500 + log | NFT-RES-01 (uses `media` drop) | Covered |
| AC-3.5 | After B7 cascade does NOT touch `orthophotos` / `gps_corrections` | covered via NFT-RES-04 (post-B9 build asserts tables absent); cascade does not reference them by construction (verified by code-level absence at Step 8) | Partially covered |
| AC-3.6 | <50ms typical (P50) | NFT-PERF-01 | Covered |
| AC-3.7 | autopilot race after step 1 → orphan | NFT-RES-08-style (orphan race, on `map_objects` insert) — design spec'd; probabilistic implementation deferred | NOT COVERED — see Uncovered Items §2 |
### AC-4 — Waypoint CRUD F4
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-4.1 | Routes nested under `/missions/{id}/waypoints[/{wpId}]` | FT-P-13, FT-P-14, FT-P-15, FT-P-18, FT-N-07 (every endpoint exercised) | Covered |
| AC-4.2 | Parent missing → 404 | FT-N-07 | Covered |
| AC-4.3 | GET unpaginated, ordered by `OrderNum` ASC | FT-P-13 | Covered |
| AC-4.4 | PUT is full overwrite | FT-P-15 | Covered |
| AC-4.5 | Scoped cascade (detection → annotations → media → waypoints) | FT-P-18 | Covered |
| AC-4.6 | Same NO-transaction caveat | NFT-RES-02 | Covered |
| AC-4.7 | Require `Policy="FL"` | NFT-SEC-01, NFT-SEC-05 | Covered |
### AC-5 — JWT validation
| 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.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.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-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.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 |
| AC-6.6 | Migrator idempotent | NFT-RES-03 | Covered |
| AC-6.7 | DB unreachable → process exits non-zero | NFT-RES-05 | Covered |
| 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-7 — Health probe
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-7.1 | `GET /health` anonymous | FT-P-16, NFT-SEC-07 | Covered |
| AC-7.2 | `200 { "status": "healthy" }` | FT-P-16, FT-P-17 | Covered |
| AC-7.3 | <10ms typical | NFT-PERF-03 | Covered |
| AC-7.4 | If pipeline down, TCP connect fails (Watchtower restarts) | container-lifecycle behavior outside the service; out-of-scope at the service test level | Out of scope — see Uncovered Items §4 |
### AC-8 — Wire shape
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-8.1 | Entity bodies PascalCase | FT-P-01, FT-P-04 (key-set assertion) | Covered |
| AC-8.2 | Error envelope camelCase by accidental match | FT-N-02, FT-N-05 (key-set assertion includes `statusCode`, `message`) | Covered |
| AC-8.3 | Envelope MUST NOT include `errors` field | FT-N-02 (key-set excludes `errors`), FT-N-05, NFT-SEC-08 | Covered |
| AC-8.4 | KeyNotFoundException → 404 | FT-N-02, FT-N-05, FT-N-07 | Covered |
| AC-8.5 | ArgumentException → 400, InvalidOperationException → 409 | FT-N-04 (400), FT-N-03 (409) | Covered |
| AC-8.6 | 500 body redacted, stack only in log | FT-N-08, NFT-SEC-08 | Covered |
| AC-8.7 | `PaginatedResponse<T>` PascalCase keys | FT-P-08 (key-set assertion) | Covered |
### AC-9 — Authorization
| 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.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 |
### AC-10 — Operational invariants (API-observable subset)
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-10.1 | One container per device | container-orchestration constraint, not API-observable; tracked under H1 + H3 | Out of scope (suite-level) |
| AC-10.2 | RTO ≈ container restart, RPO = device-local backup | suite-level operational concern | Out of scope |
| AC-10.3 | Unhandled 500 logged with stack trace via `LogError` | FT-N-08, NFT-SEC-08 | Covered |
| AC-10.4 | No correlation id, no per-user audit | absence-of-feature; NOT directly testable; documented carry-forward | Documentation only |
| AC-10.5 | B9 DROP block ordered AFTER `gps-denied` migrated | suite-level deploy ordering, NOT enforced by this service | Out of scope (suite-level) |
| AC-10.6 | Cross-service cascade requires sibling tables present | NFT-RES-01 covers the failure mode (table dropped) — passes when failure produces 500 + partial deletes | Covered |
## Restrictions Coverage
### Hardware (H1H6)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| H1 | Edge-device, one container per device | NFT-RES-LIM-01 through NFT-RES-LIM-04 (resource budget aligned with device assumption) | Indirectly covered |
| H2 | Multi-arch (ARM64 + AMD64) | NOT testable at the test-spec level — covered by suite-level CI matrix (`.woodpecker/build-arm.yml` + future `build-amd.yml`) | Out of scope |
| H3 | Vertical scale only | implicit in test environment (single `missions` container) | Implicitly covered |
| H4 | No managed cloud | architectural constraint; not testable | Documentation only |
| H5 | Watchtower + flight-gate | suite-level orchestration | Out of scope |
| H6 | No container-internal resource limits | NFT-RES-LIM-0104 (observe baseline so suite-level cgroups can be sized correctly) | Covered |
### Software (S1S15)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| S1 | C# / .NET 10 | implicit in test environment | Implicitly covered |
| S2 | ASP.NET Core | implicit | Implicitly covered |
| S3S5 | 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 |
| 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 |
| S10 | Layer-organized | code organization; NOT a behavioral test | Out of scope |
| S11 | No automated tests today | this entire spec converts S11 from a constraint into a goal | Resolved by this spec |
| S12 | No migration tool | NFT-RES-03 + NFT-RES-04 (idempotency observed) | Covered |
| S13 | No in-process MQ / event bus | architectural constraint | Out of scope (architecture) |
| S14 | Owned + borrowed tables | covered by FT-P-12, FT-P-18 (cascade walks both owned and borrowed) | Covered |
| S15 | `gps-denied` decoupled | covered indirectly by NFT-RES-04 (legacy tables absent post-B9) + AC-3.5 absence of cascade reference | Covered |
### Environment (E1E10)
| 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 |
| 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 |
### Operational (O1O10)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| O1 | Migrator at every start, idempotent | NFT-RES-03, NFT-RES-04 | Covered |
| O2 | flight-gate prevents restart mid-mission | suite-level orchestration | Out of scope |
| O3 | No version table | covered indirectly by NFT-RES-03 (no version-table query observed) | Implicitly covered |
| O4 | Single Woodpecker job, no test/security stage | this spec adds a test stage as a follow-up artifact | Resolved by this spec |
| O5 | No structured logging | absence-of-feature; NOT testable directly | Documentation only |
| O6 | No correlation id, no audit | absence-of-feature | Documentation only |
| O7 | Health is process-liveness only | FT-P-17 (PG stopped, health still 200) | Covered |
| O8 | Cascade NOT transaction-wrapped | NFT-RES-01, NFT-RES-02 | Covered |
| O9 | Sibling table absent → cascade fails on `relation does not exist` | NFT-RES-01 (uses `media` drop) | Covered |
| O10 | One-instance-per-device → no cluster awareness | architectural constraint | Documentation only |
## Coverage Summary
| Category | Total Items | Covered | Partially / Implicit | Not Covered | Out of Scope / Doc-only | Coverage % (Covered + Partial of in-scope) |
|----------|-----------|---------|--------------------|-------------|------------------------|-------------------------------------------|
| AC-1 Vehicle CRUD | 9 | 8 | 1 (carry-forward) | 0 | 0 | 100% |
| 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-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 O | 10 | 4 | 2 | 0 | 4 | 100% (in-scope) |
| **Total** | 112 | 67 | 11 | 6 | 28 | **93%** 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. |
| 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. |
**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.
## 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.