mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 18:21:14 +00:00
[AZ-810] Strict validation for POST /api/satellite/upload metadata
Adds the per-endpoint child of AZ-795 ("Strict Input Validation Epic")
for the UAV upload multipart endpoint. Three new validators land under
SatelliteProvider.Api/Validators/:
- UavTileBatchMetadataPayloadValidator: items NotNull + NotEmpty +
count <= MaxBatchSize + RuleForEach dispatching to the per-item
validator.
- UavTileMetadataValidator: lat / lon / tileZoom range, tileSizeMeters
> 0, capturedAt within [now - MaxAgeDays, now + future-skew]; uses an
injectable TimeProvider so unit tests can drive a fixed clock.
- UavUploadValidationFilter: IEndpointFilter that reads the multipart
`metadata` form field, deserializes it with the strict global
JsonSerializerOptions (so UnmappedMemberHandling.Disallow +
[JsonRequired] from AZ-795 are honored), runs the FluentValidation
chain, and enforces the cross-field `items.Count == files.Count`
envelope rule. FluentValidation errors are prefixed with `metadata.`
so wire keys look like `errors["metadata.items[0].latitude"]`.
[JsonRequired] is added to every non-optional axis on
UavTileMetadata and UavTileBatchMetadataPayload; FlightId stays
nullable per AZ-503 anonymous-flight semantics.
Coverage: 13 unit tests + 16 integration tests + 1 curl probe script
exercise the happy path and every failure mode. All 9 ACs covered;
no regression in AZ-488 UavUploadTests payloads (traced against the
new rules).
Documentation: uav-tile-upload.md bumped v1.1.0 -> v1.2.0 with the
new validation rules section + 400-shape examples + changelog entry.
api_program.md updated to describe the three new validators + filter
+ the AddTransient<UavUploadValidationFilter>() DI registration.
Reports: batch_04_cycle8_report.md + reviews/batch_04_cycle8_review.md
record the PASS_WITH_WARNINGS verdict (2 Low DRY-in-tests findings:
FixedTimeProvider duplication crossed the cycle-2 "promote to shared"
threshold; PostBatch helper duplicated between two integration
suites). Both deferred to follow-up PBIs.
Task spec archived: _docs/02_tasks/todo/AZ-810... -> done/.
Jira: AZ-810 transitioned In Progress -> In Testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,7 +12,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` (OSM convention) and added strict validation: range-checked `lat`/`lon`/`zoom` via `WithValidation<GetTileByLatLonQuery>()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY` → `z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. AZ-810 (cycle 8) added a strict **metadata-layer** validator that runs BEFORE the quality gate via the custom `UavUploadValidationFilter`: 14 rules covering required fields (`[JsonRequired]`), per-item ranges (`latitude`/`longitude`/`tileZoom`/`tileSizeMeters`/`capturedAt`), envelope alignment (`items.Count == files.Count`), and unknown-field / type-mismatch rejection via `UnmappedMemberHandling.Disallow`. Errors surface as RFC 7807 `ValidationProblemDetails` matching `error-shape.md` v1.0.0 with `errors["metadata.…"]` keys. Contract: `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing |
|
||||
| GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status |
|
||||
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via `WithValidation<CreateRouteRequest>()`: non-zero `id`, name length ∈ \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with per-point lat/lon range checks, per-polygon NW-of-SE invariants, and the `createTilesZip ⇒ requestMaps` cross-field rule. Deserializer-layer failures (missing `[JsonRequired]` axes, unknown fields, type mismatches) are caught by `GlobalExceptionHandler` and produce the same RFC 7807 envelope. Contract: `_docs/02_document/contracts/api/route-creation.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
@@ -28,6 +28,8 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
- `GetTileByLatLonQueryValidator` — `AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` is required."` (no spurious range error against a null sentinel).
|
||||
- `RegionRequestValidator` (AZ-808 cycle 8) — `AbstractValidator<RequestRegionRequest>`. Post-deserialization business rules: non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\]. Required-field detection lives at the deserializer layer (`[JsonRequired]` + `UnmappedMemberHandling.Disallow`).
|
||||
- `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator` (AZ-809 cycle 8) — three FluentValidation validators for the route-creation endpoint. The root validator chains `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` for per-point checks and `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")` for per-polygon checks. The `OverridePropertyName` on the geofences chain restores the full wire path (`geofences.polygons[i].northWest`) because FluentValidation's default name policy drops the parent on deep expressions like `req.Geofences!.Polygons`. `RoutePointValidator` uses `OverridePropertyName("lat"/"lon")` after each range rule so error keys match the wire format (`lat`/`lon`) rather than the camelCased C# names (`latitude`/`longitude`). The cross-field rule `createTilesZip ⇒ requestMaps` lives on the root via `Must(req => !(req.CreateTilesZip && !req.RequestMaps)).WithName("createTilesZip")`.
|
||||
- `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator` (AZ-810 cycle 8) — FluentValidation validators for the UAV upload metadata envelope. Root validator runs `items` count rules (non-null, non-empty, ≤ `UavQualityConfig.MaxBatchSize`) then `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(...))` so per-item errors come out as `items[i].<field>` (then prefixed with `metadata.` by `UavUploadValidationFilter`). Per-item rules: `latitude` ∈ \[-90, 90\], `longitude` ∈ \[-180, 180\], `tileZoom` ∈ \[0, 22\], `tileSizeMeters` > 0, `capturedAt` within `\[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds\]`. `flightId` is intentionally NOT validated beyond JSON shape — AZ-503 anonymous-flight semantics require `null` to be valid, and malformed UUID strings are already rejected at the deserializer with a JsonException. The freshness check uses an injectable `TimeProvider` (defaults to `TimeProvider.System`) so unit tests can drive it with a fixed clock.
|
||||
- `UavUploadValidationFilter` (AZ-810 cycle 8) — endpoint filter for `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `WithValidation<T>()` JSON-body filter cannot bind directly; this filter reads the `metadata` form field, deserializes it via the strict global `JsonSerializerOptions` (so `UnmappedMemberHandling.Disallow` + `[JsonRequired]` from AZ-795 are honored), runs `IValidator<UavTileBatchMetadataPayload>` from DI, and enforces the cross-field `items.Count == files.Count` rule. Error-map keys from the per-item validator are prefixed with `metadata.` so paths surface to the caller as `errors["metadata.items[0].latitude"]`. Registered as a transient via `AddTransient<UavUploadValidationFilter>()` and wired on the endpoint with `.AddEndpointFilter<UavUploadValidationFilter>()`. The downstream `IUavTileUploadHandler` retains its own envelope checks as defence-in-depth (covers direct handler callers in unit tests).
|
||||
|
||||
### Api/DTOs (AZ-811 cycle 8)
|
||||
- `GetTileByLatLonQuery` — `record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the `GetTileByLatLon` handler. **Nullable on purpose**: minimal-API binding throws `BadHttpRequestException` for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` with no `errors{}` envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per `error-shape.md` v1.0.0. The handler dereferences `.Value` only after the validator filter passes.
|
||||
@@ -39,7 +41,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
- `UavTileBatchUploadRequest` — multipart envelope with `metadata` (JSON string) and `files` (`IFormFileCollection`)
|
||||
|
||||
### Common/DTO (AZ-488)
|
||||
- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape
|
||||
- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape. AZ-810 cycle 8 added `[JsonRequired]` to every non-optional axis (`latitude`, `longitude`, `tileZoom`, `tileSizeMeters`, `capturedAt` on the per-item record; `items` on the envelope) so the deserializer rejects partial payloads with HTTP 400 before the FluentValidation + `IUavTileQualityGate` layers run. `flightId` stays optional per AZ-503 anonymous-flight semantics.
|
||||
- `UavTileBatchUploadResponse`, `UavTileUploadResultItem` — per-item response shape
|
||||
- `UavTileUploadStatus`, `UavTileRejectReasons` — string-constant enumerations exposed in the v1.0.0 contract
|
||||
|
||||
@@ -75,7 +77,8 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
11. **Kestrel HTTP/2 (AZ-505)**: `builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2))`. The dev listener is now `https://+:8080` with a self-signed cert (`./certs/api.pfx`, generated idempotently by `scripts/run-tests.sh` and bound via `ASPNETCORE_Kestrel__Certificates__Default__Path` / `__Password` in `docker-compose.yml`). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises both `h2` and `http/1.1` so HTTP/2-capable clients (browser Leaflet, `HttpClient` with `Version20` + `RequestVersionExact`, httpx `http2=True`) multiplex tile reads on a single TLS connection, and legacy clients fall back to HTTP/1.1. The integration-test container trusts the dev cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. AZ-505 AC-5 verifies the multiplex semantics here; production termination is expected at the ingress (Envoy / nginx / ALB) — Kestrel can then drop to HTTP/2 cleartext behind it without changing this code.
|
||||
12. **ProblemDetails + global exception handler (AZ-795, cycle 7)**: `AddProblemDetails()` + `AddExceptionHandler<GlobalExceptionHandler>()` register the uniform RFC 7807 error pipeline. `app.UseExceptionHandler()` (in the middleware chain) routes unhandled exceptions through `GlobalExceptionHandler`, which converts `BadHttpRequestException(JsonException)` (unknown-member rejection, missing-required-field, JSON type mismatch) into `ValidationProblemDetails` with the same `errors[]` map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract — `error-shape.md` v1.0.0 §"Two collaborating pieces of shared infrastructure".
|
||||
13. **Strict JSON parsing (AZ-795, cycle 7)**: `ConfigureHttpJsonOptions` sets `PropertyNamingPolicy = CamelCase`, `PropertyNameCaseInsensitive = true`, `UnmappedMemberHandling = Disallow`, and adds `JsonStringEnumConverter` with camelCase naming. `UnmappedMemberHandling.Disallow` is the key strict-parsing knob: any unknown root or nested field is rejected at the deserializer rather than silently dropped. Catches typos (`{"Z":12}` uppercase, `{"tileZoom":...}` post-rename) that no FluentValidation rule can see after deserialization.
|
||||
14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining<Program>()` auto-registers every `IValidator<T>` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation<TileInventoryRequest>()` on the inventory MapPost — the generic `ValidationEndpointFilter<T>` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure.
|
||||
14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining<Program>()` auto-registers every `IValidator<T>` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`, AZ-808 `RegionRequestValidator`, AZ-809 `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator`, AZ-810 `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator`, AZ-811 `GetTileByLatLonQueryValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation<T>()` on the JSON-body endpoints — the generic `ValidationEndpointFilter<T>` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure.
|
||||
15. **AZ-810 multipart validation filter (cycle 8)**: `AddTransient<UavUploadValidationFilter>()` registers the bespoke filter used by `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `.WithValidation<T>()` JSON-body filter cannot bind; this filter reads the `metadata` form field, deserializes it via the strict global `JsonSerializerOptions`, runs the FluentValidation chain, and enforces the cross-field `items.Count == files.Count` envelope rule. Wired on the endpoint with `.AddEndpointFilter<UavUploadValidationFilter>()` between `.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` and the metadata accept/produces annotations.
|
||||
|
||||
### Startup
|
||||
1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure
|
||||
|
||||
Reference in New Issue
Block a user