[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:
Oleksandr Bezdieniezhnykh
2026-05-23 13:32:19 +03:00
parent 5e056b2334
commit 490902c80a
15 changed files with 1564 additions and 7 deletions
@@ -4,9 +4,9 @@
**Producer task**: AZ-488 — `_docs/02_tasks/done/AZ-488_uav_tile_upload.md`
**Extended by**: AZ-503 — `_docs/02_tasks/done/AZ-503_tile_identity_uuidv5_bulk_list.md` (added optional `flightId` per-item field; per-flight on-disk path; deterministic `tileId`)
**Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client
**Version**: 1.1.0
**Version**: 1.2.0
**Status**: frozen
**Last Updated**: 2026-05-12
**Last Updated**: 2026-05-23
## Purpose
@@ -50,6 +50,41 @@ Field names are camelCase. Property-name matching is case-insensitive on read.
- `metadata.items.length` MUST NOT exceed `UavQualityConfig.MaxBatchSize` (default `100`). Oversize → HTTP 400.
- The total request body size is capped at `MaxBatchSize × MaxBytes` by Kestrel's `MaxRequestBodySize` and the form `MultipartBodyLengthLimit`. Requests beyond this cap are rejected at the framework layer (HTTP 413/400).
## Metadata validation (14 rules, v1.2.0)
Before any file bytes are inspected by the Quality Gate below, the `metadata` envelope is run through a strict validator chain. This is the **metadata layer**; the **file layer** (see Quality Gate) is unchanged.
The validator is split into three composing layers and runs inside a custom multipart endpoint filter (`UavUploadValidationFilter`):
1. **Deserializer layer**`JsonSerializerOptions.UnmappedMemberHandling.Disallow` + `[JsonRequired]` on every non-optional axis of `UavTileBatchMetadataPayload` / `UavTileMetadata`. Catches missing-required, unknown fields (root and nested), JSON type mismatches, and malformed UUIDs. Errors surface under `errors["metadata"]`.
2. **FluentValidation layer**`UavTileBatchMetadataPayloadValidator` (envelope rules) and `UavTileMetadataValidator` (per-item rules). Errors surface under `errors["metadata.items"]` / `errors["metadata.items[i].<field>"]`.
3. **Cross-field envelope rule**`items.Count == files.Count`, evaluated in the filter after the FluentValidation result is clean. Errors surface under **both** `errors["metadata.items"]` AND `errors["files"]`.
Any failing rule short-circuits with HTTP 400 + RFC 7807 `ValidationProblemDetails` per `error-shape.md` v1.0.0. The body never reaches the Quality Gate or the persistence path on a metadata validation failure.
| # | Rule | Failure condition | Error path | Layer |
|---|------|-------------------|------------|-------|
| 1 | Multipart envelope present | Request `Content-Type` is not `multipart/form-data` | `errors["metadata"]` | filter |
| 2 | `metadata` form field present | Multipart form has no part named `metadata` | `errors["metadata"]` | filter |
| 3 | `metadata` parses as JSON | Malformed JSON body | `errors["metadata"]` | deserializer |
| 4 | `items` required + non-empty | `items` missing OR `items: []` | `errors["metadata.items"]` | FluentValidation |
| 5 | `items.Count``UavQualityConfig.MaxBatchSize` | `items.Count > MaxBatchSize` (default 100) | `errors["metadata.items"]` | FluentValidation |
| 6 | `items.Count == files.Count` | Per-item file count differs from metadata count | `errors["metadata.items"]` + `errors["files"]` | filter |
| 7 | `latitude` ∈ [-90, +90] | Out of range | `errors["metadata.items[i].latitude"]` | FluentValidation |
| 8 | `longitude` ∈ [-180, +180] | Out of range | `errors["metadata.items[i].longitude"]` | FluentValidation |
| 9 | `tileZoom` ∈ [0, 22] | Out of range | `errors["metadata.items[i].tileZoom"]` | FluentValidation |
| 10 | `tileSizeMeters` > 0 | Zero or negative | `errors["metadata.items[i].tileSizeMeters"]` | FluentValidation |
| 11 | `capturedAt` within freshness window | `capturedAt > now + CapturedAtFutureSkewSeconds` OR `capturedAt < now - MaxAgeDays` | `errors["metadata.items[i].capturedAt"]` | FluentValidation |
| 12 | `flightId` parses as UUID | Non-UUID string (`null`/missing is valid per AZ-503) | `errors["metadata"]` | deserializer |
| 13 | Unknown fields rejected (root + nested) | Any field not declared on the DTO | `errors["metadata"]` | deserializer |
| 14 | Type mismatch | e.g. `"latitude": "fifty"`, `"tileZoom": 18.5` | `errors["metadata"]` | deserializer |
### Relationship to the Quality Gate
The Quality Gate's Rule 4 (captured-at freshness) is preserved exactly as documented below. It runs **after** the metadata validator and provides defence-in-depth against handler callers that bypass the filter (unit tests of `IUavTileUploadHandler`, future internal call paths). Operators consuming the public API will see the metadata validator's verdict first.
The Quality Gate's Rules 1, 2, 3, 5 (file-level: format, size, dimensions, luminance) are unchanged and still produce per-item rejections via the existing HTTP 200 + `rejectReason` envelope — they have no metadata-validator equivalent.
## Quality Gate (5 rules)
Each item is evaluated in this fixed order. The **first** failing rule produces the reported reason; subsequent rules are not evaluated for that item.
@@ -106,14 +141,31 @@ Adding a new code is a **minor** contract version bump per the Versioning Rules
### HTTP 400 — envelope error (RFC 7807 `application/problem+json`)
Returned when the request itself is malformed:
Returned when the request itself is malformed. As of v1.2.0 every 400 body conforms to the shared `ValidationProblemDetails` shape in `error-shape.md` v1.0.0, with the `errors` map keys listed in the "Metadata validation" rule table above. Triggers include:
- `metadata` field absent, empty, or not valid JSON
- `metadata.items` empty or null
- `metadata.items.length``files.length`
- `metadata.items.length` > `MaxBatchSize`
- Per-item `latitude`/`longitude`/`tileZoom`/`tileSizeMeters` out of declared range
- Per-item `capturedAt` outside the freshness window
- Unknown root or nested fields
- Type mismatches and malformed UUIDs
The 5-rule per-item quality gate never produces a 400; per-item failures always surface in the HTTP 200 response array.
Sample body:
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"metadata.items[0].latitude": ["`latitude` must be between -90 and 90."]
}
}
```
The 5-rule per-item quality gate never produces a 400; per-item file rejections always surface in the HTTP 200 response array.
### HTTP 401 — missing or invalid JWT (from AZ-487)
@@ -185,3 +237,4 @@ Each version bump requires updating the Change Log and notifying every consumer
|---------|------|--------|--------|
| 1.0.0 | 2026-05-11 | Initial contract — batch UAV upload endpoint, 5-rule quality gate, per-source UPSERT, closed reject-reason enum, GPS-permission requirement. Produced by AZ-488. | autodev (cycle 2 step 10) |
| 1.1.0 | 2026-05-12 | Minor bump for AZ-503: added optional `flightId` per-item metadata field (backward-compatible default `null`); `tileId` in the response is now a deterministic UUIDv5 derived from `(z, x, y, source, flightId)` instead of a random Guid; on-disk path adds a `{flightId or 'none'}` segment for per-flight evidence isolation. No reject-reason changes, no envelope changes, no permission changes. v1.0.0 clients omitting `flightId` keep working unchanged. | autodev (cycle 5 step 13) |
| 1.2.0 | 2026-05-23 | Minor bump for AZ-810: added the **metadata validation** layer (14 rules) wired through a new `UavUploadValidationFilter` that runs BEFORE the per-item Quality Gate. Marks every non-optional metadata axis with `[JsonRequired]`; uses `UnmappedMemberHandling.Disallow` so unknown root/nested fields are rejected. HTTP 400 envelope-error body now matches the shared `ValidationProblemDetails` shape per `error-shape.md` v1.0.0. **Behavior change**: callers previously sending malformed metadata that silently coerced (e.g. `latitude: 0` for a missing field) now receive HTTP 400 instead of HTTP 200 + per-item rejection. No wire-format renames, no reject-reason changes, no permission changes. The Quality Gate (5 rules) is unchanged and continues as defence-in-depth. | autodev (cycle 8 batch 4) |