Files
missions/_docs/00_problem/acceptance_criteria.md
T
Oleksandr Bezdieniezhnykh 7025f4d075 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.
2026-05-14 19:48:25 +03:00

129 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) — matches spec endpoint 13 | `VehicleService.GetVehicles` |
| AC-1.6 | `GET /vehicles?name=&isDefault=` filters case-sensitively on `Name` and exactly on `IsDefault` | `VehicleService.GetVehicles` query expression |
| 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) | `MissionService.GetMissions`; 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 produces `Npgsql PostgresException` → 500 (UX gap — spec wants 400) | `MissionService.CreateMission`; tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats |
## 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 (47 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 | Parent mission missing → 404 (`KeyNotFoundException`) | `WaypointService.*` initial existence check |
| 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: HMAC-SHA256 (HS256) with `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | `Auth/JwtExtensions.cs` |
| AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromMinutes(1)` (tighter than .NET's 5-minute default) | `Auth/JwtExtensions.cs` |
| AC-5.3 | `ValidateIssuer = false` and `ValidateAudience = false` — known CMMC L2 finding (suite-tracked under AZ-487 / AZ-494) | `Auth/JwtExtensions.cs`; `_docs/02_document/architecture.md` § 7 |
| AC-5.4 | Missing `Authorization` header → 401 | `JwtBearerHandler` |
| AC-5.5 | Invalid signature → 401 | HMAC verify fails |
| AC-5.6 | Expired token (with 1-min skew applied) → 401 | `ValidateLifetime` |
| AC-5.7 | Token signed with old `JWT_SECRET` (rotation) → 401 across the entire device until coordinated re-deploy | shared-secret model |
| AC-5.8 | Valid signature + lifetime, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`5_identity` description.md) |
| AC-5.9 | The local validator never calls back to `admin`; `admin` outage does NOT take this service down (until issued tokens expire) | `Auth/JwtExtensions.cs` — pure local validation |
## AC-6 — Service startup + schema migration (F6)
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-6.1 | `Program.cs` reads `DATABASE_URL` (env or fallback) → `ConvertPostgresUrl` → Npgsql connection string | `Program.cs` `ConvertPostgresUrl` |
| AC-6.2 | `Program.cs` reads `JWT_SECRET` (env or fallback) → `AddJwtAuth(jwt)` | `Program.cs` `AddJwtAuth` |
| 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`) 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;` (B9 one-shot, post-B9 only) | `Database/DatabaseMigrator.cs` post-B9 |
| 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-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 equal to `"FL"` | `Auth/JwtExtensions.cs` |
| 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 |