mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 12: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.
19 KiB
19 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) 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 |