[AZ-808] [AZ-811] Strict validation on region POST + lat/lon GET

AZ-808: FluentValidation for POST /api/satellite/request
- RegionRequestValidator: id non-empty, lat/lon/sizeMeters/zoomLevel ranges
- RequestRegionRequest: [JsonRequired] on every property, no implicit defaults
- Wired via .WithValidation<RequestRegionRequest>() in MapPost chain
- Unit + integration tests + curl probe script
- New contract: contracts/api/region-request.md v1.0.0

AZ-811: FluentValidation + envelope filter for GET /api/satellite/tiles/latlon
- GetTileByLatLonQuery: nullable record (double?/int?) so the minimal-API
  binder never short-circuits with BadHttpRequestException before filters
- GetTileByLatLonQueryValidator: Cascade(Stop) + NotNull + InclusiveBetween
  per param; missing surfaces as `\`<name>\` is required.`
- RejectUnknownQueryParamsEndpointFilter: reusable IEndpointFilter that
  rejects any query key outside the allowed set with errors[<key>] map;
  catches legacy `?Latitude=` typos and hostile probes (`?debug=1&admin=1`)
- Handler: [AsParameters] GetTileByLatLonQuery + .Value deref post-validator
- Unit (validator + filter) + integration tests + curl probe script
- New contract: contracts/api/tile-latlon.md v1.0.0

Shared hygiene
- Promote AssertErrorsContainsMention from per-test-file private helpers to
  ProblemDetailsAssertions (closes batch-1 Low-severity DRY warning)
- Sync Swagger param descriptions, README, blackbox/security/perf scripts,
  uuidv5 doc with the new lat/lon/zoom query-param names

Docs
- system-flows.md F1/F2 reference the new contracts + validation layers
- modules/api_program.md adds Api/Validators + Api/DTOs sections
- _autodev_state.md: batch 2 of 4 complete; next batch = AZ-809

