mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 10:51:08 +00:00
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:
@@ -0,0 +1,128 @@
|
||||
# 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 (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 |
|
||||
@@ -0,0 +1,241 @@
|
||||
# Input Data Parameters — Azaion.Missions
|
||||
|
||||
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
|
||||
> Schemas below match the actual `Database/Entities/*.cs` LinqToDB mappings and `DTOs/*.cs` request shapes (post-B6 names). Today's source still uses pre-rename names; the doc-vs-code mapping is in `_docs/02_document/04_verification_log.md` § 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. Configuration input (env vars)
|
||||
|
||||
| Variable | Type | Required | Default (dev fallback) | Source order | Format / constraints | Used by |
|
||||
|----------|------|----------|------------------------|--------------|----------------------|---------|
|
||||
| `DATABASE_URL` | string | yes (production) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` | `IConfiguration` → `Environment.GetEnvironmentVariable` → fallback | Either `postgresql://user:pass@host:port/db` (converted via local helper `ConvertPostgresUrl`) OR a raw Npgsql connection string | `Program.cs` (DI registration of `AppDataConnection`) |
|
||||
| `JWT_SECRET` | string | yes (production) | `development-secret-key-min-32-chars!!` | same as above | UTF-8 string, ≥32 chars (`SymmetricSecurityKey` accepts shorter but `JwtBearer` HS256 requires ≥32 bytes) | `Program.cs` `AddJwtAuth` |
|
||||
| `AZAION_REVISION` | string | no | (build-time) | Dockerfile `ARG` baked from `CI_COMMIT_SHA` | git SHA | Dockerfile only; surfaced via `docker inspect` |
|
||||
| `ASPNETCORE_URLS` | string | no | `http://+:8080` | ASP.NET Core convention | URL list | ASP.NET Core host |
|
||||
|
||||
**Important**: ADR-005 carry-forward — neither Swagger UI mounting nor the dev fallbacks for `JWT_SECRET` / `DATABASE_URL` are gated on `IsDevelopment()`. A production deploy without the env vars set will silently boot with the well-known dev secret; tracked at suite level (CMMC L2 row 3, AZ-487 / AZ-494).
|
||||
|
||||
## 2. HTTP request DTOs (post-B6 shapes)
|
||||
|
||||
### 2.1 Vehicle (`/vehicles`)
|
||||
|
||||
```csharp
|
||||
public class CreateVehicleRequest {
|
||||
public VehicleType Type { get; set; } // enum int: Plane=0, Copter=1, UGV=2, GuidedMissile=3
|
||||
public string Model { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public FuelType FuelType { get; set; } // enum int: Electric=0, Gasoline=1, Diesel=2
|
||||
public decimal BatteryCapacity { get; set; }
|
||||
public decimal EngineConsumption { get; set; }
|
||||
public decimal EngineConsumptionIdle { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateVehicleRequest { // all properties nullable -- partial update
|
||||
public VehicleType? Type;
|
||||
public string? Model;
|
||||
public string? Name;
|
||||
public FuelType? FuelType;
|
||||
public decimal? BatteryCapacity;
|
||||
public decimal? EngineConsumption;
|
||||
public decimal? EngineConsumptionIdle;
|
||||
public bool? IsDefault;
|
||||
}
|
||||
|
||||
public class GetVehiclesQuery {
|
||||
public string? Name { get; set; } // case-sensitive contains
|
||||
public bool? IsDefault { get; set; } // exact match
|
||||
}
|
||||
|
||||
public class SetDefaultRequest {
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Validation**: NONE today. No `[Required]`, no `[Range]`, no min-length. Empty `Name`, negative `BatteryCapacity`, out-of-range enum int values are accepted. Carry-forward improvement.
|
||||
|
||||
### 2.2 Mission (`/missions`)
|
||||
|
||||
```csharp
|
||||
public class CreateMissionRequest {
|
||||
public Guid VehicleId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public DateTime? CreatedDate { get; set; } // defaults to UtcNow if null
|
||||
}
|
||||
|
||||
public class UpdateMissionRequest { // partial update
|
||||
public string? Name { get; set; }
|
||||
public Guid? VehicleId { get; set; }
|
||||
}
|
||||
|
||||
public class GetMissionsQuery {
|
||||
public string? Name { get; set; }
|
||||
public DateTime? FromDate { get; set; }
|
||||
public DateTime? ToDate { get; set; }
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
```
|
||||
|
||||
**Validation**: existence check on `VehicleId` (returns 400 today via `ArgumentException`; spec wants 404 — carry-forward divergence). No bounds on `Page` / `PageSize` (negative or huge values accepted by binding).
|
||||
|
||||
### 2.3 Waypoint (`/missions/{id}/waypoints`)
|
||||
|
||||
```csharp
|
||||
public class GeoPoint { // shared value object; all fields nullable
|
||||
public decimal? Lat { get; set; }
|
||||
public decimal? Lon { get; set; }
|
||||
public string? Mgrs { get; set; } // Military Grid Reference System
|
||||
}
|
||||
|
||||
public class CreateWaypointRequest {
|
||||
public GeoPoint? GeoPoint { get; set; } // nullable: all-null is accepted today (no invariant)
|
||||
public WaypointSource WaypointSource { get; set; } // enum int
|
||||
public WaypointObjective WaypointObjective { get; set; } // enum int
|
||||
public int OrderNum { get; set; }
|
||||
public decimal Height { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateWaypointRequest { // identical SHAPE to Create -- non-nullable enums/numerics
|
||||
public GeoPoint? GeoPoint { get; set; }
|
||||
public WaypointSource WaypointSource { get; set; }
|
||||
public WaypointObjective WaypointObjective { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
public decimal Height { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Validation**: NONE. No min-length, no enum range check, no `Lat`/`Lon` bounds, no MGRS format validation. `GeoPoint` may be all-null. **`UpdateWaypoint` is structurally NOT partial** — every field gets overwritten on PUT (inconsistent with vehicle's partial-update pattern).
|
||||
|
||||
**Spec divergence (Geopoint)**: spec stores `Waypoints.GPS` as a single `string GPS` field with `Lat <-> MGRS` auto-conversion (`../../suite/_docs/02_missions.md`, `../../suite/_docs/00_database_schema.md`). Code stores 3 separate columns with NO conversion. Carry-forward.
|
||||
|
||||
## 3. Persisted data — owned tables (post-B7+B9)
|
||||
|
||||
### 3.1 `vehicles` (owned)
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | UUID | NO | primary key |
|
||||
| `type` | INTEGER | NO | `VehicleType` enum int (Plane / Copter / UGV / GuidedMissile) |
|
||||
| `model` | TEXT | NO | |
|
||||
| `name` | TEXT | NO | |
|
||||
| `fuel_type` | INTEGER | NO | `FuelType` enum int |
|
||||
| `battery_capacity` | NUMERIC | NO | |
|
||||
| `engine_consumption` | NUMERIC | NO | |
|
||||
| `engine_consumption_idle` | NUMERIC | NO | |
|
||||
| `is_default` | BOOLEAN | NO | "exactly one default" enforced by `VehicleService` (stricter than spec — B12 decision) |
|
||||
|
||||
### 3.2 `missions` (owned)
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | UUID | NO | primary key |
|
||||
| `created_date` | TIMESTAMPTZ | NO | server-assigned `UtcNow` if not supplied |
|
||||
| `name` | TEXT | NO | |
|
||||
| `vehicle_id` | UUID | NO | logical FK to `vehicles.id`; existence-checked in service, no DB-level FK constraint declared in migrator |
|
||||
|
||||
Index: `ix_missions_vehicle_id` on `vehicle_id`.
|
||||
|
||||
### 3.3 `waypoints` (owned)
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | UUID | NO | primary key |
|
||||
| `mission_id` | UUID | NO | logical FK to `missions.id` |
|
||||
| `lat` | NUMERIC | YES | spec divergence — see § 2.3 |
|
||||
| `lon` | NUMERIC | YES | spec divergence |
|
||||
| `mgrs` | TEXT | YES | spec divergence |
|
||||
| `waypoint_source` | INTEGER | NO | `WaypointSource` enum int |
|
||||
| `waypoint_objective` | INTEGER | NO | `WaypointObjective` enum int |
|
||||
| `order_num` | INTEGER | NO | listing order |
|
||||
| `height` | NUMERIC | NO | metres |
|
||||
|
||||
Index: `ix_waypoints_mission_id` on `mission_id`.
|
||||
|
||||
### 3.4 `map_objects` (owned schema; written by `autopilot`)
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | UUID | NO | primary key |
|
||||
| `mission_id` | UUID | NO | logical FK to `missions.id` |
|
||||
| `h3_index` | TEXT | NO | Uber H3 hex grid cell |
|
||||
| `mgrs` | TEXT | NO | |
|
||||
| `lat` | NUMERIC | YES | |
|
||||
| `lon` | NUMERIC | YES | |
|
||||
| `class_num` | INTEGER | NO | detection class id |
|
||||
| `label` | TEXT | NO | |
|
||||
| `size_width_m` | NUMERIC | NO | |
|
||||
| `size_length_m` | NUMERIC | NO | |
|
||||
| `confidence` | NUMERIC | NO | 0..1 |
|
||||
| `object_status` | INTEGER | NO | `ObjectStatus` enum int |
|
||||
| `first_seen_at` | TIMESTAMPTZ | NO | |
|
||||
| `last_seen_at` | TIMESTAMPTZ | NO | |
|
||||
|
||||
Index: `ix_map_objects_mission_id` on `mission_id`.
|
||||
|
||||
`autopilot` is the writer (per `../../suite/_docs/06_autopilot_design.md`); this service owns the schema and cascade-deletes only.
|
||||
|
||||
## 4. Persisted data — borrowed read-only stubs
|
||||
|
||||
| Table | Schema owner | This service uses for |
|
||||
|-------|--------------|------------------------|
|
||||
| `media` | `annotations` (per `../../suite/_docs/01_annotations.md`) | id resolution + cascade-delete walk on mission/waypoint delete |
|
||||
| `annotations` | `annotations` | id resolution + cascade-delete walk |
|
||||
| `detection` (singular by upstream owner) | Detection pipeline | cascade-delete walk |
|
||||
|
||||
Stub schemas (just enough to query / delete by id):
|
||||
|
||||
```csharp
|
||||
[Table("media")] public class Media { [PrimaryKey, Column("id")] public string Id = ""; [Column("waypoint_id")] public Guid? WaypointId; }
|
||||
[Table("annotations")] public class Annotation { [PrimaryKey, Column("id")] public string Id = ""; [Column("media_id")] public string MediaId = ""; }
|
||||
[Table("detection")] public class Detection { [PrimaryKey, Column("id")] public Guid Id; [Column("annotation_id")] public string AnnotationId = ""; }
|
||||
```
|
||||
|
||||
Migrations for these tables are owned by the respective sibling services. If they have not migrated on a given device, this service's cascade-delete walk fails on `relation does not exist` (abnormal deployment).
|
||||
|
||||
## 5. Removed in B7 (post-B7+B9 schema)
|
||||
|
||||
These tables and entities are **out of this repo**; cleanup happens once on legacy devices via the B9 `DROP TABLE IF EXISTS` block in `DatabaseMigrator`:
|
||||
|
||||
| Table | Pre-B7 owner | Post-B7 owner |
|
||||
|-------|--------------|---------------|
|
||||
| `orthophotos` | this repo (`Orthophoto` entity, 03_gps_denied component) | `gps-denied` service (separate repo) |
|
||||
| `gps_corrections` | this repo (`GpsCorrection` entity, 03_gps_denied component) | `gps-denied` service |
|
||||
|
||||
`gps-denied` references `mission_id` / `waypoint_id` as plain GUIDs in its OWN tables — no runtime coupling, no FK declaration, no cascade by this service.
|
||||
|
||||
## 6. Enum values
|
||||
|
||||
| Enum | Values | Persisted as | Defined in |
|
||||
|------|--------|--------------|------------|
|
||||
| `VehicleType` | `Plane=0`, `Copter=1`, `UGV=2`, `GuidedMissile=3` | INTEGER | `Enums/VehicleType.cs` (post-B6) |
|
||||
| `FuelType` | `Electric=0`, `Gasoline=1`, `Diesel=2` | INTEGER | `Enums/FuelType.cs` |
|
||||
| `WaypointSource` | `Operator=0`, `Mission=1`, ... | INTEGER | `Enums/WaypointSource.cs` |
|
||||
| `WaypointObjective` | `Surveillance=0`, `Strike=1`, ... | INTEGER | `Enums/WaypointObjective.cs` |
|
||||
| `ObjectStatus` | `Active=0`, `Lost=1`, ... | INTEGER | `Enums/ObjectStatus.cs` (used only by `MapObject`) |
|
||||
|
||||
Per `_docs/02_document/modules/enums.md`, integer values are NOT range-validated on input — model binding accepts any int.
|
||||
|
||||
## 7. Inbound data shapes (HTTP)
|
||||
|
||||
| Endpoint | Method | Body / Query | Returns |
|
||||
|----------|--------|--------------|---------|
|
||||
| `/vehicles` | GET | `?name=&isDefault=` | `List<Vehicle>` (PascalCase JSON; not paginated) |
|
||||
| `/vehicles/{id}` | GET | — | `Vehicle` |
|
||||
| `/vehicles` | POST | `CreateVehicleRequest` | `Vehicle` (created) |
|
||||
| `/vehicles/{id}` | PUT | `UpdateVehicleRequest` (partial) | `Vehicle` (updated) |
|
||||
| `/vehicles/{id}/setDefault` | POST | `SetDefaultRequest` | `Vehicle` |
|
||||
| `/vehicles/{id}` | DELETE | — | 204 / 409 if referenced |
|
||||
| `/missions` | GET | `?name=&fromDate=&toDate=&page=&pageSize=` | `PaginatedResponse<Mission>` |
|
||||
| `/missions/{id}` | GET | — | `Mission` |
|
||||
| `/missions` | POST | `CreateMissionRequest` | `Mission` (created) |
|
||||
| `/missions/{id}` | PUT | `UpdateMissionRequest` (partial) | `Mission` (updated) |
|
||||
| `/missions/{id}` | DELETE | — | 204 / 404; runs F3 cascade |
|
||||
| `/missions/{id}/waypoints` | GET | — | `List<Waypoint>` (unpaginated, ordered by `OrderNum`) |
|
||||
| `/missions/{id}/waypoints` | POST | `CreateWaypointRequest` | `Waypoint` (created) |
|
||||
| `/missions/{id}/waypoints/{wpId}` | PUT | `UpdateWaypointRequest` (full overwrite) | `Waypoint` |
|
||||
| `/missions/{id}/waypoints/{wpId}` | DELETE | — | 204; runs F4 scoped cascade |
|
||||
| `/health` | GET | — anonymous | `200 { "status": "healthy" }` |
|
||||
|
||||
All routes except `/health` require JWT bearer with `permissions=FL` claim.
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$comment": "Expected per-table delete counts and cascade order for FT-P-12 (mission cascade delete F3). Used as the file_reference comparison for the cascade walk.",
|
||||
"input_fixture": "fixture_cascade_F3.sql",
|
||||
"trigger": "DELETE /missions/22222222-0000-0000-0000-000000000001",
|
||||
"expected_response": {
|
||||
"status_code": 204,
|
||||
"body_length": 0
|
||||
},
|
||||
"expected_cascade_order": [
|
||||
"SELECT FROM map_objects WHERE mission_id = M1",
|
||||
"DELETE FROM map_objects WHERE mission_id = M1",
|
||||
"SELECT FROM waypoints WHERE mission_id = M1",
|
||||
"SELECT FROM media WHERE waypoint_id IN (WP1, WP2)",
|
||||
"SELECT FROM annotations WHERE media_id IN (ME1, ME2)",
|
||||
"DELETE FROM detection WHERE annotation_id IN (AN1, AN2)",
|
||||
"DELETE FROM annotations WHERE id IN (AN1, AN2)",
|
||||
"DELETE FROM media WHERE id IN (ME1, ME2)",
|
||||
"DELETE FROM waypoints WHERE mission_id = M1",
|
||||
"DELETE FROM missions WHERE id = M1"
|
||||
],
|
||||
"expected_per_table_post_state": {
|
||||
"missions": {
|
||||
"filter": "id = '22222222-0000-0000-0000-000000000001'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"waypoints": {
|
||||
"filter": "mission_id = '22222222-0000-0000-0000-000000000001'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"map_objects": {
|
||||
"filter": "mission_id = '22222222-0000-0000-0000-000000000001'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"media": {
|
||||
"filter": "id IN ('media-fixture-001', 'media-fixture-002')",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"annotations": {
|
||||
"filter": "id IN ('anno-fixture-001', 'anno-fixture-002')",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"detection": {
|
||||
"filter": "annotation_id IN ('anno-fixture-001', 'anno-fixture-002')",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
}
|
||||
},
|
||||
"expected_per_table_pre_state_for_safety_check": {
|
||||
"missions": 1,
|
||||
"waypoints": 2,
|
||||
"map_objects": 3,
|
||||
"media": 2,
|
||||
"annotations": 2,
|
||||
"detection": 2
|
||||
},
|
||||
"expected_total_round_trips": "between 6 and 9 (4 SELECT + 5 DELETE per the documented walk; allow ±1 for collapsed/skipped phases when chains are empty)"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$comment": "Expected per-table delete counts and cascade order for FT-P-18 (waypoint cascade delete F4). Asserts that the SIBLING waypoint chain remains untouched.",
|
||||
"input_fixture": "fixture_cascade_F4.sql",
|
||||
"trigger": "DELETE /missions/22222222-0000-0000-0000-000000000004/waypoints/33333333-0000-0000-0000-00000000F4A1",
|
||||
"expected_response": {
|
||||
"status_code": 204,
|
||||
"body_length": 0
|
||||
},
|
||||
"expected_cascade_order": [
|
||||
"SELECT FROM media WHERE waypoint_id = WP1",
|
||||
"SELECT FROM annotations WHERE media_id = ME1",
|
||||
"DELETE FROM detection WHERE annotation_id = AN1",
|
||||
"DELETE FROM annotations WHERE id = AN1",
|
||||
"DELETE FROM media WHERE id = ME1",
|
||||
"DELETE FROM waypoints WHERE id = WP1"
|
||||
],
|
||||
"expected_per_table_post_state_target_chain": {
|
||||
"waypoints": {
|
||||
"filter": "id = '33333333-0000-0000-0000-00000000F4A1'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"media": {
|
||||
"filter": "id = 'media-F4-target-001'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"annotations": {
|
||||
"filter": "id = 'anno-F4-target-001'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"detection": {
|
||||
"filter": "annotation_id = 'anno-F4-target-001'",
|
||||
"expected_count": 0,
|
||||
"comparison": "exact"
|
||||
}
|
||||
},
|
||||
"expected_per_table_post_state_sibling_chain_must_remain": {
|
||||
"waypoints": {
|
||||
"filter": "id = '33333333-0000-0000-0000-00000000F4B2'",
|
||||
"expected_count": 1,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"media": {
|
||||
"filter": "id = 'media-F4-sibling-002'",
|
||||
"expected_count": 1,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"annotations": {
|
||||
"filter": "id = 'anno-F4-sibling-002'",
|
||||
"expected_count": 1,
|
||||
"comparison": "exact"
|
||||
},
|
||||
"detection": {
|
||||
"filter": "annotation_id = 'anno-F4-sibling-002'",
|
||||
"expected_count": 1,
|
||||
"comparison": "exact"
|
||||
}
|
||||
},
|
||||
"expected_per_table_pre_state_for_safety_check": {
|
||||
"missions": 1,
|
||||
"waypoints": 2,
|
||||
"media": 2,
|
||||
"annotations": 2,
|
||||
"detection": 2
|
||||
},
|
||||
"expected_total_round_trips": "5 to 6 (2 SELECT + 4 DELETE for the target chain; mission row is NOT touched)"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Fixture: full F3 cascade chain rooted at one mission.
|
||||
-- Used by: blackbox-tests.md FT-P-12, FT-N-04 (variant), resilience-tests.md NFT-RES-01, security-tests.md NFT-SEC-08 (variant)
|
||||
-- Naming: post-rename target. Pre-rename code path runs the same DDL via Azaion.Flights.Database.DatabaseMigrator;
|
||||
-- this file ASSUMES the schema is already in place (the missions container's startup runs the migrator).
|
||||
--
|
||||
-- Deterministic UUIDs so tests can assert against known IDs.
|
||||
--
|
||||
-- Chain shape:
|
||||
-- 1 vehicle (V1)
|
||||
-- 1 mission (M1) → references V1
|
||||
-- 2 waypoints (WP1, WP2) → both reference M1
|
||||
-- 2 media rows (ME1 ↔ WP1, ME2 ↔ WP2)
|
||||
-- 2 annotations (AN1 ↔ ME1, AN2 ↔ ME2)
|
||||
-- 2 detection rows (DT1 ↔ AN1, DT2 ↔ AN2)
|
||||
-- 3 map_objects (MO1, MO2, MO3) → all reference M1
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Vehicle (1 row)
|
||||
INSERT INTO vehicles (id, type, model, name, fuel_type, battery_capacity, engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES ('11111111-0000-0000-0000-000000000001', 0, 'Bayraktar', 'BR-test', 1, 0, 5, 1, true);
|
||||
|
||||
-- Mission (1 row) — id M1
|
||||
INSERT INTO missions (id, created_date, name, vehicle_id)
|
||||
VALUES ('22222222-0000-0000-0000-000000000001', '2026-05-14T00:00:00Z', 'cascade-F3-fixture', '11111111-0000-0000-0000-000000000001');
|
||||
|
||||
-- Waypoints (2 rows) — ids WP1, WP2
|
||||
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, waypoint_source, waypoint_objective, order_num, height) VALUES
|
||||
('33333333-0000-0000-0000-000000000001', '22222222-0000-0000-0000-000000000001', 50.45, 30.52, NULL, 0, 0, 1, 100),
|
||||
('33333333-0000-0000-0000-000000000002', '22222222-0000-0000-0000-000000000001', 50.46, 30.53, NULL, 0, 0, 2, 110);
|
||||
|
||||
-- Media (2 rows) — borrowed-table stubs; the test side-channel CREATEs these tables before the test class runs
|
||||
-- IMPORTANT: media/annotations/detection are owned by sibling services in production; in tests, side-channel CREATEs them.
|
||||
INSERT INTO media (id, waypoint_id) VALUES
|
||||
('media-fixture-001', '33333333-0000-0000-0000-000000000001'),
|
||||
('media-fixture-002', '33333333-0000-0000-0000-000000000002');
|
||||
|
||||
-- Annotations (2 rows)
|
||||
INSERT INTO annotations (id, media_id) VALUES
|
||||
('anno-fixture-001', 'media-fixture-001'),
|
||||
('anno-fixture-002', 'media-fixture-002');
|
||||
|
||||
-- Detection (2 rows; uuid PK)
|
||||
INSERT INTO detection (id, annotation_id) VALUES
|
||||
('44444444-0000-0000-0000-000000000001', 'anno-fixture-001'),
|
||||
('44444444-0000-0000-0000-000000000002', 'anno-fixture-002');
|
||||
|
||||
-- Map objects (3 rows; written by autopilot in production)
|
||||
INSERT INTO map_objects (id, mission_id, h3_index, mgrs, lat, lon, class_num, label, size_width_m, size_length_m, confidence, object_status, first_seen_at, last_seen_at) VALUES
|
||||
('55555555-0000-0000-0000-000000000001', '22222222-0000-0000-0000-000000000001', '8a2a107255dffff', '38UPV1234567890', 50.45, 30.52, 1, 'truck', 3.0, 6.0, 0.91, 0, '2026-05-14T00:00:01Z', '2026-05-14T00:00:02Z'),
|
||||
('55555555-0000-0000-0000-000000000002', '22222222-0000-0000-0000-000000000001', '8a2a107255bffff', '38UPV1234567891', 50.46, 30.53, 2, 'armor', 4.0, 8.0, 0.88, 0, '2026-05-14T00:00:03Z', '2026-05-14T00:00:04Z'),
|
||||
('55555555-0000-0000-0000-000000000003', '22222222-0000-0000-0000-000000000001', '8a2a107255affff', '38UPV1234567892', 50.47, 30.54, 1, 'truck', 3.0, 6.0, 0.93, 1, '2026-05-14T00:00:05Z', '2026-05-14T00:00:06Z');
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Fixture: scoped F4 cascade chain rooted at one waypoint, with a sibling waypoint that has its own chain
|
||||
-- (so the test asserts the sibling chain is INTACT after deleting the target waypoint).
|
||||
-- Used by: blackbox-tests.md FT-P-18, resilience-tests.md NFT-RES-02
|
||||
--
|
||||
-- Chain shape:
|
||||
-- 1 vehicle (V1)
|
||||
-- 1 mission (M1) → references V1
|
||||
-- 2 waypoints:
|
||||
-- WP1 (target) → 1 media (ME1) → 1 annotation (AN1) → 1 detection (DT1)
|
||||
-- WP2 (sibling) → 1 media (ME2) → 1 annotation (AN2) → 1 detection (DT2)
|
||||
-- No map_objects (F4 cascade does not touch map_objects per the documented walk).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO vehicles (id, type, model, name, fuel_type, battery_capacity, engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES ('11111111-0000-0000-0000-000000000004', 0, 'Bayraktar', 'BR-F4-test', 1, 0, 5, 1, false);
|
||||
|
||||
INSERT INTO missions (id, created_date, name, vehicle_id)
|
||||
VALUES ('22222222-0000-0000-0000-000000000004', '2026-05-14T00:00:00Z', 'cascade-F4-fixture', '11111111-0000-0000-0000-000000000004');
|
||||
|
||||
-- Waypoints — WP1 is the delete target, WP2 is the sibling that must remain after delete
|
||||
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, waypoint_source, waypoint_objective, order_num, height) VALUES
|
||||
('33333333-0000-0000-0000-00000000F4A1', '22222222-0000-0000-0000-000000000004', 50.45, 30.52, NULL, 0, 0, 1, 100), -- WP1 target
|
||||
('33333333-0000-0000-0000-00000000F4B2', '22222222-0000-0000-0000-000000000004', 50.46, 30.53, NULL, 0, 0, 2, 110); -- WP2 sibling
|
||||
|
||||
-- Media chain for both waypoints
|
||||
INSERT INTO media (id, waypoint_id) VALUES
|
||||
('media-F4-target-001', '33333333-0000-0000-0000-00000000F4A1'),
|
||||
('media-F4-sibling-002', '33333333-0000-0000-0000-00000000F4B2');
|
||||
|
||||
INSERT INTO annotations (id, media_id) VALUES
|
||||
('anno-F4-target-001', 'media-F4-target-001'),
|
||||
('anno-F4-sibling-002', 'media-F4-sibling-002');
|
||||
|
||||
INSERT INTO detection (id, annotation_id) VALUES
|
||||
('44444444-0000-0000-0000-00000000F4D1', 'anno-F4-target-001'),
|
||||
('44444444-0000-0000-0000-00000000F4D2', 'anno-F4-sibling-002');
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,207 @@
|
||||
# Expected Results — Azaion.Missions
|
||||
|
||||
> **Status**: derived-from-spec (autodev `/test-spec` Step 3 prerequisite, 2026-05-14).
|
||||
> **Source**: every row below is grounded in `_docs/00_problem/acceptance_criteria.md` (AC-1…AC-10), `_docs/00_problem/input_data/data_parameters.md` (HTTP shapes), and `_docs/00_problem/restrictions.md`.
|
||||
> **Naming convention**: rows describe the **post-rename target** (`/vehicles`, `/missions`, `Vehicle`, `Mission`, `VehicleType { Plane, Copter, UGV, GuidedMissile }`). Where today's pre-rename code diverges, the row carries a `today:` note. The B-tickets `AZ-544 / AZ-545 / AZ-546 / AZ-547 / AZ-548 / AZ-549 / AZ-550 / AZ-551` are the planned converger; the leftover index is `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`.
|
||||
> **Input shape**: this is an HTTP API service, not a data-processing pipeline. "Input" rows describe HTTP requests (method + path + JWT claim + body / query); reference files only appear when a scenario needs a fixture row (e.g., a pre-existing mission row in the DB).
|
||||
|
||||
---
|
||||
|
||||
## Result Format Legend
|
||||
|
||||
| Result Type | When to Use | Example |
|
||||
|-------------|-------------|---------|
|
||||
| Exact value | Output must match precisely | `status_code: 200`, `body.IsDefault: true` |
|
||||
| Tolerance range | Numeric output with acceptable variance | `latency: ≤ 50ms` |
|
||||
| Threshold | Output must exceed or stay below a limit | `latency < 500ms` |
|
||||
| Pattern match | Output must match a string/regex pattern | `body.message contains "Internal server error"` |
|
||||
| File reference | Complex output compared against a reference file | `match expected_results/cascade_F3_walk.json` |
|
||||
| Schema match | Output structure must conform to a schema | `body matches PaginatedResponse<Mission>` |
|
||||
| Set/count | Output must contain specific items or counts | `body.length == 0`, `body.Items.length ≤ 20` |
|
||||
| DB state | Side effect on persisted rows must hold post-call | `db.vehicles WHERE is_default=true count == 1` |
|
||||
| Log assertion | Side effect on logger must hold post-call | `logger emits "Unhandled exception" with stack trace` |
|
||||
|
||||
## Comparison Methods
|
||||
|
||||
| Method | Description | Tolerance Syntax |
|
||||
|--------|-------------|-----------------|
|
||||
| `exact` | Actual == Expected | N/A |
|
||||
| `numeric_tolerance` | abs(actual - expected) ≤ tolerance | `± <value>` |
|
||||
| `threshold_min` | actual ≥ threshold | `≥ <value>` |
|
||||
| `threshold_max` | actual ≤ threshold | `≤ <value>` |
|
||||
| `regex` | actual matches regex pattern | regex string |
|
||||
| `substring` | actual contains substring | substring |
|
||||
| `json_diff` | structural comparison against reference JSON | diff tolerance per field |
|
||||
| `set_contains` | actual output set contains expected items | subset notation |
|
||||
| `set_equals` | actual output set equals expected exactly | set equality |
|
||||
| `db_query` | result of a `SELECT` against a controlled test DB equals expected | exact / count |
|
||||
| `file_reference` | compare against reference file in `expected_results/` | file path |
|
||||
|
||||
---
|
||||
|
||||
## Input → Expected Result Mapping
|
||||
|
||||
### AC-1 — Vehicle CRUD (`/vehicles`)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 1.1 | `POST /vehicles` body `{ Type:0, Model:"Bayraktar", Name:"BR-01", FuelType:1, BatteryCapacity:0, EngineConsumption:5, EngineConsumptionIdle:1, IsDefault:false }`, JWT `permissions=FL` | Create non-default Plane | `status_code: 201`; body `Vehicle` with `Id` (UUID), `Type:0`, `Name:"BR-01"`, `IsDefault:false` (PascalCase per AC-8.1); `db.vehicles.count == prev+1` | exact (status, body fields), db_query (count) | N/A | N/A |
|
||||
| 1.2 | Same as 1.1 but `IsDefault:true` against a DB containing one prior `vehicles` row with `is_default=true` | Create default — must demote prior default first (AC-1.2) | `status_code: 201`; new row has `IsDefault:true`; the prior default row now has `is_default=false`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true == 1` | exact (status), db_query (count, prior row state) | N/A | N/A |
|
||||
| 1.3 | Same as 1.2 but inject a concurrent `INSERT vehicles (..., is_default=true)` between the service's `UPDATE … SET is_default=FALSE` and its `INSERT` | TOCTOU race window (AC-1.4) | `status_code: 201`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true >= 2` is observable in at least one race interleaving | db_query (count) | N/A | N/A |
|
||||
| 1.4 | `POST /vehicles/{id}/setDefault` body `{ IsDefault:true }` against `id` of a non-default row | Promote existing vehicle to default (AC-1.2) | `status_code: 200`; body `Vehicle` with `IsDefault:true`; previous default has `is_default=false`; default count == 1 | exact (status, body), db_query (count) | N/A | N/A |
|
||||
| 1.5 | `GET /vehicles` no query, JWT `permissions=FL`, DB has 3 rows | List all vehicles (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names | exact (status, length), schema (array, not paginated), exact (case) | N/A | N/A |
|
||||
| 1.6 | `GET /vehicles?name=BR&isDefault=true` against DB with `["BR-01" default, "BR-02" non-default, "MQ-9" default]` | Filter by name substring + is_default (AC-1.6) | `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A |
|
||||
| 1.7 | `GET /vehicles?name=br` against DB with `"BR-01"` only | Case-sensitive name filter (AC-1.6) | `status_code: 200`; `body.length == 0` | exact (status, length) | N/A | N/A |
|
||||
| 1.8 | `GET /vehicles/{id}` with `id` not in DB | Vehicle not found (AC-1.7) | `status_code: 404`; body matches `{ statusCode:404, message: <non-empty string> }` (camelCase by accidental match per AC-8.2) | exact (status), schema (envelope shape), exact (case) | N/A | N/A |
|
||||
| 1.9 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | Vehicle in use → 409 (AC-1.8) | `status_code: 409`; body envelope `{ statusCode:409, message:<non-empty> }`; `db.vehicles WHERE id={id}` still exists (count==1) | exact (status, envelope shape), db_query | N/A | N/A |
|
||||
| 1.10 | `DELETE /vehicles/{id}` against vehicle referenced by 0 missions | Vehicle deletable | `status_code: 204`; `db.vehicles WHERE id={id}` count == 0 | exact (status), db_query | N/A | N/A |
|
||||
| 1.11 | `GET /vehicles` without `Authorization` header | Unauthenticated (AC-1.9, AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
| 1.12 | `GET /vehicles` with JWT having `permissions="OTHER"` | Wrong permission (AC-1.9, AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
|
||||
|
||||
### AC-2 — Mission create / read / update (`/missions`)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 2.1 | `POST /missions` body `{ Name:"Recon-01", VehicleId:<existing>, CreatedDate:null }`, JWT `FL` | Create mission with default created date (AC-2.1) | `status_code: 201`; body `Mission` with server-assigned `Id`, `CreatedDate` set to a UTC timestamp within `now ± 5s`; `Name == "Recon-01"`; `VehicleId` echoes input | exact (status, fields), numeric_tolerance (CreatedDate ± 5s) | ±5s | N/A |
|
||||
| 2.2 | `POST /missions` body `{ Name:"Recon-02", VehicleId:<random uuid>, CreatedDate:null }` | Vehicle not found (AC-2.2) | `status_code: 400` (today via `ArgumentException`; spec wants 404 — divergence carry-forward) | exact (status) | N/A | N/A |
|
||||
| 2.3 | `GET /missions` no query, DB has 25 missions | Default pagination (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20` | schema, exact (counts) | N/A | N/A |
|
||||
| 2.4 | `GET /missions?page=2&pageSize=20` against same 25-row DB | Second page | `body.Page == 2`; `body.PageSize == 20`; `body.Items.length == 5` | exact (counts) | N/A | N/A |
|
||||
| 2.5 | `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z` against DB with 3 January missions and 2 February missions | Date range filter | `body.TotalCount == 3`; `body.Items.length == 3` | exact (counts) | N/A | N/A |
|
||||
| 2.6 | `GET /missions/{id}` with `id` not in DB | Not found (AC-2.4) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A |
|
||||
| 2.7 | `PUT /missions/{id}` body `{ Name:"Recon-01-renamed", VehicleId:null }` against existing mission | Partial update — only Name (AC-2.5) | `status_code: 200`; `body.Name == "Recon-01-renamed"`; `body.VehicleId == <previous>` (preserved) | exact (status, fields) | N/A | N/A |
|
||||
| 2.8 | `GET /missions/{id}` against mission with 2 waypoints | LinqToDB does NOT eager-load (AC-2.6) | `body.Vehicle == null`; `body.Waypoints` is `null` or `[]` (depending on JSON null serialization) | exact (null/empty) | N/A | N/A |
|
||||
| 2.9 | `POST /missions` simulating TOCTOU: vehicle exists at check time, deleted before insert | TOCTOU FK race (AC-2.8) | `status_code: 500`; logger emits `LogError(ex, "Unhandled exception")` with `Npgsql.PostgresException` in the stack | exact (status), log_assertion (substring) | N/A | N/A |
|
||||
| 2.10 | `GET /missions` without `Authorization` | Unauthenticated (AC-2.7, AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
|
||||
### AC-3 — Mission cascade delete (`DELETE /missions/{id}`) — most critical
|
||||
|
||||
Test data fixtures live in `expected_results/fixture_cascade_F3.sql` (seed script that creates one mission with the full dependency chain across `map_objects`, `waypoints`, `media`, `annotations`, `detection`).
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 3.1 | Apply `fixture_cascade_F3.sql`, then `DELETE /missions/{seeded_id}`, JWT `FL` | Full cascade walk (AC-3.1) | `status_code: 204`; rows in `map_objects`, `waypoints`, `media`, `annotations`, `detection`, `missions` matching the seeded chain are all deleted; cascade order is `map_objects → detection → annotations → media → waypoints → missions` (validated via per-statement instrumentation in tests) | exact (status), db_query (each table count == 0 for seeded ids), file_reference (cascade order log) | N/A | `expected_results/cascade_F3_walk.json` |
|
||||
| 3.2 | `DELETE /missions/{id}` with `id` not in DB | Mission not found before any cascade runs (AC-3.2) | `status_code: 404`; NO `DELETE` statement issued against `map_objects`, `waypoints`, etc. (validated via SQL query log instrumentation) | exact (status), log_assertion (no DELETE on dependency tables) | N/A | N/A |
|
||||
| 3.3 | Apply `fixture_cascade_F3.sql`, drop `media` table from test DB, then `DELETE /missions/{id}` | Cascade fails mid-walk on missing dep table (AC-3.4) | `status_code: 500`; logger emits `Unhandled exception` with `relation "media" does not exist`; `db.missions WHERE id={id}` still exists (cascade NOT transaction-wrapped per AC-3.3, partial deletes remain) | exact (status), log_assertion (regex), db_query (target row remains) | N/A | N/A |
|
||||
| 3.4 | Apply `fixture_cascade_F3.sql`, then `DELETE /missions/{id}` while a parallel `INSERT INTO map_objects … mission_id={id}` runs immediately after the service's `SELECT FROM map_objects` step | Orphan-row race (AC-3.7) | `status_code: 204`; `SELECT COUNT(*) FROM map_objects WHERE mission_id={id} >= 1` is observable in at least one race interleaving | db_query | N/A | N/A |
|
||||
| 3.5 | `DELETE /missions/{id}` with `id` of a 1-waypoint mission against local PostgreSQL on the same device, no map_objects/media/annotations/detection rows | Latency target (AC-3.6) | end-to-end latency `≤ 50ms` (P50 across 100 invocations) | threshold_max | ≤ 50ms (P50) | N/A |
|
||||
| 3.6 | After `B7+B9` migration ran, `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` | Tables removed (AC-3.5) | both queries return `NULL` | exact | N/A | N/A |
|
||||
|
||||
### AC-4 — Waypoint CRUD (`/missions/{id}/waypoints`)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 4.1 | `GET /missions/{nonexistent}/waypoints`, JWT `FL` | Parent mission missing (AC-4.2) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A |
|
||||
| 4.2 | `GET /missions/{id}/waypoints` against mission with 5 waypoints having `OrderNum [3, 1, 2, 5, 4]` | Unpaginated, ordered (AC-4.3) | `status_code: 200`; body is JSON array; `body.length == 5`; `[w.OrderNum for w in body] == [1, 2, 3, 4, 5]` | exact (status, length, ordering) | N/A | N/A |
|
||||
| 4.3 | `POST /missions/{id}/waypoints` body `{ GeoPoint:{Lat:50.45, Lon:30.52, Mgrs:null}, WaypointSource:0, WaypointObjective:0, OrderNum:1, Height:120 }` | Create waypoint with lat/lon | `status_code: 201`; body `Waypoint` with server-assigned `Id`; `body.GeoPoint.Lat == 50.45`; `body.Mgrs == null` (no auto-conversion today, divergent from spec — see data_parameters.md § 2.3) | exact (status, fields) | N/A | N/A |
|
||||
| 4.4 | `PUT /missions/{id}/waypoints/{wpId}` body `{ GeoPoint:null, WaypointSource:1, WaypointObjective:1, OrderNum:2, Height:0 }` against waypoint that previously had `Height=120` | Full overwrite (AC-4.4) — every field replaced including `Height: 120 → 0` | `status_code: 200`; `body.Height == 0` (overwritten); `body.OrderNum == 2`; `body.GeoPoint == null` | exact (status, every field) | N/A | N/A |
|
||||
| 4.5 | Apply `fixture_cascade_F4.sql` (waypoint with media→annotations→detection chain), then `DELETE /missions/{mid}/waypoints/{wpId}` | Scoped cascade (AC-4.5) | `status_code: 204`; rows for that waypoint's `detection`, `annotations`, `media`, `waypoints` are all deleted; rows belonging to OTHER waypoints in the same mission are untouched | exact (status), db_query (per table) | N/A | `expected_results/cascade_F4_walk.json` |
|
||||
| 4.6 | `DELETE` as in 4.5 with `media` table dropped | Same NO-transaction caveat as AC-3.3 (AC-4.6) | `status_code: 500`; partial deletes remain | exact (status), db_query | N/A | N/A |
|
||||
| 4.7 | `GET /missions/{id}/waypoints` without `Authorization` | Unauthenticated (AC-4.7) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
|
||||
### AC-5 — JWT bearer validation
|
||||
|
||||
JWT fixtures use `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256, claims include `permissions=FL` unless noted.
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 5.1 | `GET /vehicles` without `Authorization` header | Missing token (AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
| 5.2 | `GET /vehicles` with `Authorization: Bearer <token signed by different secret>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
| 5.3 | `GET /vehicles` with token where `exp` is `now - 120s` (outside 1-min skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
| 5.4 | `GET /vehicles` with token where `exp` is `now - 30s` (inside 1-min skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A |
|
||||
| 5.5 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions` claim absent | Missing claim (AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
|
||||
| 5.6 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions == "ADMIN"` | Wrong claim value | `status_code: 403` | exact (status) | N/A | N/A |
|
||||
| 5.7 | `GET /vehicles` with no `iss` and no `aud` claim, otherwise valid | `ValidateIssuer/ValidateAudience = false` (AC-5.3) | `status_code: 200` | exact (status) | N/A | N/A |
|
||||
| 5.8 | Restart service with rotated `JWT_SECRET`, replay a previously valid token | Cross-rotation invalidation (AC-5.7) | `status_code: 401` | exact (status) | N/A | N/A |
|
||||
|
||||
### AC-6 — Service startup + schema migration
|
||||
|
||||
Bootstrap fixtures use a Postgres container started fresh per scenario.
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 6.1 | Start service with `DATABASE_URL=postgresql://u:p@h:5432/d` (URL form) | URL conversion (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a connection error | log_assertion (no error), exact (health 200) | N/A | N/A |
|
||||
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form) | Raw connection string accepted (AC-6.1) | same as 6.1 | log_assertion, exact | N/A | N/A |
|
||||
| 6.3 | Start service against an empty `azaion` database, inspect schema after startup | Migrator creates 4 owned tables + 3 indexes (AC-6.4) | `SELECT to_regclass(t)` returns non-NULL for each of `vehicles, missions, waypoints, map_objects`; index list contains `ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id` | set_equals (table set), set_contains (index set) | N/A | N/A |
|
||||
| 6.4 | Start service twice in a row against the same DB | Idempotency (AC-6.6) | second startup completes with same exit code as first; no `relation already exists` error in logs | exact (exit code), log_assertion (no error) | N/A | N/A |
|
||||
| 6.5 | Pre-create `orthophotos` and `gps_corrections` tables, then start a post-B9 service | One-shot legacy drop (AC-6.5, AC-10.5) | both tables are absent after startup; `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` both return NULL | exact | N/A | N/A |
|
||||
| 6.6 | Start service with `DATABASE_URL` pointing at unreachable host | DB unreachable (AC-6.7) | process exits with non-zero exit code within `≤ 30s` | exact (non-zero), threshold_max (≤ 30s) | ≤ 30s | N/A |
|
||||
| 6.7 | Start service against a postgres instance where the `azaion` database does NOT exist | DB missing (AC-6.8) | process exits with non-zero exit code; logger emits message containing Npgsql `3D000` | exact (non-zero), log_assertion (substring `3D000`) | N/A | N/A |
|
||||
| 6.8 | Make any handler throw `InvalidOperationException`, observe response | `ErrorHandlingMiddleware` registered FIRST (AC-6.9) | response: `status_code: 409`; envelope is the camelCase `{ statusCode, message }`; logger captured stack | exact (status, envelope), log_assertion | N/A | N/A |
|
||||
| 6.9 | Start service, run `curl localhost:8080` from inside container | Listens on port 8080 (AC-6.10) | TCP connect succeeds; `/health` returns `200` | exact | N/A | N/A |
|
||||
|
||||
### AC-7 — Health probe
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 7.1 | `GET /health` without `Authorization` (AC-7.1) | Anonymous health probe | `status_code: 200`; body equals `{ "status": "healthy" }` exactly (case-sensitive property names) | exact (status, body) | N/A | N/A |
|
||||
| 7.2 | `GET /health` with PostgreSQL stopped | Probe is process-liveness only (AC-7.3) | `status_code: 200`; body equals `{ "status": "healthy" }` (no DB ping today) | exact | N/A | N/A |
|
||||
| 7.3 | `GET /health` measured locally, 100 sequential calls | Latency target (AC-7.3) | P50 latency `≤ 10ms` | threshold_max | ≤ 10ms (P50) | N/A |
|
||||
|
||||
### AC-8 — Wire shape (HTTP contract)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 8.1 | `GET /vehicles/{id}` with valid id, JWT `FL` | Entity body case (AC-8.1) | response body has top-level keys `Id, Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault` (PascalCase, NO `id`/`type`/etc.) | set_equals (key set, case-sensitive) | N/A | N/A |
|
||||
| 8.2 | `GET /missions/{nonexistent}`, JWT `FL` | Error envelope case (AC-8.2) | response body has exactly the keys `statusCode, message` (lowercase `s`/`m`) | set_equals (key set, case-sensitive) | N/A | N/A |
|
||||
| 8.3 | Same as 8.2 | Error envelope MUST NOT include spec's `errors` field today (AC-8.3) | response body MUST NOT contain key `errors` | exact (key absence) | N/A | N/A |
|
||||
| 8.4 | `GET /missions/{nonexistent}` | KeyNotFoundException → 404 (AC-8.5) | `status_code: 404` | exact (status) | N/A | N/A |
|
||||
| 8.5 | `POST /missions` with `VehicleId = <random uuid>` (existence check fails) | ArgumentException → 400 (AC-8.5) | `status_code: 400` | exact (status) | N/A | N/A |
|
||||
| 8.6 | `DELETE /vehicles/{id}` against vehicle in use | InvalidOperationException → 409 (AC-8.5) | `status_code: 409` | exact (status) | N/A | N/A |
|
||||
| 8.7 | Force a generic `Exception` (e.g., divide-by-zero in a handler) | Fallthrough → 500 + body redaction (AC-8.6) | `status_code: 500`; body equals `{ "statusCode":500, "message":"Internal server error" }` exactly; logger captures the stack via `LogError` | exact (status, body), log_assertion | N/A | N/A |
|
||||
| 8.8 | `GET /missions?page=1&pageSize=10` against 5-mission DB | `PaginatedResponse<Mission>` PascalCase (AC-8.7) | response body has top-level keys `Items, TotalCount, Page, PageSize` (PascalCase) | set_equals (key set) | N/A | N/A |
|
||||
|
||||
### AC-9 — Authorization (cross-cutting)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 9.1 | Each protected endpoint (`/vehicles`, `/missions`, `/missions/{id}/waypoints`) called with token having `permissions == "FL"` | Policy "FL" satisfies (AC-9.1, AC-9.4) | each call: `status_code` ∈ `{200, 201, 204}` (no 401/403) | exact (status set) | N/A | N/A |
|
||||
| 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` | Hardcoded string mismatch (AC-9.2) | `status_code: 403` | exact (status) | N/A | N/A |
|
||||
| 9.3 | `GET /health` with NO `Authorization` header | Health is exempt (AC-9.4 contrast) | `status_code: 200` | exact (status) | N/A | N/A |
|
||||
|
||||
### AC-10 — Operational invariants (verifiable observables)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|---|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 10.1 | Force any handler to throw `Exception` | Stack trace logged, NOT returned (AC-10.3, AC-8.6) | logger output contains the type name of the thrown exception AND the file path of the throw site; HTTP body equals `{ "statusCode":500, "message":"Internal server error" }` (no stack in body) | log_assertion (substring), exact (body) | N/A | N/A |
|
||||
| 10.2 | Apply `fixture_cascade_F3.sql`, then `DELETE /missions/{id}` with `media` table dropped | Cascade NOT transaction-wrapped (AC-3.3, AC-10.[8 in restrictions]) | `map_objects` rows for `mission_id` are deleted (work done before failure remains); only post-failure work is missing — `media`/`annotations`/`detection`/`waypoints`/`missions` rows still present | db_query (per table) | N/A | N/A |
|
||||
| 10.3 | After `B9` migrator runs once on a device with legacy `orthophotos` and `gps_corrections` rows | One-shot destructive step (AC-10.5, AC-6.5) | both tables are absent post-startup; second startup leaves DB unchanged (idempotent because `IF EXISTS`) | exact, db_query | N/A | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Expected Result Reference Files
|
||||
|
||||
The reference files below are required for cascade-walk and fixture-seeding scenarios. They live alongside this report under `_docs/00_problem/input_data/expected_results/`.
|
||||
|
||||
| File | Purpose | Used by |
|
||||
|------|---------|---------|
|
||||
| `cascade_F3_walk.json` | Cascade order + per-table delete-count expectations for AC-3.1 | 3.1 |
|
||||
| `cascade_F4_walk.json` | Same for the waypoint-scoped F4 cascade | 4.5 |
|
||||
| `fixture_cascade_F3.sql` | Seed script for AC-3 cascade scenarios (creates 1 mission with full chain across `map_objects`, `waypoints`, `media`, `annotations`, `detection`) | 3.1, 3.3, 3.4, 10.2 |
|
||||
| `fixture_cascade_F4.sql` | Seed script for AC-4 cascade scenarios (single waypoint with media chain) | 4.5, 4.6 |
|
||||
|
||||
These reference files are **not yet produced** in this turn — they are listed here so the test-spec skill (Phase 1) can confirm coverage. Step 5 (Decompose Tests) and Step 6 (Implement Tests) will materialise them as concrete fixtures.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| AC Group | # Test Inputs | Quantifiable Pass/Fail | Notes |
|
||||
|----------|---------------|------------------------|-------|
|
||||
| AC-1 Vehicle CRUD | 12 | 100% | 1 row covers TOCTOU race (1.3) |
|
||||
| AC-2 Mission CRUD | 10 | 100% | 1 row covers TOCTOU race (2.9) |
|
||||
| AC-3 Cascade delete F3 | 6 | 100% | Fixture-seed scenarios + latency P50 |
|
||||
| AC-4 Waypoint CRUD F4 | 7 | 100% | Includes full-overwrite vs partial divergence (4.4) |
|
||||
| AC-5 JWT validation | 8 | 100% | Skew, rotation, missing/invalid claim |
|
||||
| AC-6 Startup + migration | 9 | 100% | Idempotency, B9 legacy drop, bootstrap failure modes |
|
||||
| AC-7 Health probe | 3 | 100% | Anonymous, no DB ping, latency P50 |
|
||||
| AC-8 Wire shape | 8 | 100% | PascalCase + camelCase divergence locked in (today) |
|
||||
| AC-9 Authorization | 3 | 100% | Hardcoded `"FL"` typo coverage |
|
||||
| AC-10 Operational invariants | 3 | 100% | Subset that is API-observable; non-observable rows (RTO/RPO, hardware) tracked under restrictions |
|
||||
|
||||
**Total**: 69 input rows; every row has at least one quantifiable comparison method. Cascade walk + bootstrap rows depend on test fixtures listed above; those will be created by the test implementation step.
|
||||
|
||||
## Open questions (carry-forward)
|
||||
|
||||
1. AC-3.6 latency target `<50ms` is documented for "local PostgreSQL on the same device" — the test environment must mirror this (PostgreSQL container on the same host as the service container, no inter-host network) for the threshold to be meaningful. Decision deferred to test-spec Phase 2 environment design.
|
||||
2. AC-1.3 / AC-2.9 / AC-3.4 race-window scenarios require a controllable concurrency primitive (parallel client, instrumented transaction barrier). Deferred to test-spec Phase 2 environment design.
|
||||
3. AC-5.7 secret-rotation scenario requires service restart between requests; this is "container-restart" semantics in production. Deferred to test-spec Phase 2.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Problem — Azaion.Missions
|
||||
|
||||
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
|
||||
> **Mode**: retrospective synthesis from the verified `_docs/02_document/` set + the canonical suite spec at `../../suite/_docs/02_missions.md`.
|
||||
> **Forward-looking caveat**: same as `solution.md` § header — references to "the system" mean the post-rename, post-GPS-Denied-removal target. Today's source still uses pre-rename names; deltas tracked under Jira AZ-EPIC (AZ-539) children B4–B12.
|
||||
|
||||
---
|
||||
|
||||
## What is this system?
|
||||
|
||||
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment: it is the local source of truth for the operator's vehicle inventory, mission plans, and ordered waypoints, and it is the orchestrator of the cross-service cascade-delete that keeps the rest of the device's edge stack consistent when missions or waypoints are removed.
|
||||
|
||||
It is one of ~6 backend services running side-by-side **on each customer device** (Jetson Orin / OrangePI / operator-PC). All edge services share **one local PostgreSQL** on the device; each migrates only the tables it owns. JWTs are minted by the central `admin` service and validated locally with a shared HMAC secret — `missions` never calls `admin` back.
|
||||
|
||||
## What problem does it solve?
|
||||
|
||||
When a human operator plans, runs, and tears down missions on the edge:
|
||||
|
||||
1. **Inventory** — the operator must register the vehicles they own (Plane / Copter / UGV / GuidedMissile) and pick a default for one-click mission setup.
|
||||
2. **Mission planning** — the operator must define a named mission against a chosen vehicle and lay out an ordered set of waypoints (lat/lon or MGRS, with per-waypoint source / objective / height).
|
||||
3. **Mission lifecycle** — when a mission or waypoint is deleted, every downstream artefact (media uploaded by `annotations`, AI annotations, AI detections, autopilot-emitted `map_objects`) MUST be cleaned up in FK-safe order so the local DB doesn't accumulate orphans.
|
||||
4. **Cross-service cohesion** — sibling services (`autopilot`, `annotations`, detection pipeline, `gps-denied`, `ui`) must be able to read mission/waypoint data without round-trips to the central admin and without their own copies. The shared local DB is that contract.
|
||||
5. **Local trust** — the operator's device may be intermittently offline from the central network. Authn/authz cannot depend on a live `admin` callback; tokens validate locally with a shared secret.
|
||||
|
||||
## Who are the users?
|
||||
|
||||
| Persona | Role | How they interact |
|
||||
|---------|------|-------------------|
|
||||
| **Operator** | Human running missions in the field | Through the React `ui` (REST + JWT). Sole human user of this service today |
|
||||
| **`autopilot`** | Sibling edge service (consumes missions/waypoints, writes `map_objects`) | Reads `missions` / `waypoints` from the shared DB; writes `map_objects` to a table this service owns the schema for and cascade-deletes |
|
||||
| **`annotations`** | Sibling edge service (owns `media` / `annotations` table schemas) | Indirect — `missions` cascade-deletes from `media` / `annotations` when missions/waypoints are removed |
|
||||
| **Detection pipeline** | Sibling edge service (owns the `detection` table schema) | Indirect — same pattern as `annotations` |
|
||||
| **`gps-denied`** *(post-B7)* | Sibling edge service (owns `orthophotos` + `gps_corrections`) | None at runtime — references `mission_id` / `waypoint_id` as plain GUIDs in its own tables; manages its own cleanup |
|
||||
| **`admin`** | Central .NET service (token issuer) | One-way: issues JWTs that this service validates locally. `admin` outages do NOT take this service down (until tokens expire) |
|
||||
|
||||
There are **no application-level admin / superuser roles inside this service** — every protected endpoint is gated by the single `"FL"` permission. The role → permission matrix is suite-level (`../../suite/_docs/00_roles_permissions.md`).
|
||||
|
||||
## How does it work at a high level?
|
||||
|
||||
ASP.NET Core thin controller → service class → linq2db active-record over a per-HTTP-request scoped `AppDataConnection`. No repository abstraction; no in-process message queue or event bus; no background workers.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ui[[Operator UI]] -- "REST + JWT" --> mc[MissionsController / VehiclesController]
|
||||
mc --> svc[MissionService / VehicleService / WaypointService]
|
||||
svc --> ado[AppDataConnection<br/>linq2db ITable<T>]
|
||||
ado --> pg[(postgres-local)]
|
||||
autopilot[[autopilot]] -- "DB read missions/waypoints<br/>DB write map_objects" --> pg
|
||||
svc -. "cascade delete on mission/waypoint delete" .-> pg
|
||||
```
|
||||
|
||||
Seven flows make up the runtime surface (`_docs/02_document/system-flows.md`):
|
||||
|
||||
- **F1** Vehicle CRUD
|
||||
- **F2** Mission create / read / update
|
||||
- **F3** Mission delete + **cross-service cascade** (the most critical flow; not transaction-wrapped today — ADR-006)
|
||||
- **F4** Waypoint create / read / update / delete (delete is a scoped F3)
|
||||
- **F5** JWT bearer validation (cross-cutting; local HS256 only)
|
||||
- **F6** Service startup + idempotent schema migration
|
||||
- **F7** Anonymous `GET /health` probe
|
||||
|
||||
## Cross-cutting contracts owned here
|
||||
|
||||
1. **JWT validation contract** — trust admin-issued tokens via shared HMAC secret (`JWT_SECRET`); reject everything else with `401`/`403`.
|
||||
2. **Mission ownership graph & cascade-delete** — only place in the system that knows `mission → {map_objects, waypoints → media → annotations → detection}`.
|
||||
3. **Suite-standard wire shapes** *(currently divergent — see `_docs/02_document/architecture.md` ADR-002)* — error envelope and `PaginatedResponse<T>` are shared with `annotations`, `admin`, `satellite-provider`.
|
||||
|
||||
## Out of scope for this service
|
||||
|
||||
- **GPS-Denied** (orthophoto upload, live-GPS, GPS correction) — separate `gps-denied` service after B7.
|
||||
- **Token issuance** — `admin` mints tokens; this service only validates.
|
||||
- **Detection / AI** — owned by the detection pipeline; this service only cascade-deletes orphaned rows on mission/waypoint delete.
|
||||
- **Media storage** — `annotations` owns `media` (text PK + waypoint FK); this service only cascade-deletes.
|
||||
- **Multi-instance HA** — exactly one container per device; horizontal scale-out explicitly not supported.
|
||||
|
||||
## Cross-reference index
|
||||
|
||||
| Concern | Where |
|
||||
|---------|-------|
|
||||
| Spec (canonical, post-rename) | `../../suite/_docs/02_missions.md` |
|
||||
| Top-level architecture (envelope, pagination, topology) | `../../suite/_docs/00_top_level_architecture.md` |
|
||||
| Authoritative ER diagram | `../../suite/_docs/00_database_schema.md` |
|
||||
| Roles & `FL` permission origin | `../../suite/_docs/00_roles_permissions.md` |
|
||||
| GPS-Denied (separate service) | `../../suite/_docs/11_gps_denied.md` |
|
||||
| Verified architecture (this repo) | `_docs/02_document/architecture.md` |
|
||||
| Verified system flows (this repo) | `_docs/02_document/system-flows.md` |
|
||||
| Glossary (confirmed-by-user) | `_docs/02_document/glossary.md` |
|
||||
| Verification log (drift mapping) | `_docs/02_document/04_verification_log.md` |
|
||||
| Solution (retrospective) | `_docs/01_solution/solution.md` |
|
||||
| Restrictions (retrospective) | `_docs/00_problem/restrictions.md` |
|
||||
| Acceptance criteria (retrospective) | `_docs/00_problem/acceptance_criteria.md` |
|
||||
| Input data | `_docs/00_problem/input_data/data_parameters.md` |
|
||||
| Security approach | `_docs/00_problem/security_approach.md` |
|
||||
| Rename leftover (Jira index) | `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` |
|
||||
@@ -0,0 +1,83 @@
|
||||
# Restrictions — Azaion.Missions
|
||||
|
||||
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
|
||||
> Each restriction below is grounded in code, configuration, or Dockerfile evidence — none are aspirational. References point to the artefact that establishes the constraint.
|
||||
|
||||
---
|
||||
|
||||
## Hardware restrictions
|
||||
|
||||
| # | Restriction | Evidence |
|
||||
|---|-------------|----------|
|
||||
| H1 | Service runs on operator-owned edge devices (Jetson Orin / OrangePI / operator-PC), one container per device | `Dockerfile` multi-arch; `../../suite/_docs/00_top_level_architecture.md` § Edge Tier |
|
||||
| H2 | Multi-arch container — ARM64 dominant (Jetson / OrangePI), AMD64 supported (operator-PC) | `Dockerfile` `--platform=$BUILDPLATFORM`, `dotnet publish --os linux --arch $arch`; `.woodpecker/build-arm.yml` tag suffix `-arm` |
|
||||
| H3 | Vertical scale only — exactly one instance per device, no horizontal scale-out | `_docs/02_document/architecture.md` § 3 Deployment Model; suite arch doc § Edge Tier |
|
||||
| H4 | No managed cloud — every deployment is on customer-owned hardware | suite arch doc § Edge Tier |
|
||||
| H5 | Watchtower handles container restarts; `flight-gate` prevents container restart mid-mission | suite arch doc § Edge Tier; `_docs/02_document/architecture.md` § 6 Availability |
|
||||
| H6 | Resource limits not enforced inside the container; device-level cgroups / docker compose limits set at suite level | `Dockerfile` (no `--memory` / cpu); suite `_infra/_compose/` |
|
||||
|
||||
## Software restrictions
|
||||
|
||||
| # | Restriction | Evidence |
|
||||
|---|-------------|----------|
|
||||
| S1 | Language: C#; runtime: .NET 10 (`net10.0`) | `Azaion.Flights.csproj` (post-B5: `Azaion.Missions.csproj`) |
|
||||
| S2 | Web framework: ASP.NET Core (`Microsoft.NET.Sdk.Web`) | csproj |
|
||||
| S3 | Data access library: linq2db `6.2.0` | csproj `<PackageReference Include="linq2db" Version="6.2.0" />` |
|
||||
| S4 | Database driver: Npgsql `10.0.2` | csproj |
|
||||
| S5 | Auth library: `Microsoft.AspNetCore.Authentication.JwtBearer` `10.0.5` | csproj |
|
||||
| S6 | Swagger / OpenAPI: Swashbuckle `10.1.5`, mounted unconditionally (NOT gated on `IsDevelopment()`) — ADR-005 carry-forward | csproj + `Program.cs` |
|
||||
| S7 | Database engine: PostgreSQL (no other DB engines supported) | `Program.cs` `UsePostgreSQL`; suite arch doc § Database Topology |
|
||||
| S8 | One csproj, one root namespace (`Azaion.Missions.*` post-B5) — components are logical groupings, not compilation units | csproj; `_docs/02_document/architecture.md` ADR-008 |
|
||||
| S9 | No `src/` directory — project sits at the repo root | repo layout; `_docs/02_document/00_discovery.md` § Repository Layout |
|
||||
| S10 | Layer-organized layout (`Controllers/`, `Services/`, `DTOs/`, `Enums/`, `Auth/`, `Middleware/`, `Database/` at repo root) | repo layout; `_docs/02_document/module-layout.md` |
|
||||
| S11 | No automated tests today (no `tests/` directory; no test sibling project) | repo layout; `_docs/02_document/00_discovery.md` § Test Layout |
|
||||
| S12 | No migration tool — schema bootstrap via raw `CREATE TABLE IF NOT EXISTS` + one-shot B9 `DROP TABLE IF EXISTS` block | `Database/DatabaseMigrator.cs`; ADR-004 |
|
||||
| S13 | No in-process message queue, no event bus, no RPC — components communicate via direct C# calls registered in DI | `_docs/02_document/architecture.md` § 5; `Program.cs` |
|
||||
| S14 | Tables OWNED by this service (post-B7+B9): `vehicles`, `missions`, `waypoints`, `map_objects` (4 owned). 3 borrowed read-only stubs (`media`, `annotations`, `detection`) | `Database/DatabaseMigrator.cs`; `Database/AppDataConnection.cs`; `_docs/02_document/data_model.md` |
|
||||
| S15 | `gps-denied` is decoupled by design — no runtime call in either direction; `gps-denied` references `mission_id` / `waypoint_id` as plain GUIDs in its own tables | ADR-007 |
|
||||
|
||||
## Environment / configuration restrictions
|
||||
|
||||
| # | Restriction | Evidence |
|
||||
|---|-------------|----------|
|
||||
| E1 | Two required env vars at runtime: `DATABASE_URL`, `JWT_SECRET` | `Program.cs` `Environment.GetEnvironmentVariable` |
|
||||
| E2 | `DATABASE_URL` accepts either a `postgresql://user:pass@host:port/db` URL OR a raw Npgsql connection string (local helper `ConvertPostgresUrl`) | `Program.cs` `ConvertPostgresUrl` |
|
||||
| E3 | Hardcoded development fallbacks: `JWT_SECRET=development-secret-key-min-32-chars!!`, `DATABASE_URL=Host=localhost;Database=azaion;Username=postgres;Password=changeme` (NOT gated on `IsDevelopment()`) — ADR-005 carry-forward; production deploys MUST override | `Program.cs` |
|
||||
| E4 | `JWT_SECRET` is shared across `admin` + every backend service on the same edge device — rotation requires a coordinated re-deploy across all of them | `_docs/02_document/components/05_identity/description.md`; suite arch doc |
|
||||
| E5 | Container `EXPOSE 8080`; edge compose maps host port `5002:8080` | `Dockerfile`; suite `_infra/_compose/` |
|
||||
| E6 | Image tag: `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` post-B10 (was `azaion/flights:*-arm` pre-B10) | `.woodpecker/build-arm.yml` (post-B10) |
|
||||
| E7 | Entrypoint: `dotnet Azaion.Missions.dll` post-B5 (was `Azaion.Flights.dll` pre-B5) | `Dockerfile` (post-B5) |
|
||||
| E8 | No environment-specific overrides in `appsettings.*.json` — single config flow via env vars | `Program.cs`; no `appsettings.Production.json` in repo |
|
||||
| E9 | CORS: `AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in **all** environments (assumed safe behind suite reverse proxy) | `Program.cs` |
|
||||
| E10 | TLS termination is the suite reverse proxy's responsibility — container exposes plain HTTP on `:8080` | `Dockerfile`; suite arch doc |
|
||||
|
||||
## Operational restrictions
|
||||
|
||||
| # | Restriction | Evidence |
|
||||
|---|-------------|----------|
|
||||
| O1 | Migrator runs at every process start; idempotent (`IF NOT EXISTS`); B9 adds a one-shot `DROP TABLE IF EXISTS orthophotos / gps_corrections` block for fielded legacy devices | `Database/DatabaseMigrator.cs` |
|
||||
| O2 | `flight-gate` (suite-level) is the ONLY orchestration that prevents restart mid-mission; no Kubernetes | suite arch doc § Edge Tier |
|
||||
| O3 | No version table; the migrator runs every startup | `Database/DatabaseMigrator.cs` |
|
||||
| O4 | Single Woodpecker CI job per repo: docker build + push on `[dev, stage, main]` branches; no test, no security scan, no migration check | `.woodpecker/build-arm.yml` |
|
||||
| O5 | No structured logging (Serilog / Seq) — `LogError(ex, "Unhandled exception")` is the only application-level log | `Middleware/ErrorHandlingMiddleware.cs`; `_docs/02_document/architecture.md` § 7 |
|
||||
| O6 | No correlation ID, no per-request audit trail, no per-user attribution (JWT user-id claim parsed but not consumed) | `Auth/JwtExtensions.cs`; `_docs/02_document/components/05_identity/description.md` |
|
||||
| O7 | Health endpoint: `GET /health` returns `{ status: "healthy" }` with no DB ping (process-liveness only) | `Program.cs` `MapGet("/health")` |
|
||||
| O8 | Cascade-delete is **NOT** transaction-wrapped today (ADR-006) — partial failure leaves orphan rows in `media` / `annotations` / `detection` / `map_objects` | `Services/FlightService.cs` (post-B6: `MissionService.cs`); `Services/WaypointService.cs` |
|
||||
| O9 | Each backend service is responsible for its own table migrations; if `annotations` is absent at deploy time, the cascade-delete walk fails on `relation does not exist` (abnormal edge deployment) | `_docs/02_document/components/02_mission_planning/description.md` Caveats #6 |
|
||||
| O10 | One-instance-per-device constraint means session state, in-memory caches, and rate limits are NOT cluster-aware (none of these are implemented today either) | `Program.cs`; suite arch doc |
|
||||
|
||||
## Out-of-scope (NOT this service's responsibility)
|
||||
|
||||
| Concern | Where it lives | Why it's not here |
|
||||
|---------|----------------|-------------------|
|
||||
| Token issuance (sign / mint) | `admin` (central .NET service) | Local validation only; offline-tolerant edge design |
|
||||
| User CRUD, role assignment | `admin` + `../../suite/_docs/00_roles_permissions.md` | Suite-level concern |
|
||||
| Media storage / upload | `annotations` (sibling edge service) | `annotations` owns the table schema |
|
||||
| AI annotation rules | `annotations` | Schema and behaviour both owned by `annotations` |
|
||||
| Object detection / class definitions | Detection pipeline (sibling edge service) | Pipeline owns the `detection` table |
|
||||
| `map_objects` write path | `autopilot` (sibling edge service) | This service owns the schema + cascade-delete only |
|
||||
| Orthophoto / live-GPS / GPS correction | `gps-denied` (separate service after B7) | ADR-007 |
|
||||
| TLS / HTTPS termination | Suite reverse proxy | `_docs/02_document/architecture.md` § 7 |
|
||||
| Schema rename / column drop / type change | Future migration tool (ADR-004 carry-forward) | Today's `IF NOT EXISTS` migrator can't reshape existing schema; B9's `DROP TABLE IF EXISTS` is the single explicit destructive step |
|
||||
| `iss` / `aud` JWT validation | Suite-level remediation (CMMC L2 row 3, AZ-487 / AZ-494) | Out of this Epic; consistent with shared-secret model today |
|
||||
| camelCase wire-shape migration | Suite-wide cutover (ADR-002 carry-forward) | All-or-nothing; UI + autopilot consume PascalCase today |
|
||||
@@ -0,0 +1,132 @@
|
||||
# Security Approach — Azaion.Missions
|
||||
|
||||
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
|
||||
> All claims below trace to actual code, configuration, or a tracked suite-level finding. Items called out as "currently divergent" are intentional carry-forward — see `_docs/02_document/architecture.md` § 8 ADRs and `00_discovery.md` § Spec ↔ Code Divergences.
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
**Mechanism**: JWT bearer (HS256) with **local validation only** — this service never calls back to the issuing `admin` service.
|
||||
|
||||
**Trust model**: a single shared HMAC secret (`JWT_SECRET`) is provisioned to `admin` (issuer) and to every backend service on each edge device (validators). Rotation requires a coordinated re-deploy across all of them.
|
||||
|
||||
**Validation parameters** (`Auth/JwtExtensions.cs`):
|
||||
|
||||
| Parameter | Value | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Algorithm | HS256 (`SymmetricSecurityKey(UTF-8(JWT_SECRET))`) | Symmetric → asymmetric switch is suite-wide concern, not in this Epic |
|
||||
| `ValidateLifetime` | `true` | Tokens with `exp` in the past are rejected |
|
||||
| `ClockSkew` | `TimeSpan.FromMinutes(1)` | Tighter than .NET's 5-min default |
|
||||
| `ValidateIssuer` | **`false`** | Known CMMC L2 finding (suite-tracked AZ-487/AZ-494); consistent with shared-secret trust |
|
||||
| `ValidateAudience` | **`false`** | Same finding as above |
|
||||
| `ValidateIssuerSigningKey` | `true` | Always required when `ValidateLifetime`/`ValidateIssuer` are set explicitly |
|
||||
|
||||
**Failure outcomes**:
|
||||
|
||||
| Condition | HTTP code |
|
||||
|-----------|-----------|
|
||||
| Missing `Authorization` header | 401 |
|
||||
| Invalid signature | 401 |
|
||||
| Expired token (with 1-min skew) | 401 |
|
||||
| Token signed with old `JWT_SECRET` after rotation | 401 (until coordinated re-deploy + re-login) |
|
||||
| Valid signature + lifetime, but missing `permissions=FL` claim | 403 |
|
||||
|
||||
**`admin` outage**: tokens issued before the outage continue to validate locally. This service does **not** require `admin` to be reachable for any flow. Once issued tokens expire, new logins fail at `admin`'s end (UI concern), but this service stays up.
|
||||
|
||||
## 2. Authorization
|
||||
|
||||
**Single named policy**: `"FL"`. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy is satisfied by a `permissions` claim with value `"FL"`.
|
||||
|
||||
**Role → permission matrix** is suite-level (`../../suite/_docs/00_roles_permissions.md`); this service does NOT enforce roles, only the `FL` permission.
|
||||
|
||||
**No per-method authz**: every protected endpoint has the same gate. There is no notion of "read-only operator" vs "full-access operator" inside this service.
|
||||
|
||||
**Hardcoded policy name carries legacy wording**: the string `"FL"` (originally "Flight") survives the rename to `missions`. Renaming the permission code is a fleet-wide auth change (would invalidate every issued token until new ones are minted) and is **NOT** in this Epic. Tracked as a TODO in `../../suite/_docs/00_roles_permissions.md`.
|
||||
|
||||
**Typo risk**: the `"FL"` string is repeated in feature controllers as a raw string. A typo silently turns into a permanent 403 with no compile-time detection. Mitigation: code review + the `module-layout.md` § Verification Needed #4 entry.
|
||||
|
||||
**No per-user attribution / audit**: the JWT's `sub` / user-id claim is parsed by `JwtBearerHandler` into `ClaimsPrincipal`, but nothing in this service consumes it. Logs are timestamp-only — incident reconstruction requires correlation by request time, not by user.
|
||||
|
||||
## 3. Data protection
|
||||
|
||||
**At rest**: PostgreSQL on-disk encryption is the device-level concern (suite-level, NOT this service). This service does NOT encrypt data at the column level.
|
||||
|
||||
**In transit**:
|
||||
|
||||
- The container `EXPOSE 8080` is **plain HTTP**. TLS termination is handled by the suite's edge reverse proxy (per `../../suite/_docs/00_top_level_architecture.md`).
|
||||
- No `app.UseHttpsRedirection()` in this service. If the reverse proxy is misconfigured or absent, traffic between the operator UI and this service may be cleartext on the local edge network.
|
||||
|
||||
**Secrets management**:
|
||||
|
||||
- `JWT_SECRET` and `DATABASE_URL` are env vars (with hardcoded dev fallbacks). See § 6 below.
|
||||
- No secret manager (Vault, AWS SM, K8s Secrets) — secrets are baked into the device's docker compose env at provisioning time.
|
||||
- No runtime gate prevents startup with the dev fallback in production (ADR-005 carry-forward).
|
||||
|
||||
## 4. Input validation
|
||||
|
||||
**None at the application layer**. No `[Required]`, no `[Range]`, no min-length attributes; no custom validators. The following are all accepted by ASP.NET Core model binding without rejection:
|
||||
|
||||
| Bad input | Accepted today |
|
||||
|-----------|---------------|
|
||||
| `CreateVehicleRequest.Name = ""` | yes |
|
||||
| `CreateVehicleRequest.BatteryCapacity = -1` | yes |
|
||||
| `CreateVehicleRequest.Type = (VehicleType)999` | yes — int casts to enum without range check |
|
||||
| `CreateWaypointRequest.OrderNum = -1` | yes |
|
||||
| `CreateWaypointRequest.GeoPoint = null` (or all three of `Lat`/`Lon`/`Mgrs` null) | yes |
|
||||
| `GetMissionsQuery.Page = -1` / `PageSize = 1_000_000` | yes — no bounds |
|
||||
|
||||
This is a **carry-forward concern** — input-shape testing is not a security gate today; the threat surface is mitigated by the closed edge network and authenticated single-operator workflow. Tightening is on the autodev backlog (Phase B feature cycle).
|
||||
|
||||
## 5. CORS
|
||||
|
||||
**Open in every environment**: `AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in `Program.cs`. Spec does not mandate a CORS policy.
|
||||
|
||||
The closed edge network behind the suite reverse proxy is the deployment-shape mitigation. Worth confirming on the **first production rollout** that the upstream proxy whitelists origins; if not, this is a finding to surface.
|
||||
|
||||
## 6. Production-deploy footguns
|
||||
|
||||
These are explicit security-relevant risks the code carries today, all tracked at the suite level or as carry-forward:
|
||||
|
||||
| Footgun | Where | Mitigation |
|
||||
|---------|-------|------------|
|
||||
| **Dev fallback for `JWT_SECRET`** silently accepted in production if env var unset | `Program.cs` (no `IsDevelopment` gate, ADR-005) | Suite-level remediation pending; recommend "fail-fast at startup if `JWT_SECRET` is unset OR equals the well-known dev fallback" |
|
||||
| **Dev fallback for `DATABASE_URL`** silently accepted in production if env var unset | `Program.cs` | Same pattern as `JWT_SECRET`; misconfigured deploy hits localhost Postgres on the device, which usually doesn't exist → process exits, but the failure mode is loud (crash) not silent |
|
||||
| **Swagger UI mounted unconditionally** | `Program.cs` (no `IsDevelopment` gate, ADR-005) | Reverse-proxy-level allowlist on `/swagger` is the suite-level mitigation; verify on first production rollout |
|
||||
| **CORS `AllowAnyOrigin/Method/Header`** in production | `Program.cs` | Reverse-proxy origin whitelist is the suite-level mitigation |
|
||||
| **No HTTPS redirection** | `Program.cs` (no `app.UseHttpsRedirection()`) | Reverse proxy enforces TLS upstream |
|
||||
| **Stack trace logged for unhandled 500s** | `Middleware/ErrorHandlingMiddleware.cs` `LogError(ex, ...)` | Stack is logged only — NOT returned in the HTTP response body (the 500 body is the generic `"Internal server error"` message from middleware) |
|
||||
| **JWT `iss`/`aud` validation disabled** | `Auth/JwtExtensions.cs` | CMMC L2 row 3 finding; tracked at suite level under AZ-487 / AZ-494 |
|
||||
| **Cascade-delete is NOT transaction-wrapped** (data-integrity, not auth) | `Services/MissionService.cs`, `Services/WaypointService.cs` (ADR-006) | One-line fix queued; recommended to land with B6 |
|
||||
| **Hardcoded permission string `"FL"`** in feature controllers | `Controllers/{Vehicles,Missions}Controller.cs` | Risk: typo silently turns into permanent 403; mitigation by code review + `module-layout.md` |
|
||||
| **Permission code `"FL"` retains legacy "Flight" wording** post-rename | `Auth/JwtExtensions.cs` | Fleet-wide auth change deferred (not in this Epic); TODO in `../../suite/_docs/00_roles_permissions.md` |
|
||||
|
||||
## 7. Audit logging
|
||||
|
||||
**None at the application level today.** The only structured log emitted by application code is `_logger.LogError(ex, "Unhandled exception")` in `ErrorHandlingMiddleware` for 500s. There is:
|
||||
|
||||
- **No correlation ID** per request
|
||||
- **No per-user attribution** (the JWT user-id claim is not consumed)
|
||||
- **No security-event log** (auth failures are logged by `JwtBearerHandler` at default ASP.NET Core levels — typically Information, not surfaced as a dedicated audit channel)
|
||||
- **No data-access audit** (writes/deletes go directly through linq2db with no wrapper that emits an audit row)
|
||||
|
||||
Production incident response on this service today requires grep-by-timestamp correlation against the operator UI's logs and `admin`'s issuance logs.
|
||||
|
||||
## 8. Threat model summary (one-paragraph)
|
||||
|
||||
The deployment shape — closed edge network, single operator per device, suite reverse proxy enforcing TLS and origin allowlisting upstream, Watchtower restart on crash — is the **primary defence-in-depth layer** for everything not handled by HS256 JWT validation and the `FL` permission gate. The known weak points (dev fallbacks not gated on `IsDevelopment()`, no input validation, no application-level audit log, `iss`/`aud` not validated) are documented and tracked, with the most critical ones (CMMC L2 row 3, default-vehicle race) under suite-level or B-ticket Jira IDs. This Epic (rename + GPS-Denied removal) does **not** change the security posture; it preserves every current invariant.
|
||||
|
||||
## 9. References
|
||||
|
||||
| Concern | File |
|
||||
|---------|------|
|
||||
| Auth registration | `Auth/JwtExtensions.cs` |
|
||||
| Authorization attribute usage | `Controllers/AircraftsController.cs` (post-B6: `VehiclesController.cs`), `Controllers/FlightsController.cs` (post-B6: `MissionsController.cs`) |
|
||||
| Error envelope (no stack-leak) | `Middleware/ErrorHandlingMiddleware.cs` |
|
||||
| Env var resolution + dev fallbacks | `Program.cs` |
|
||||
| CMMC L2 scorecard | `../../suite/_docs/05_security/cmmc_l2_scorecard.md` |
|
||||
| Roles & `FL` permission origin | `../../suite/_docs/00_roles_permissions.md` |
|
||||
| ADR-005 (Swagger + dev fallbacks) | `_docs/02_document/architecture.md` § 8 |
|
||||
| ADR-002 (PascalCase wire shape) | `_docs/02_document/architecture.md` § 8 |
|
||||
| Component identity description | `_docs/02_document/components/05_identity/description.md` |
|
||||
| Component http-conventions description | `_docs/02_document/components/06_http_conventions/description.md` |
|
||||
Reference in New Issue
Block a user