mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 06:41:07 +00:00
7025f4d075
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.
14 KiB
14 KiB
Acceptance Criteria — Azaion.Missions
Status: derived-from-code (autodev
/documentStep 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 autodevexisting-codeflow'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 (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 | 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 |