All smoke tests green (mode=smoke, exit 0). AZ-808 + AZ-811 transitioned
to In Testing on Jira.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-22 16:29:41 +03:00
parent fcd494f67e
commit 34ee1e0b83
35 changed files with 1993 additions and 122 deletions
@@ -0,0 +1,132 @@
# Strict validation for region-request endpoint (POST /api/satellite/request)
**Task**: AZ-808_region_endpoint_validation
**Name**: Strict validation for region-request endpoint
**Description**: Add FluentValidation-backed strict input validation to `POST /api/satellite/request` (region onboarding — enqueues a square region of tiles for async Google-Maps backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400). Second concrete child of AZ-795; reuses the shared infra wired in cycle 7.
**Complexity**: 3 points (7 validation rules — was 6 before the 2026-05-22 probe added the `Id` rule)
**Dependencies**: AZ-795 (HARD — shared infra already landed in cycle 7); AZ-796 (reference implementation pattern); AZ-812 (field-naming coordination — see below)
**Component**: SatelliteProvider.Api/Validators + SatelliteProvider.Common (RequestRegionRequest DTO)
**Tracker**: AZ-808 (https://denyspopov.atlassian.net/browse/AZ-808)
**Epic**: AZ-795 — Strict input validation across all public endpoints
**Originating ticket**: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — consumer needs this endpoint to seed Derkachi reference tile catalog; black-box probe surfaced concrete silent-coercion behavior
## Scope
Add FluentValidation-backed strict input validation to `POST /api/satellite/request` (region onboarding — enqueues a square region of tiles for async Google-Maps backfill). Reject malformed payloads with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.
Originating discovery: AZ-777 Phase 2 (gps-denied-onboard) — the consumer needs to call this endpoint to seed the Derkachi reference tile catalog. A black-box probe (2026-05-22) confirmed real silent-coercion behavior that this task fixes (see *Probe-confirmed gaps* below).
Jira AZ-808 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
## Probe-confirmed gaps (2026-05-22)
A black-box probe of the running producer captured these concrete behaviors that this task must close:
1. **`Id` silently coerces to zero-Guid when omitted.** Body `{"latitude":49.94,"longitude":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}` (no `id`) returned HTTP 200 with `"id":"00000000-0000-0000-0000-000000000000"` and `status:queued`. The `[Required]` DataAnnotation on `RequestRegionRequest.Id` is NOT enforced — the deserializer just yields the default Guid. This is the same silent-coercion class that motivated AZ-795. Validator must reject zero-Guid + missing-Id with the same RFC 7807 shape as the inventory validator.
2. **`UnmappedMemberHandling.Disallow` IS active for this endpoint.** Sending the wrong field name (`{"lat":49.94,...}`) returned HTTP 400 with the proper ValidationProblemDetails shape: `{"errors":{"lat":["The JSON property 'lat' could not be mapped to any .NET member contained in type 'SatelliteProvider.Common.DTO.RequestRegionRequest'."]}}`. So rule 8 (unknown-field rejection) is already covered by AZ-795 cycle-7 shared infra; this task only needs to verify it stays active after wiring `WithValidation<T>()`.
3. **Happy path works end-to-end.** With the correct shape `{"id":"<guid>","latitude":..,"longitude":..,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}`: HTTP 200 + regionId + 9 tiles downloaded from Google Maps + accessible via `GET /tiles/{z}/{x}/{y}` (13 KB JPEG verified). Validator must NOT regress this path.
## Field-naming coordination with AZ-812
This spec uses the **current wire format** (`latitude`, `longitude`) because that's what the DTO ships today and that's what the validator must reject malformed values for. **AZ-812** (mirror of AZ-794 for inventory) is filed to rename these to `lat`/`lon` for OSM-style consistency across all satellite-provider endpoints.
If AZ-812 lands **before** this task, rewrite all field references in this spec from `latitude`/`longitude` to `lat`/`lon` before implementing. If AZ-812 lands **after** this task, AZ-812 must also update the validator + contract doc + integration tests. Pick the ordering during planning to avoid double migration.
## Endpoint surface
`POST /api/satellite/request`
Current wire format (per `RequestRegionRequest.cs`, probe-confirmed 2026-05-22):
```json
{
"id": "<guid>",
"latitude": 50.10,
"longitude": 36.10,
"sizeMeters": 5000,
"zoomLevel": 18,
"stitchTiles": false
}
```
Response: HTTP 200 with `RegionStatusResponse` (id, status, csvFilePath, summaryFilePath, tilesDownloaded, tilesReused, createdAt, updatedAt). Async — the actual tile downloads happen in the background via `RegionProcessingService` (Flow F3). Caller polls `GET /api/satellite/region/{id}` until `status:completed`.
## Required validations
1. **Body present** — null/empty body → 400 (`errors.$`).
2. **`id` required, non-zero Guid** — NEW (probe-confirmed gap). Missing or `00000000-...` → 400 with `errors.id`. Use `RuleFor(x => x.Id).NotEmpty()` (FluentValidation's `NotEmpty()` rejects default-Guid).
3. **`latitude` required** — double, in `[-90.0, 90.0]`. Out-of-range or missing → 400 with `errors.latitude`.
4. **`longitude` required** — double, in `[-180.0, 180.0]`. Out-of-range or missing → 400 with `errors.longitude`.
5. **`sizeMeters` required** — double, in `[100.0, 10000.0]` (matches current inline check in `RequestRegion Handler` per `api_program.md`). Out-of-range or missing → 400 with `errors.sizeMeters`.
6. **`zoomLevel` required** — int, in `[0, 22]` (align with `TileCoordValidator` slippy-map range used by AZ-796 for the inventory endpoint). Out-of-range or missing → 400 with `errors.zoomLevel`.
7. **`stitchTiles` required** — bool. Missing → 400 with `errors.stitchTiles` (no defaulting to `false` — force the caller to declare intent).
8. **Unknown root fields rejected** — already covered by AZ-795's `UnmappedMemberHandling.Disallow` (probe-confirmed active). Verify it stays active after wiring `WithValidation<T>()`.
9. **Type mismatch** — e.g. `"latitude": "fifty"` → 400 with `errors.latitude` ("could not be parsed"). Already covered by AZ-795's `GlobalExceptionHandler`; verify it triggers for this endpoint.
## Implementation pattern (mirror AZ-796)
1. New file: `SatelliteProvider.Api/Validators/RegionRequestValidator.cs``AbstractValidator<RequestRegionRequest>` with rules 27.
2. Mark `RequestRegionRequest` props with `[JsonRequired]` (replacing or supplementing the existing `[Required]` DataAnnotation — the latter is not enforced by `System.Text.Json`, as the probe confirmed). Apply to `Id`, `Latitude`, `Longitude`, `SizeMeters`, `ZoomLevel`, `StitchTiles`.
3. Add `.WithValidation<RequestRegionRequest>()` to the `MapPost("/api/satellite/request", ...)` chain in `Program.cs`.
4. Unit tests: `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` — one test per `RuleFor(...)` (≥ 6 methods covering id, latitude, longitude, sizeMeters, zoomLevel, stitchTiles).
5. Integration tests: `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` (new file) — ≥ 9 methods (1 happy + 1 per failure-mode AC — including missing-id reproducing the probe's silent-coercion case).
6. Manual probe: `scripts/probe_region_validation.sh` (mirrors `scripts/probe_inventory_validation.sh` from AZ-796). MUST include the missing-id test case.
## New contract doc
Create `_docs/02_document/contracts/api/region-request.md` v1.0.0. The region endpoint has **no formal contract** today (only `system-flows.md` F2 + module docs). The contract doc must cover:
- Endpoint, auth, request body, response body (use the actual `RegionStatusResponse` shape: id, status, csvFilePath, summaryFilePath, tilesDownloaded, tilesReused, createdAt, updatedAt), error shape (reference `error-shape.md` v1.0.0).
- Invariants (one regionId per request; client-provided non-zero Id; size cap; async semantics — caller must poll `GET /api/satellite/region/{id}`).
- Test cases mirroring the validator rules (same `Case | Input | Expected | Notes` table format as `tile-inventory.md` v2.0.0). MUST include the missing-id case.
- Cross-link to `RegionStatus` flow (F3) and the consumer-facing inventory contract (`tile-inventory.md` — callers seed via region, then read via inventory).
- Reference to AZ-812 (field-naming follow-up).
## Coordination with sibling tickets
- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
- **AZ-796 (inventory)**: reference implementation — copy the validator + integration-test layout 1:1.
- **AZ-812 (region field rename)**: hard coordination on field names. See *Field-naming coordination with AZ-812* above.
- **AZ-777 (gps-denied-onboard)**: consumer-side dependency — Phase 2 cannot proceed safely until this validator lands AND the contract doc exists. Consumer has black-box-probed the endpoint and can use it today, but silent-coercion bugs make Phase 2 fragile until validation is in place.
- Sibling validation tasks created in the same batch: **AZ-809** (route), **AZ-810** (UAV upload metadata), **AZ-811** (lat/lon GET).
## Acceptance criteria
**AC-1**: Each of the 9 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision; unrelated rules NOT in the `errors` map).
**AC-2**: Happy path unchanged — a valid body still returns HTTP 200 + `RegionStatusResponse`; background processing still runs; the probe's 9-tile Derkachi case (`{"id":"<guid>","latitude":49.94,"longitude":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}`) still completes in under 10 seconds.
**AC-3**: `RegionRequestValidator` lives in its own file under `SatelliteProvider.Api/Validators/` and is unit-tested (≥ 1 test per `RuleFor`).
**AC-4**: `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` covers happy + 8+ failure modes with full ValidationProblemDetails assertion (use the existing `ProblemDetailsAssertions` helper from AZ-795). MUST include `Post_WithMissingId_ReturnsBadRequest` (reproducing the 2026-05-22 probe's silent-coercion case).
**AC-5**: `_docs/02_document/contracts/api/region-request.md` v1.0.0 created and published.
**AC-6**: `_docs/02_document/system-flows.md` F2 updated to reference the new contract doc + error shape.
**AC-7**: OpenAPI spec marks `RequestRegionRequest` fields `required`, declares ranges, and documents the 400 response (matches AZ-796 Swashbuckle annotations).
**AC-8**: Manual probe script exercises each failure mode end-to-end via `curl` + JWT.
## Out of scope
- The Region API's processing semantics (Flow F3 — `RegionProcessingService`) — validation lives at the API layer only.
- Any change to `IRegionService.RequestRegionAsync` signature beyond accepting the validated DTO.
- `GET /api/satellite/region/{id}` status endpoint (separate task if path-parameter validation needed; current Guid binding is framework-handled).
- The field-name rename (`Latitude/Longitude``Lat/Lon`) — handled by AZ-812.
- Performance — validation overhead is negligible vs the async enqueue + Google Maps round-trip.
## Constraints
- **Breaking behavior change** — any consumer today omitting `id` (silently getting zero-Guid) or sending malformed values will start getting 400. Known consumer set: gps-denied-onboard (currently uses correct body shape with id, per black-box probe 2026-05-22). Other consumers TBD by parent-suite team.
- No regression in any existing `RegionRequestTests.cs` happy-path coverage.
## References
- Jira AZ-808: https://denyspopov.atlassian.net/browse/AZ-808
- Parent Epic: AZ-795 (shared infra; error-shape contract)
- Reference implementation: AZ-796 (inventory endpoint)
- Coordination: AZ-812 (region field-name rename to OSM convention)
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (flagged this endpoint as next in line)
- Originating consumer discovery: gps-denied-onboard AZ-777 Phase 2 (2026-05-22 black-box probe)
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)
@@ -0,0 +1,105 @@
# Strict validation for lat/lon tile GET endpoint (GET /api/satellite/tiles/latlon)
**Task**: AZ-811_latlon_get_endpoint_validation
**Name**: Strict validation for lat/lon tile GET endpoint
**Description**: Add FluentValidation-backed strict input validation to `GET /api/satellite/tiles/latlon` (single-tile download by lat/lon/zoom). Reject malformed query strings with RFC 7807 ValidationProblemDetails (HTTP 400). Fifth concrete child of AZ-795; query-string surface differs from sibling JSON-body endpoints — needs explicit unknown-query-param filter.
**Complexity**: 2 points (simple endpoint, 3 typed params + unknown-param check, reuses cycle-7 shared infra, small new contract doc)
**Dependencies**: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract reference)
**Component**: SatelliteProvider.Api/Validators + small new endpoint filter (RejectUnknownQueryParamsEndpointFilter)
**Tracker**: AZ-811 (https://denyspopov.atlassian.net/browse/AZ-811)
**Epic**: AZ-795 — Strict input validation across all public endpoints
**Originating ticket**: AZ-795 cycle-7 retro (explicitly names this endpoint as a remaining per-endpoint child)
## Scope
Add FluentValidation-backed strict input validation to `GET /api/satellite/tiles/latlon`. Reject malformed query strings with **RFC 7807 ValidationProblemDetails** (HTTP 400) per the Epic's `error-shape.md` v1.0.0 contract.
Differs from siblings (AZ-796 / AZ-808 / AZ-809 / AZ-810) in that the input surface is **query string**, not a JSON body, so the unknown-field rejection knob (`UnmappedMemberHandling.Disallow`) does not apply directly — query-param-strictness needs an explicit shape check.
Originating discovery: AZ-795 cycle-7 retro — this endpoint is explicitly named as a remaining gap alongside the POST endpoints.
Jira AZ-811 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
## Endpoint surface
`GET /api/satellite/tiles/latlon?lat=<float>&lon=<float>&zoom=<int>` (auth: JWT bearer required, no permission claim).
Response (current per `api_program.md::GetTileByLatLon Handler`): HTTP 200 with `DownloadTileResponse` (tile metadata; the actual bytes are served separately via `GET /tiles/{z}/{x}/{y}`).
Current behavior on bad input: query params bind via the framework's default model binder — missing/malformed params trigger a generic 400 or silent defaults, neither of which conforms to `error-shape.md` v1.0.0.
## Required validations
1. **`lat` query param required** — double, in `[-90.0, 90.0]`. Missing/out-of-range/malformed → 400 with `errors.lat`.
2. **`lon` query param required** — double, in `[-180.0, 180.0]`. Missing/out-of-range/malformed → 400 with `errors.lon`.
3. **`zoom` query param required** — int, in `[0, 22]` (align with `TileCoordValidator`). Missing/out-of-range/malformed → 400 with `errors.zoom`.
4. **Unknown query parameters rejected** — any query string param outside `{lat, lon, zoom}` → 400 with `errors.<paramName>`. (Requires explicit query-param-shape check inside the endpoint filter — the framework's default binder silently ignores extras.)
5. **Type mismatch** — e.g. `lat=fifty` (not parseable as double) → 400 with `errors.lat` ("could not be parsed"). Covered by AZ-795's `GlobalExceptionHandler` IF the binding throws — verify this code path triggers it (it does for `[FromBody]` deserializers; query-string parse failures may take a different path — surface in PR and adapt).
## Implementation pattern (adapted for query string)
1. Bind query params to a dedicated record: `record GetTileByLatLonQuery(double Lat, double Lon, int Zoom)`. Default `[AsParameters]` binding works; `[JsonRequired]` doesn't apply (no JSON deserializer in the path), so missing-required is detected by the validator only.
2. New file: `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs``AbstractValidator<GetTileByLatLonQuery>` with rules 13.
3. Add `.WithValidation<GetTileByLatLonQuery>()` to the `MapGet("/api/satellite/tiles/latlon", ...)` chain. May require a small variant of `ValidationEndpointFilter<T>` that runs against the bound query-record rather than the body-bound record — the cycle-7 generic filter already does the bound-argument lookup, so it should Just Work; verify.
4. **Rule 4 (unknown query params)** is the novel piece: implement as a separate endpoint filter that inspects `HttpContext.Request.Query.Keys` against the allowed set `{"lat", "lon", "zoom"}`. On any extras → `Results.ValidationProblem` with one `errors` entry per unexpected key. Either:
- Standalone filter `RejectUnknownQueryParamsEndpointFilter` (parameterized by allowed keys; reusable across future query-param endpoints).
- Inline `Func<EndpointFilterInvocationContext, ...>` for now and extract when the second consumer arrives. Parent-suite team decides.
5. Unit tests: `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` (≥ 3 methods — one per RuleFor). Plus a test for the unknown-query-param filter (≥ 1 method).
6. Integration tests: `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` (new file) — ≥ 6 methods (1 happy + 1 per failure-mode AC + 1 unknown-query-param).
7. Manual probe: `scripts/probe_latlon_validation.sh``curl` against each failure mode.
## New contract doc
Create `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0. This endpoint has **no formal contract** today; the producer-doc surface is `modules/api_program.md::GetTileByLatLon Handler` only. Cover:
- Endpoint, auth, query params, response body (`DownloadTileResponse`), error shape (reference `error-shape.md` v1.0.0).
- Invariants (single tile per request; (lat, lon, zoom) deterministically maps to a (z, x, y) coord; output references the slippy-map URL `/tiles/{z}/{x}/{y}` for body fetch).
- Test cases table mirroring validator rules.
- Cross-link to `tile-inventory.md` v2.0.0 (related single-vs-bulk read patterns) + `GET /tiles/{z}/{x}/{y}` URL contract.
## Coordination with sibling tickets
- **Parent (AZ-795)**: depends on shared infra already landed in cycle 7.
- **AZ-796 (inventory)**: reference for `[FromBody]` validator pattern.
- **AZ-808 (region)**: reference for endpoint without prior contract doc.
- **AZ-777 (gps-denied-onboard)**: not currently a consumer (the onboard side uses `GET /tiles/{z}/{x}/{y}` directly with pre-computed coords from inventory); but this endpoint is needed for future workflows (e.g. UI-driven single-tile fetch by user-clicked coordinates).
## Acceptance criteria
**AC-1**: Each of the 5 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision).
**AC-2**: Happy path unchanged — a valid `?lat=&lon=&zoom=` still returns HTTP 200 + `DownloadTileResponse`; tile is still downloaded/persisted as before.
**AC-3**: `GetTileByLatLonQueryValidator` lives in its own file under `SatelliteProvider.Api/Validators/` and is unit-tested (≥ 3 methods).
**AC-4**: `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` covers happy + 4+ failure modes with full ValidationProblemDetails assertion.
**AC-5**: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 created and published.
**AC-6**: `_docs/02_document/modules/api_program.md::GetTileByLatLon Handler` updated to reference the validator + new contract doc.
**AC-7**: OpenAPI spec marks the query params as required + ranges + 400 response.
**AC-8**: Manual probe script exercises each failure mode end-to-end via `curl` + JWT.
**AC-9**: The novel unknown-query-param rejection filter (item 4 of Implementation pattern) is documented in `_docs/02_document/modules/api_program.md` so the next query-param endpoint can reuse it cleanly.
## Out of scope
- The actual tile download / persistence semantics — unchanged.
- `GET /tiles/{z}/{x}/{y}` path-parameter validation (separate concern; the path int binder rejects malformed values at the framework layer, but range-checking `z` and `x`/`y` bounds is a gap that may warrant a separate task if parent-suite team decides).
- Performance — query-string validation overhead is negligible vs the conditional Google-Maps round-trip.
## Constraints
- **Breaking behavior change** — callers sending unknown extra query params (e.g. typo `?latitude=`) that today silently fall back to `lat=0` will start getting 400. Known consumer set: TBD by parent-suite team (gps-denied-onboard does NOT currently call this endpoint).
- No regression in any existing `TileByLatLonTests.cs` happy-path coverage.
- The unknown-query-param rejection (rule 4) is a NEW behavior on top of standard ASP.NET binding; document it loudly in the contract doc so consumers know.
## References
- Jira AZ-811: https://denyspopov.atlassian.net/browse/AZ-811
- Parent Epic: AZ-795
- Reference implementations: AZ-796 (single-DTO pattern), AZ-808 (no-prior-contract pattern, same batch)
- Cycle-7 retro: `_docs/06_metrics/retro_2026-05-22_cycle7.md` (flagged this endpoint as a per-endpoint child of AZ-795)
- Related contract docs: `error-shape.md` v1.0.0, `tile-inventory.md` v2.0.0 (both produced by AZ-795+AZ-796 cycle 7)