mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 18:11:07 +00:00
78dea8ebab
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.
134 lines
19 KiB
Markdown
134 lines
19 KiB
Markdown
# Acceptance Criteria — Azaion.Missions
|
||
|
||
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
|
||
> **Source**: every criterion below is grounded in observable code behaviour, configuration, suite spec, or HTTP contract — none are aspirational. Where the spec and code currently disagree (rename / GPS-Denied / wire shape), the criterion captures **today's behaviour** with a forward-looking note pointing at the responsible Jira child (B6 / B7 / etc.) under AZ-EPIC AZ-539.
|
||
> No automated tests exist yet, so today the AC must be verified by inspection. The autodev `existing-code` flow's Phase A Steps 3 → 7 is the planned path to convert these into runnable test cases.
|
||
|
||
---
|
||
|
||
## AC-1 — Vehicle CRUD (F1)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-1.1 | `POST /vehicles` creates a row in `vehicles` and returns the created `Vehicle` (PascalCase JSON today) | Inspect `VehicleService.CreateVehicle`; HTTP `POST /vehicles { Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault }` |
|
||
| AC-1.2 | If `IsDefault == true` on create or update or `SetDefault`, the service runs `UPDATE vehicles SET is_default = FALSE WHERE is_default = TRUE` BEFORE inserting/updating with `IsDefault = true` | `VehicleService.{CreateVehicle, UpdateVehicle, SetDefault}` — clear-then-set pattern |
|
||
| AC-1.3 | "Exactly one default" is **stricter than spec** (B12 decision pending — `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`) | code reflects current behaviour; B12 ticket AZ-551 records the resolution decision |
|
||
| AC-1.4 | The clear-then-set is **NOT** transaction-wrapped → race window can leave 2+ defaults or zero defaults | `VehicleService` — no `db.BeginTransactionAsync`; tracked in `_docs/02_document/components/01_vehicle_catalog/description.md` Caveats #1 |
|
||
| AC-1.5 | `GET /vehicles` returns a plain `List<Vehicle>` (NO pagination, NO total count) ordered by `Name` ASC | `VehicleService.GetVehicles` `OrderBy(a => a.Name)` |
|
||
| AC-1.6 | `GET /vehicles?name=&isDefault=` filters **case-INSENSITIVELY** on `Name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`) and exactly on `IsDefault` | `VehicleService.GetVehicles` `a.Name.ToLower().Contains(query.Name.ToLower())` |
|
||
| AC-1.7 | `GET /vehicles/{id}` returns 404 (`KeyNotFoundException` → `ErrorHandlingMiddleware`) when id absent | `VehicleService.GetVehicle` |
|
||
| AC-1.8 | `DELETE /vehicles/{id}` returns 409 (`InvalidOperationException` → `ErrorHandlingMiddleware`) when any mission references the vehicle | `VehicleService.DeleteVehicle` `IsAny<Mission>` check |
|
||
| AC-1.9 | Every `/vehicles/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `VehiclesController` |
|
||
|
||
## AC-2 — Mission create / read / update (F2)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-2.1 | `POST /missions { Name, VehicleId, CreatedDate? }` creates a row and returns the created `Mission` | `MissionService.CreateMission`; default `CreatedDate = UtcNow` if null |
|
||
| AC-2.2 | `POST /missions` with non-existent `VehicleId` returns `400 Bad Request` (today, via `ArgumentException`) — **spec wants `404`** | `MissionService.CreateMission` existence check; carry-forward divergence |
|
||
| AC-2.3 | `GET /missions?name=&fromDate=&toDate=&page=&pageSize=` returns `PaginatedResponse<Mission>` (the only paginated endpoint in this service), ordered by `CreatedDate` DESC (newest first); `name` filter is **case-INSENSITIVE** (`LOWER(name) LIKE %lower(input)%`) | `MissionService.GetMissions` `OrderByDescending(f => f.CreatedDate)`; `f.Name.ToLower().Contains(query.Name.ToLower())`; default `page=1`, `pageSize=20` |
|
||
| AC-2.4 | `GET /missions/{id}` returns 404 when id absent | `MissionService.GetMission` |
|
||
| AC-2.5 | `PUT /missions/{id}` applies partial update — non-null fields in `UpdateMissionRequest` overwrite, null fields are preserved | `MissionService.UpdateMission` |
|
||
| AC-2.6 | LinqToDB does NOT eager-load `[Association]` — `Mission.Vehicle` and `Mission.Waypoints` serialize as `null` / `[]` on the wire | `Database/Entities/Mission.cs`; verified observation |
|
||
| AC-2.7 | Every `/missions/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` |
|
||
| AC-2.8 | TOCTOU on `VehicleId` deletion between existence check and insert is **partly mitigated by DB-level FK** — `missions.vehicle_id REFERENCES vehicles(id)` causes PostgreSQL to reject the insert with error code `23503` if the parent was deleted between check and insert. Surface today: `Npgsql PostgresException` (code `23503`) → `ErrorHandlingMiddleware` fallthrough → 500 (UX gap — spec wants 400). Mitigation in app code (wrap check + insert in a transaction OR map `23503` to 400) is carry-forward — tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats | `MissionService.CreateMission`; `Database/DatabaseMigrator.cs` (FK declaration) |
|
||
|
||
## AC-3 — Mission delete with cross-service cascade (F3) — **most critical**
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-3.1 | `DELETE /missions/{id}` walks the cascade in this exact order: `map_objects` → resolve `waypointIds` → resolve `mediaIds` (via `media.waypoint_id`) → resolve `annotationIds` (via `annotations.media_id`) → `detection` (by `annotation_id`) → `annotations` (by id) → `media` (by id) → `waypoints` (by `mission_id`) → `missions` (by id) | `MissionService.DeleteMission` (post-B6/B7) |
|
||
| AC-3.2 | Mission missing → 404 (`KeyNotFoundException`) **before** any cascade DELETE runs | `MissionService.DeleteMission` initial existence check |
|
||
| AC-3.3 | Cascade is **NOT** transaction-wrapped today (ADR-006); partial failure leaves orphan rows in any sub-table | `MissionService.DeleteMission`; no `db.BeginTransactionAsync` |
|
||
| AC-3.4 | `relation does not exist` for any of `media` / `annotations` / `detection` → 500 with `LogError`; this is an abnormal deployment (some sibling service hasn't migrated) | `Middleware/ErrorHandlingMiddleware.cs` fallthrough |
|
||
| AC-3.5 | After B7 the cascade does NOT touch `orthophotos` or `gps_corrections` — `gps-denied` owns those tables and lifecycle | post-B7 spec; `_docs/02_document/architecture.md` ADR-007 |
|
||
| AC-3.6 | End-to-end latency target: <50ms typical against local PostgreSQL on the same device (4–7 sequential round-trips) | `_docs/02_document/architecture.md` § 6 |
|
||
| AC-3.7 | `autopilot` racing the delete by inserting a `map_object` AFTER step 1 reads zero rows leaves one orphan; small race window in single-operator workflow | `_docs/02_document/system-flows.md` F3 error-scenario table |
|
||
|
||
## AC-4 — Waypoint create / read / update / delete (F4)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-4.1 | All routes are nested: `GET/POST/PUT/DELETE /missions/{missionId}/waypoints[/{wpId}]` | `MissionsController` route attributes |
|
||
| AC-4.2 | **Create**: parent mission missing → 404 (`KeyNotFoundException("Mission not found")`) via an explicit `db.Missions.AnyAsync(m => m.Id == missionId)` check before insert. **Update / Delete**: the check is collapsed into a single composite `WHERE w.MissionId == missionId AND w.Id == waypointId` predicate; if no row matches (parent missing OR child missing OR mismatched parent/child pair) → 404 with the same `Waypoint not found` message. The two error cases (parent vs child) are NOT distinguishable from the response | `WaypointService.{CreateWaypoint, UpdateWaypoint, DeleteWaypoint}` |
|
||
| AC-4.3 | `GET /missions/{id}/waypoints` is **unpaginated**, ordered by `OrderNum` ASC (matches spec endpoint 6) | `WaypointService.GetWaypoints` `OrderBy(w => w.OrderNum)` |
|
||
| AC-4.4 | `PUT /missions/{id}/waypoints/{wpId}` is a **full overwrite** of every field even though the request DTO looks "partial-shaped" — non-nullable enums/numerics in `UpdateWaypointRequest` mean every field gets replaced (inconsistent with vehicle's nullable partial-update pattern) | `Services/WaypointService.cs` `UpdateWaypoint` + `DTOs/UpdateWaypointRequest.cs` |
|
||
| AC-4.5 | `DELETE /missions/{id}/waypoints/{wpId}` walks the same cascade as F3, scoped to one waypoint (`detection` → `annotations` → `media` → `waypoints`) | `WaypointService.DeleteWaypoint` |
|
||
| AC-4.6 | Same NO-transaction caveat as AC-3.3 applies to waypoint delete | `WaypointService.DeleteWaypoint` |
|
||
| AC-4.7 | Every waypoint route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` |
|
||
|
||
## AC-5 — JWT bearer validation (F5)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-5.1 | Algorithm: **ECDSA-SHA256** asymmetric signature validation against public keys retrieved from `admin`'s JWKS. `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` is pinned — defends against HS256-confusion (an attacker who learns the JWKS public key cannot forge tokens with `alg: HS256` using that key as the HMAC secret) | `Auth/JwtExtensions.cs` `TokenValidationParameters.ValidAlgorithms` |
|
||
| AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromSeconds(30)` (tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting) | `Auth/JwtExtensions.cs` `ClockSkew = TimeSpan.FromSeconds(30)` |
|
||
| AC-5.3 | `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`; `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. The CMMC L2 row 3 finding is structurally fixed in this service's code; the suite-level docs may still describe the legacy "iss/aud disabled" model and have a separate sync task pending | `Auth/JwtExtensions.cs` |
|
||
| AC-5.4 | Missing `Authorization` header on a `[Authorize]` route → 401 | `JwtBearerHandler` |
|
||
| AC-5.5 | Invalid signature → 401 (ECDSA verify fails against every cached public key whose `kid` matches the token header) | `Auth/JwtExtensions.cs` `IssuerSigningKeyResolver` + `JwtBearerHandler` |
|
||
| AC-5.6 | Expired token (with 30s skew applied) → 401 | `ValidateLifetime = true` |
|
||
| AC-5.7 | Token's `kid` not in cached JWKS → 401. JWKS rotation publishes a new `kid`; the cached manager refreshes on the default schedule (matches admin's `Cache-Control: public, max-age=3600`). **No coordinated redeploy** is needed for rotation | `ConfigurationManager<JsonWebKeySet>` refresh |
|
||
| AC-5.8 | Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`05_identity/description.md`) |
|
||
| AC-5.9 | Request-path validation does NOT call `admin`; only the first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `JWT_JWKS_URL` (which must be HTTPS — `HttpDocumentRetriever { RequireHttps = true }`). Once cached, the manager refreshes on its default schedule. `admin` outage AFTER the JWKS has been cached does NOT take this service down until cache + tokens expire; `admin` outage AT the time of the first JWKS fetch causes the first protected request to fail 500 | `Auth/JwtExtensions.cs` `ConfigurationManager<JsonWebKeySet>` |
|
||
| AC-5.10 | Token header with `alg ∉ [EcdsaSha256]` (e.g. forged `alg: HS256`, or genuine but unsupported `alg: RS256`) → 401 — algorithm pin defense | `Auth/JwtExtensions.cs` `ValidAlgorithms` |
|
||
| AC-5.11 | `iss` claim ≠ resolved `JWT_ISSUER` → 401 | `Auth/JwtExtensions.cs` `ValidateIssuer` + `ValidIssuer` |
|
||
| AC-5.12 | `aud` claim ≠ resolved `JWT_AUDIENCE` → 401 | `Auth/JwtExtensions.cs` `ValidateAudience` + `ValidAudience` |
|
||
|
||
## AC-6 — Service startup + schema migration (F6)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-6.1 | `Program.cs` resolves **four** required configuration values via `Infrastructure/ConfigurationResolver.cs` → `ResolveRequiredOrThrow`: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`. Resolution order per key is env-var-first, then `IConfiguration` config key (`Database:Url` / `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl`), else THROW `InvalidOperationException` at startup. **No hardcoded development fallbacks** — ADR-005's "dev fallback secret" branch is obsolete; only the Swagger-unconditional branch remains | `Program.cs`, `Infrastructure/ConfigurationResolver.cs` |
|
||
| AC-6.2 | `Program.cs` calls `AddJwtAuth(issuer, audience, jwksUrl)` (NOT `AddJwtAuth(secret)`). The legacy `JWT_SECRET` env var / config key is no longer consulted anywhere in the codebase. JWKS is fetched lazily on the first protected request via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever { RequireHttps = true }` | `Program.cs`, `Auth/JwtExtensions.cs` |
|
||
| AC-6.3 | `DatabaseMigrator.Migrate` runs ONCE at startup, INSIDE a single startup scope (not per-request) | `Program.cs` `using var scope = app.Services.CreateScope(); ... DatabaseMigrator.Migrate(db)` |
|
||
| AC-6.4 | Migrator runs `CREATE TABLE IF NOT EXISTS` for the 4 owned tables (`vehicles`, `missions`, `waypoints`, `map_objects`) using PostgreSQL `TIMESTAMP` (no timezone) for date columns, with explicit `REFERENCES` for FKs (`missions.vehicle_id → vehicles(id)`, `waypoints.mission_id → missions(id)`, `map_objects.waypoint_id → waypoints(id)`), and `CREATE INDEX IF NOT EXISTS` for 3 indexes | `Database/DatabaseMigrator.cs` |
|
||
| AC-6.5 | Migrator runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` unconditionally (B9 one-shot kept idempotent indefinitely — re-running on a fresh DB is a no-op) | `Database/DatabaseMigrator.cs` |
|
||
| AC-6.6 | Migrator is idempotent — every startup runs the same statements; `IF NOT EXISTS` makes them safe to re-run | `Database/DatabaseMigrator.cs` |
|
||
| AC-6.7 | `postgres-local` unreachable at startup → process exits non-zero; Watchtower restarts the container; `flight-gate` prevents restart mid-mission | `Program.cs` (no DB error swallow); suite arch doc |
|
||
| AC-6.8 | `azaion` database does not exist → process exits with Npgsql `3D000`; database creation is a provisioning concern, NOT this service | suite-level concern |
|
||
| AC-6.9 | After migrator, `ErrorHandlingMiddleware` is registered FIRST in the pipeline — wraps every subsequent middleware exception | `Program.cs` middleware order |
|
||
| AC-6.10 | Service serves on port 8080 inside the container (`EXPOSE 8080`); edge compose maps host `5002:8080` | `Dockerfile`; suite `_infra/_compose/` |
|
||
| AC-6.11 | CORS is gated by `Infrastructure/CorsConfigurationValidator.cs` at startup: in `Production` (case-insensitive on `ASPNETCORE_ENVIRONMENT`) the host THROWS when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`; in non-Production environments the same empty allow-list falls back to permissive (`AllowAnyOrigin/Method/Header`) AND emits a `PermissiveDefaultWarning` startup log. The pre-B11 "all environments permissive" assumption no longer holds | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
|
||
| AC-6.12 | The JWKS HTTPS-only constraint (`HttpDocumentRetriever { RequireHttps = true }`) means a misconfigured `JWT_JWKS_URL = http://...` will pass startup config resolution (any non-empty string is accepted by `ResolveRequiredOrThrow`) but cause the first protected request to fail at JWKS-fetch time → 500. Detected only at runtime, not at startup | `Auth/JwtExtensions.cs` `HttpDocumentRetriever` |
|
||
|
||
## AC-7 — Health probe (F7)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-7.1 | `GET /health` is anonymous (no `[Authorize]`) | `Program.cs` `MapGet("/health")` |
|
||
| AC-7.2 | Returns `200 OK` with body `{ "status": "healthy" }` | `Results.Ok(new { status = "healthy" })` |
|
||
| AC-7.3 | Latency target: <10ms typical (no DB ping today — process-liveness only) | `Program.cs` |
|
||
| AC-7.4 | If pipeline is down, the probe fails at TCP-connect time and Watchtower restarts the container | suite arch doc |
|
||
|
||
## AC-8 — Wire shape (HTTP contract)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-8.1 | Entity / DTO bodies serialize as **PascalCase** today (no `JsonNamingPolicy.CamelCase` configured) — divergent from suite spec (ADR-002 carry-forward) | `Program.cs` (no `JsonSerializerOptions.PropertyNamingPolicy`); `_docs/02_document/architecture.md` ADR-002 |
|
||
| AC-8.2 | Error envelope is camelCase **by accidental match** — middleware writes `new { statusCode, message }` (lowercase property names preserved by System.Text.Json) | `Middleware/ErrorHandlingMiddleware.cs` |
|
||
| AC-8.3 | Error envelope **misses** the spec's `errors: object?` field today | `Middleware/ErrorHandlingMiddleware.cs` |
|
||
| AC-8.4 | The static `ErrorResponse` DTO is **dead on the wire** — middleware writes the anonymous object instead. If `ErrorResponse` were ever used, it would emit PascalCase + the wrong `Errors` shape (`List<string>?` instead of spec's `object?`) | `DTOs/ErrorResponse.cs` |
|
||
| AC-8.5 | `ErrorHandlingMiddleware` mapping: `KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`, fallthrough → 500 (with stack trace logged via `LogError`) | `Middleware/ErrorHandlingMiddleware.cs` |
|
||
| AC-8.6 | 500 response body shows `Internal server error` (generic), NOT the stack trace; the stack trace is logged only | `Middleware/ErrorHandlingMiddleware.cs` |
|
||
| AC-8.7 | `PaginatedResponse<T>` has fields `Items / TotalCount / Page / PageSize` — PascalCase today, divergent from suite spec | `DTOs/PaginatedResponse.cs` |
|
||
|
||
## AC-9 — Authorization (cross-cutting)
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-9.1 | One named policy `"FL"` is registered in `Auth/JwtExtensions.cs`; satisfied by a `permissions` claim **containing** `"FL"` (`AuthorizationPolicyBuilder.RequireClaim("permissions", "FL")` matches when ANY `permissions` claim value equals `"FL"`, so a multi-permission token `permissions: ["FL","SOMETHING_ELSE"]` is accepted) | `Auth/JwtExtensions.cs` `AddPolicy("FL")` |
|
||
| AC-9.2 | The string `"FL"` is hardcoded in feature controllers — a typo silently turns into a permanent 403 (no compile-time check) | `Controllers/{Vehicles,Missions}Controller.cs`; `_docs/02_document/module-layout.md` § Verification Needed #4 |
|
||
| AC-9.3 | The policy NAME `"FL"` retains the legacy "Flight" wording even after the service rename to `missions` — fleet-wide auth change deferred (NOT in this Epic) | `Auth/JwtExtensions.cs`; `../../suite/_docs/00_roles_permissions.md` TODO |
|
||
| AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` — every protected endpoint has the same gate | `Controllers/{Vehicles,Missions}Controller.cs` |
|
||
|
||
## AC-10 — Operational invariants
|
||
|
||
| # | Criterion | Verification |
|
||
|---|-----------|--------------|
|
||
| AC-10.1 | One container instance per device (vertical scale only) | `Dockerfile`; suite arch doc |
|
||
| AC-10.2 | RTO ≈ container restart time (~10s); RPO = device-local backup cadence (suite-level) | suite arch doc |
|
||
| AC-10.3 | Unhandled 500 exceptions are logged with stack trace via `LogError(ex, "Unhandled exception")` | `Middleware/ErrorHandlingMiddleware.cs` |
|
||
| AC-10.4 | No correlation id, no per-user audit log — supporting a production incident requires grep-by-timestamp | `_docs/02_document/architecture.md` § 7 |
|
||
| AC-10.5 | The migrator's `DROP TABLE IF EXISTS orthophotos / gps_corrections` block (B9) MUST NOT run before `gps-denied` has migrated its own copy of those tables on the device — out-of-band ordering: deploy `gps-denied` first | `Database/DatabaseMigrator.cs` post-B9; `_docs/02_document/system-flows.md` F6 |
|
||
| AC-10.6 | The cross-service cascade (`media`, `annotations`, `detection`) requires `annotations` and detection pipeline to have migrated their tables on the same device — abnormal deployment otherwise | `_docs/02_document/components/02_mission_planning/description.md` Caveats #6 |
|