[AZ-794] [AZ-795] [AZ-796] Cycle 7 Steps 12-15 sync (test-spec / docs / security / perf)

Step 12 (Test-Spec Sync): adds BT-27 for the AZ-796 9-rule
validation surface and 12 cycle-7 AC rows + Coverage Summary
update to traceability-matrix.md.

Step 13 (Update Docs): module-layout + module docs for the new
SatelliteProvider.Api/Validators namespace + GlobalExceptionHandler
+ updated TileInventory DTO; tests_unit + tests_integration
document the new InventoryRequestValidatorTests (16 unit tests
covering all 9 rules) + TileInventoryValidationTests (16
integration tests) + ProblemDetailsAssertions support;
glossary entries for Validation Problem Details / FluentValidation
/ Unmapped Member Handling; system-flows F8 (Tile Inventory Bulk
Lookup) expanded with deserializer + validator gates and a 13-row
Validation Surface table; data_parameters § Tile Inventory
documents the v2 input schema + constraints; ripple_log_cycle7
captures the doc-side ripple decisions.

Step 14 (Security Audit): 5-phase audit ran; verdict
PASS_WITH_WARNINGS (3 Low findings — D-AZ795-1 FluentValidation
12.0.0 -> 12.1.1 recommended bump, F-AZ795-1 JsonException.Message
leak in 400 detail, F-AZ795-2 BadHttpRequestException.Message leak).
No Critical / High; auth runs before validation (confirmed in
Program.cs); two NuGet additions (FluentValidation 12.0.0 +
.DependencyInjectionExtensions 12.0.0) both CVE-clean. Per-phase
reports plus consolidated security_report_cycle7.md.

Step 15 (Performance Test): docker compose stack used for perf
run, scripts/run-performance-tests.sh exited 0 with 8/8 scenarios
PASS (second consecutive clean exit-0); added PT-09 cycle-7 smoke
probe (v2 z/x/y schema, 2500-tile all-miss batch) measuring
min=27ms median=44ms p95=73ms max=86ms (13.7x under AZ-505 AC-4
1000ms budget). PT-07/08 improvements traced to the cycle-6 TLS
handshake-overhead identification, not application-side change.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-22 11:24:27 +03:00
parent 865dfdb3b9
commit bc04ba7f99
17 changed files with 779 additions and 32 deletions
+24 -12
View File
@@ -10,7 +10,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|--------|-------|---------|-------------|
| GET | `/tiles/{z}/{x}/{y}` | `ServeTile` | Slippy map tile server with in-memory caching. AZ-505 rewired the DB lookup to filter on `location_hash` (deterministic UUIDv5) so the read becomes an `Index Only Scan` against `tiles_leaflet_path`; the wire response is byte-identical to pre-AZ-505. |
| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom |
| POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{tileZoom,tileX,tileY}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. Contract: `_docs/02_document/contracts/api/tile-inventory.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/request` | `RequestRegion` | Queue region for async tile processing |
@@ -32,12 +32,21 @@ Application entry point. Configures DI container, sets up middleware, defines mi
- `UavTileBatchUploadResponse`, `UavTileUploadResultItem` — per-item response shape
- `UavTileUploadStatus`, `UavTileRejectReasons` — string-constant enumerations exposed in the v1.0.0 contract
### Common/DTO (AZ-505)
### Common/DTO (AZ-505; renamed by AZ-794 in cycle 7)
- `TileInventoryRequest` — XOR body envelope with `Tiles` (Form A) OR `LocationHashes` (Form B)
- `TileCoord``{TileZoom, TileX, TileY}` per-entry coord under Form A
- `TileCoord``{Z, X, Y}` per-entry coord under Form A. Each property is marked `[JsonRequired]` so missing axes surface as `400` at the deserializer layer (System.Text.Json throws, `GlobalExceptionHandler` converts to `ValidationProblemDetails`).
- `TileInventoryResponse``{Results: TileInventoryEntry[]}` response shape; ordering matches request
- `TileInventoryEntry` — per-entry response shape (`Present`, `LocationHash`, optional `Id`/`CapturedAt`/`Source`/`FlightId`/`ResolutionMPerPx`)
- `TileInventoryLimits.MaxEntriesPerRequest` — hard cap (5000) consumed by request validation
- `TileInventoryEntry` — per-entry response shape (`Z`, `X`, `Y`, `LocationHash`, `Present`, optional `Id`/`CapturedAt`/`Source`/`FlightId`/`ResolutionMPerPx`)
- `TileInventoryLimits.MaxEntriesPerRequest` — hard cap (5000) consumed by `InventoryRequestValidator`
### Api/Validators (AZ-795 + AZ-796, cycle 7)
- `InventoryRequestValidator` — FluentValidation `AbstractValidator<TileInventoryRequest>`. Rules: XOR `tiles`/`locationHashes`, `tiles.Count ≤ MaxEntriesPerRequest`, `locationHashes.Count ≤ MaxEntriesPerRequest`, per-entry `TileCoordValidator`.
- `TileCoordValidator` — per-entry rules: `Z` ∈ [0, 22] (slippy-map range), `X` ∈ [0, 2^Z), `Y` ∈ [0, 2^Z).
- `ValidationEndpointFilter<T>` — generic minimal-API filter that resolves `IValidator<T>` from DI, runs it against the bound argument, and returns `Results.ValidationProblem(result.ToDictionary())` on failure. Wired per-endpoint via `RouteHandlerBuilder.WithValidation<T>()`.
- `GlobalValidatorConfig.ApplyOnce()` — idempotent process-wide FluentValidation configuration. Sets `ValidatorOptions.Global.PropertyNameResolver` so error map keys are camelCase per `error-shape.md` Inv-4. Called from `Program.cs` and from the test assembly's `ValidatorTestModuleInitializer` so both contexts see identical key shapes.
### Api/GlobalExceptionHandler (AZ-795, cycle 7)
- `GlobalExceptionHandler : IExceptionHandler` — registered via `AddExceptionHandler<GlobalExceptionHandler>()` + `AddProblemDetails()`. Intercepts unhandled exceptions and converts `BadHttpRequestException(JsonException)` (unknown-member rejection, missing-required-field, type mismatch) into RFC 7807 `ValidationProblemDetails` matching the FluentValidation output shape (single source of truth — see `error-shape.md` v1.0.0 §"Both paths produce identically-shaped bodies"). 5xx errors pass through with sanitised body + `correlationId` (preserves AZ-353).
## Internal Logic
@@ -53,6 +62,9 @@ Application entry point. Configures DI container, sets up middleware, defines mi
9. JSON options: camelCase, case-insensitive
10. **JWT authentication (AZ-487 + AZ-494)**: `AddSatelliteJwt(builder.Configuration)` (extension in `SatelliteProvider.Api.Authentication`) registers `JwtBearer` with `TokenValidationParameters` set per the suite auth contract: signature + lifetime + issuer + audience validation, 30 s clock skew, ≥ 32-byte HMAC key. The `iss` value comes from `JWT_ISSUER` env (fallback `Jwt:Issuer` config); the `aud` value comes from `JWT_AUDIENCE` env (fallback `Jwt:Audience` config). All three values (secret, iss, aud) are fail-fast — the API throws `InvalidOperationException` at startup if any is unset or whitespace-only. Production deploys MUST set the env vars with admin-team-confirmed values; `appsettings.json` ships empty so the fail-fast triggers. `appsettings.Development.json` ships clearly-tagged DEV-ONLY values (`DEV-ONLY-iss-admin-azaion-local` / `DEV-ONLY-aud-satellite-provider`) so local dev works out-of-the-box. Followed by `AddAuthorization` with the `RequiresGpsPermission` policy (AZ-488).
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.
### Startup
1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure
@@ -67,12 +79,12 @@ Application entry point. Configures DI container, sets up middleware, defines mi
3. If no DB record: downloads tile via `GoogleMapsDownloaderV2.DownloadSingleTileAsync`, creates `TileEntity`, inserts
4. Returns image bytes with cache headers (`Cache-Control: public, max-age=86400`)
### GetTilesInventory Handler (AZ-505)
1. Validates XOR body shape: 400 if both `tiles` and `locationHashes` are populated, 400 if neither is populated, 400 if either exceeds `TileInventoryLimits.MaxEntriesPerRequest` (5000)
2. Delegates to `ITileService.GetInventoryAsync(request, ct)`
3. Service computes `location_hash` for Form A entries via `Uuidv5.Create(TileNamespace, "{z}/{x}/{y}")`, calls `ITileRepository.GetTilesByLocationHashesAsync(IReadOnlyList<Guid>)`, re-aligns results back to input order
4. Returns `TileInventoryResponse` with one entry per input — `present=true` entries carry `id` / `capturedAt` / `source` / `flightId` / `resolutionMPerPx`; `present=false` entries carry only `locationHash`
5. Authenticated by `.RequireAuthorization()` (401 before handler for anonymous)
### GetTilesInventory Handler (AZ-505 + AZ-796 cycle 7)
1. **Pre-handler validation (cycle 7)**: `ValidationEndpointFilter<TileInventoryRequest>` runs BEFORE the handler. Resolves `InventoryRequestValidator` from DI and asserts XOR `tiles`/`locationHashes`, per-array cap (`TileInventoryLimits.MaxEntriesPerRequest = 5000`), `z` ∈ [0, 22], `x` ∈ [0, 2^z), `y` ∈ [0, 2^z) per entry. Any failure short-circuits with HTTP 400 + `ValidationProblemDetails`. Deserializer-layer failures (missing `z/x/y`, unknown root/nested fields, JSON type mismatch) are caught earlier by System.Text.Json and surfaced as identically-shaped `ValidationProblemDetails` via `GlobalExceptionHandler` (AZ-795).
2. Handler delegates to `ITileService.GetInventoryAsync(request, ct)` — body of the handler is just the service call + `Results.Ok`.
3. Service computes `location_hash` for Form A entries via `Uuidv5.Create(TileNamespace, "{z}/{x}/{y}")`, calls `ITileRepository.GetTilesByLocationHashesAsync(IReadOnlyList<Guid>)`, re-aligns results back to input order.
4. Returns `TileInventoryResponse` with one entry per input — `present=true` entries carry `id` / `capturedAt` / `source` / `flightId` / `resolutionMPerPx`; `present=false` entries carry only `locationHash`.
5. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests).
### GetTileByLatLon Handler
Downloads a tile, persists it, returns metadata as `DownloadTileResponse`.
@@ -85,7 +97,7 @@ Buffers each `IFormFile` into memory, packages them as `UavUploadFile` records (
## Dependencies
All project references: Common, DataAccess, Services.
NuGet: `Serilog.AspNetCore` (8.0.3 — fallback retained on .NET 10 per AZ-500 Risk #4: no 10.x line published as of cycle 4; documented in `AGENTS.md`), `Swashbuckle.AspNetCore` (10.1.7 — bumped from 6.6.2 by AZ-500 to land Microsoft.OpenApi 2.x compat required by ASP.NET Core 10), `Microsoft.AspNetCore.OpenApi` (10.0.7 — bumped from 8.0.25 by AZ-500), `Microsoft.AspNetCore.Authentication.JwtBearer` (10.0.7 — added at 8.0.21 by AZ-487, bumped to 8.0.25 by AZ-496, bumped to 10.0.7 by AZ-500), `SixLabors.ImageSharp`, `Newtonsoft.Json`.
NuGet: `Serilog.AspNetCore` (8.0.3 — fallback retained on .NET 10 per AZ-500 Risk #4: no 10.x line published as of cycle 4; documented in `AGENTS.md`), `Swashbuckle.AspNetCore` (10.1.7 — bumped from 6.6.2 by AZ-500 to land Microsoft.OpenApi 2.x compat required by ASP.NET Core 10), `Microsoft.AspNetCore.OpenApi` (10.0.7 — bumped from 8.0.25 by AZ-500), `Microsoft.AspNetCore.Authentication.JwtBearer` (10.0.7 — added at 8.0.21 by AZ-487, bumped to 8.0.25 by AZ-496, bumped to 10.0.7 by AZ-500), `FluentValidation` + `FluentValidation.DependencyInjectionExtensions` (12.0.0 — added by AZ-795 to back the strict-input-validation epic), `SixLabors.ImageSharp`, `Newtonsoft.Json`.
**Microsoft.OpenApi 2.x refactor note (AZ-500)**: the major bump (1.x → 2.x) drove three internal Swashbuckle-setup edits in this file — `using Microsoft.OpenApi.Models;``using Microsoft.OpenApi;`; `AddSecurityRequirement(...)` rewritten to take a `Func<OpenApiDocument, OpenApiSecurityRequirement>` and use `OpenApiSecuritySchemeReference("Bearer")` instead of the removed `OpenApiSecurityScheme.Reference` shape; `MapType<UavTileBatchUploadRequest>` rewritten to use the new `JsonSchemaType` enum and `IDictionary<string, IOpenApiSchema>` properties bag. The Swagger document shape (paths, operations, the Bearer Authorize button, the multipart-batch upload schema) is preserved exactly — `SwaggerDocument_AdvertisesBearerSecurityScheme` and the AZ-353 swagger-ready integration assertions still pass. Eight `ASPDEPR002` deprecation warnings (`WithOpenApi(...)`) remain — they're recorded in `_docs/03_implementation/reviews/batch_01_cycle4_review.md` as a follow-up PBI; the API is still fully functional in .NET 10 (deprecated, not removed).
+9 -7
View File
@@ -110,21 +110,23 @@ Authoritative reject-reason codes for the UAV upload quality gate. Adding a new
- `ImageTooUniform = "IMAGE_TOO_UNIFORM"` — Rule 5 (luminance variance below `MinLuminanceVariance`).
- `StorageFailure = "STORAGE_FAILURE"` — reserved for the orphan-row-recovery path when the on-disk write succeeds but the DB UPSERT fails; surfaced per-item without failing the envelope (AZ-488 Reliability NFR).
### TileCoord (added AZ-505)
### TileCoord (added AZ-505, renamed AZ-794 cycle 7)
Single tile coordinate triple used by the inventory endpoint Form A request shape and as the per-entry input echo on the response.
- `TileZoom` (int) — slippy zoom level.
- `TileX`, `TileY` (int) — slippy x/y at that zoom.
- Defined in `SatelliteProvider.Common/DTO/TileInventory.cs`. Matches `tile-inventory.md` v1.0.0 Shape.
- `Z` (int) `[JsonRequired]` — slippy zoom level. Wire name `"z"`.
- `X` (int) `[JsonRequired]` — slippy x at that zoom. Wire name `"x"`.
- `Y` (int) `[JsonRequired]` — slippy y at that zoom. Wire name `"y"`.
- Defined in `SatelliteProvider.Common/DTO/TileInventory.cs`. Matches `tile-inventory.md` v2.0.0 Shape (the rename from `tileZoom/tileX/tileY` shipped in AZ-794; the `[JsonRequired]` markers + the global `UnmappedMemberHandling.Disallow` mean missing axes and the legacy field names both surface as HTTP 400 with `ValidationProblemDetails` per `error-shape.md` v1.0.0).
### TileInventoryRequest (added AZ-505)
API request body for `POST /api/satellite/tiles/inventory`. Carries one of two XOR-exclusive batch shapes.
- `Tiles` (`IReadOnlyList<TileCoord>?`) — Form A: coords-by-value. The server computes `location_hash = Uuidv5(TileNamespace, "{z}/{x}/{y}")` per entry.
- `LocationHashes` (`IReadOnlyList<Guid>?`) — Form B: hashes-by-reference. Used when the caller already has UUIDv5 location hashes (typical for the onboard cross-repo path).
- Exactly one of `Tiles` / `LocationHashes` must be populated and non-empty; both-populated or neither → HTTP 400 (`tile-inventory.md` Inv-1).
- Exactly one of `Tiles` / `LocationHashes` must be populated and non-empty; both-populated or neither → HTTP 400 (`tile-inventory.md` v2.0.0 Inv-1, enforced by `InventoryRequestValidator` via `ValidationEndpointFilter<TileInventoryRequest>` in cycle 7).
- Total entries (in either field) ≤ `TileInventoryLimits.MaxEntriesPerRequest` (5000); over-cap → HTTP 400 (Inv-7).
### TileInventoryEntry (added AZ-505)
### TileInventoryEntry (added AZ-505, coord fields renamed AZ-794 cycle 7)
Per-entry result inside `TileInventoryResponse`. One entry per request entry, in the SAME order as the request (`tile-inventory.md` Inv-2).
- `Z`, `X`, `Y` (int) — echoed coord triple matching the request entry; wire names `"z"`, `"x"`, `"y"` (renamed from `"tileZoom"`/`"tileX"`/`"tileY"` by AZ-794). Always populated; when Form B was used, these are 0 (the caller already knows the hash).
- `LocationHash` (Guid) — always populated; UUIDv5 of `"{z}/{x}/{y}"` from `Uuidv5.LocationHashForTile` (Form A) or echoed from request (Form B).
- `Present` (bool) — `true` iff a row exists in `tiles` with this `location_hash` (Inv-4).
- `Id` (Guid?) — `tiles.id` of the most-recent row across sources/flights (`captured_at DESC, updated_at DESC, id DESC`, Inv-5); null when `Present=false` (Inv-6).
@@ -135,7 +137,7 @@ API response body for `POST /api/satellite/tiles/inventory`.
- `Results` (`IReadOnlyList<TileInventoryEntry>`) — one entry per request entry; `Results.Count` always equals the request entry count (Inv-2).
### TileInventoryLimits (added AZ-505, static constants)
- `MaxEntriesPerRequest = 5000` — request-body cap enforced by the inventory handler (Inv-7).
- `MaxEntriesPerRequest = 5000` — request-body cap enforced by `InventoryRequestValidator` (per-array cap; `tile-inventory.md` v2.0.0 Inv-7).
## Internal Logic
- `GeoPoint` uses a precision tolerance of `0.00005` degrees (~5.5 meters) for equality comparison.
@@ -15,6 +15,9 @@ Console application that runs end-to-end integration tests against a live API in
- `JwtIntegrationTests` (added by AZ-487 cycle 2; helpers consolidated by AZ-491 cycle 3; iss/aud scenarios added by AZ-494 cycle 3) — `AnonymousRequest_To_AnyEndpoint_Returns401`, `ExpiredToken_Returns401`, `InvalidSignature_Returns401`, `ValidToken_Returns200_OnHealthyEndpoint`, `WrongIssuer_Returns401` (AZ-494 AC-1), `WrongAudience_Returns401` (AZ-494 AC-2), `SwaggerDocument_AdvertisesBearerSecurityScheme`. HS256 token minting lives in the shared `SatelliteProvider.TestSupport.JwtTokenFactory` (consumed via `ProjectReference`); runner-specific concerns (`JwtTestHelpers.ResolveSecretOrThrow` / `ResolveIssuerOrThrow` / `ResolveAudienceOrThrow`, `MintAuthenticated` / `MintExpired` convenience wrappers that auto-fill iss+aud from env, `AttachDefaultAuthorization`, `DefaultSubject = "integration-tests"`) remain in this project. The test runner sets `JWT_SECRET` + `JWT_ISSUER` + `JWT_AUDIENCE` on the API container and attaches a Bearer token (with matching iss/aud) to every existing test's HTTP requests so the pre-cycle-2 suite continues to pass.
- `UavUploadTests` (added by AZ-488, cycle 2; coordinate-counter promoted to defense-in-depth by AZ-493 cycle 3; AZ-503 cycle 5 added 2 more tests) — `HappyPathSingleItem_PersistsRow`, `MixedBatch_ReturnsPerItemResults`, `MultiSourceCoexistence_AZ484_Cycle2`, `SameSourceUpsert_AZ484_Cycle2`, `NoToken_Returns401`, `ValidTokenWithoutGpsPermission_Returns403`, `OversizedBatch_Returns400`, plus AZ-503: `MultiFlightUavRowsCoexist_AZ503_AC3` (two flights at the same cell → two rows, one `location_hash`, two `file_path`s under `./tiles/uav/{flight_id}/...`) and `FloatRoundingDoesNotBreakIdempotence_AZ503_AC4` (two uploads with float-distinct `latitude` recomputed from `TileToWorldPos` collapse to a single row because the conflict key is integer-only). The AZ-503 migration made `location_hash NOT NULL`, so the cycle-2 `MultiSourceCoexistence_AZ484_Cycle2` seeder was updated to compute `location_hash` via `Uuidv5.Create` (canonical name `"{zoom}/0/0"`) before the raw SQL `INSERT` — this required adding a `ProjectReference` from `SatelliteProvider.IntegrationTests` to `SatelliteProvider.Common`. The wall-clock-seeded `_coordinateCounter` is retained as a belt-and-suspenders safeguard alongside the AZ-493 startup DB-reset (below) — if a developer runs with `--keep-state`, or the DB-reset path is skipped for any reason, the wall-clock seed still spreads coordinates across runs so the unique index does not collide.
- `StubAndErrorContractTests` (existing) — updated in cycle 2 to drop the legacy `StubUpload_Returns501` expectation since AZ-488 implemented the endpoint.
- `TileInventoryTests` (added cycle 6 — AZ-505) — `OrderingAndPresentAbsentShaping_AC1`, `LeafletReadReturnsMostRecentViaLocationHash_AC2`, `ValidationRejectsBothPopulated_AC6`, `ValidationRejectsNeitherPopulated_AC6`, `ValidationRejectsOversizedBatch_AC6`, `UnauthenticatedRequestReturns401_AC6`, `PerformanceBudget_AC4` (full-suite only). Tests are cycle-7-stable — they use the post-AZ-794 `{z, x, y}` wire shape and a minor x/y reduction was applied in cycle 7 to keep the synthetic coords within the z=18 slippy bounds enforced by `TileCoordValidator`.
- `TileInventoryValidationTests` (added cycle 7 — AZ-796) — 16 tests: `HappyPath_Returns200`, `EmptyBody_Returns400`, `NeitherPopulated_Returns400`, `BothPopulated_Returns400`, `EmptyTilesArray_Returns400`, `TilesOverCap_Returns400`, `MissingZ_Returns400WithFieldPath`, `MissingXAndY_Returns400`, `ZoomOutOfRange_Returns400WithFieldPath`, `XBeyondZoomBounds_Returns400`, `YBeyondZoomBounds_Returns400`, `NegativeAxis_Returns400`, `UnknownRootField_Returns400`, `UnknownNestedField_Returns400`, `OldV1FieldName_Returns400` (AZ-794 + AZ-796 intersection — exact AZ-777 Phase 1 reproducer body, asserts legacy `tileZoom/tileX/tileY` now yields 400), `TypeMismatch_Returns400`. Each test exercises one of the 9 validation rules end-to-end through `ValidationEndpointFilter<TileInventoryRequest>` + `GlobalExceptionHandler`, asserts HTTP 400 + RFC 7807 `ValidationProblemDetails` shape via the shared `ProblemDetailsAssertions` helper.
- `IdempotentPostTests` — pre-existing; cycle 7 adjusted the route-point payload from PascalCase (`Latitude`/`Longitude`) to camelCase (`lat`/`lon`) because the post-AZ-795 `UnmappedMemberHandling.Disallow` would otherwise reject the previously-silently-ignored fields. The `RoutePoint` DTO has carried `JsonPropertyName("lat"/"lon")` since AZ-309; cycle 7's strict JSON parsing exposed the test was sending the wrong shape and getting away with it via the pre-cycle-7 permissive deserializer.
### Supporting Classes
- `Models.cs` — HTTP response DTOs for deserialization
@@ -30,6 +33,7 @@ Console application that runs end-to-end integration tests against a live API in
- Token *minting* lives in the shared `SatelliteProvider.TestSupport.JwtTokenFactory` (AZ-491) — runner-side concerns (env reads, HttpClient mutation, the iss/aud-aware mint wrapper) deliberately stay here.
- `IntegrationTestDatabaseReset.cs` (AZ-493) — instance class with a single `EnsureCleanStateAsync()` method that truncates the integration-test target tables in FK-safe order. Guarded via `SatelliteProvider.TestSupport.IntegrationTestResetGuard` (env + Host allowlist) so it cannot run against a non-test database.
- `PerfBootstrap.cs` (AZ-492) — static helpers for the perf harness bootstrap subcommands. `MintToken()` mints a 4-hour HS256 token with subject `perf-tests` and a `permissions: GPS` claim via the canonical `SatelliteProvider.TestSupport.JwtTokenFactory.Create`; `GenerateUavFixture(args)` writes a 256×256 random-noise JPEG via `SixLabors.ImageSharp` to the path passed on the CLI. Invoked from `scripts/run-performance-tests.sh` via `dotnet <SatelliteProvider.IntegrationTests.dll> --mint-only` and `--gen-uav-fixture <path>`.
- `ProblemDetailsAssertions.cs` (added cycle 7 — AZ-795) — shared static helpers for asserting RFC 7807 ProblemDetails bodies on integration-test responses. `ReadProblemDetailsAsync(HttpResponseMessage, label)` deserialises the response body into a `JsonElement` with helpful failure messages when the content-type / shape doesn't match. `AssertProblemDetails(problem, expectedStatus, label)` asserts the base ProblemDetails shape (`type`, `title`, `status`). `AssertValidationProblem(problem, expectedStatus, label, expectedErrorPath?, expectedErrorContains?)` extends the base assertion to require the `errors` map per `error-shape.md` Inv-2 and optionally checks a specific field path / message substring. Consumed by `TileInventoryValidationTests`; designed to be reused by every future per-endpoint child task under AZ-795.
## Internal Logic
- Makes HTTP calls to the API at `API_URL` environment variable (default: `http://api:8080`)
+7 -2
View File
@@ -23,17 +23,22 @@ Existing baseline (pre-cycle-2) test classes cover `TileService`, `RegionService
- `Uuidv5Tests` — pure-C# UUIDv5 generator parity tests. `Create_MatchesPythonReferenceVectors_AC1` pins 10 reference vectors generated by Python's `uuid.uuid5(TILE_NAMESPACE, name)`; `Create_IsDeterministic` asserts repeated runs return the same `Guid`; `Create_SetsVersionAndVariantBits` asserts the version nibble is `5` and the variant top-2-bits are `10` (RFC 9562 §5.5).
- `UavTileFilePathTests` (rewritten for AZ-503 from the cycle-2 placeholder) — covers `BuildUavTileFilePath(Guid? flightId, int z, int x, int y)` across three cases: `BuildUavTileFilePath_AnonymousFlight_UsesNoneSegment` (null `flightId` → literal `none` segment), `BuildUavTileFilePath_PerFlight_UsesFlightIdDirectory` (per-flight segment), `BuildUavTileFilePath_DifferentFlights_ProduceDifferentPaths` (path-distinctness across flights at the same cell). Integer-typed coordinates and the `Guid? flightId` parameter together still preclude string-injection path traversal.
### AZ-795 + AZ-796 — strict inventory validation (cycle 7)
- `Validators/InventoryRequestValidatorTests` (added cycle 7 — AZ-796) — 16 tests against `InventoryRequestValidator` + `TileCoordValidator` in isolation via FluentValidation's `TestValidate(...)` test helper. Covers every `RuleFor(...)`: `Validate_TilesPopulated_LocationHashesNull_Passes`, `Validate_LocationHashesPopulated_TilesNull_Passes`, `Validate_BothPopulated_FailsXorRule`, `Validate_NeitherPopulated_FailsXorRule`, `Validate_BothEmpty_FailsXorRule`, `Validate_TilesAtCap_Passes`, `Validate_TilesOverCap_FailsCapRule`, `Validate_LocationHashesOverCap_FailsCapRule`, `Validate_TileZoomOutOfRange_FailsRangeRule` (`[Theory]` with z ∈ {-1, 23, 100}), `Validate_TileZoomInRange_PassesRangeRule` (`[Theory]` with z ∈ {0, 18, 22}), `Validate_TileXNegative_FailsRangeRule`, `Validate_TileXAtUpperBound_FailsRangeRule`, `Validate_TileYNegative_FailsRangeRule`, `Validate_TileYAtUpperBound_FailsRangeRule`, `Validate_AxesAtMaxForZoom_Passes`.
- `TestSupport/ValidatorTestModuleInitializer.cs` (added cycle 7 — AZ-795) — `[ModuleInitializer]` that calls `GlobalValidatorConfig.ApplyOnce()` at test-assembly load time. Ensures unit tests see the same camelCase property-name resolution that `Program.cs` configures for the running API, so validator error keys (e.g., `tiles[0].z`) match the runtime contract per `error-shape.md` v1.0.0 Inv-4 without forcing every test to re-run the setup.
## Internal Logic
- Tests follow Arrange / Act / Assert. Time-dependent paths inject a `FixedTimeProvider` (cycle-2 addition) so Rule 4 has deterministic age windows.
- `JwtSecurityTokenHandler.MapInboundClaims = false` is set explicitly in JWT tests so claims read by their original names (`sub`, `permissions`, …) rather than the framework-remapped names.
- Cycle 7 also added validator-isolated assertions via FluentValidation's `TestValidate(...)` helper (no HTTP, no DI container) — the matching end-to-end assertions live in `SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs`.
## Dependencies
- Project references: `SatelliteProvider.Services.TileDownloader`, `SatelliteProvider.Services.RegionProcessing`, `SatelliteProvider.Services.RouteManagement`, `SatelliteProvider.Common`, `SatelliteProvider.DataAccess`, `SatelliteProvider.Api` (for the Authentication tests — added in AZ-487), `SatelliteProvider.TestSupport` (added by AZ-491; provides the canonical `JwtTokenFactory` consumed by both this project and `SatelliteProvider.IntegrationTests`).
- NuGet: xUnit (2.5.3), Moq (4.20.72), FluentAssertions (8.8.0), coverlet.collector (6.0.0), Microsoft.NET.Test.Sdk (17.8.0), Microsoft.Extensions.* (Caching.Memory, Configuration, DI, Logging, Options, Http — all bumped from 9.0.10 → 10.0.7 by AZ-500 as a coordinated cycle-4 move), `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.7 (consumed transitively via the `ProjectReference` to `SatelliteProvider.Api`; AZ-487 added the dependency at 8.0.21, AZ-496 bumped it to 8.0.25, AZ-500 bumped it to 10.0.7), `SixLabors.ImageSharp` 3.1.11 (added by AZ-488 for the gate tests).
- NuGet: xUnit (2.5.3), Moq (4.20.72), FluentAssertions (8.8.0), coverlet.collector (6.0.0), Microsoft.NET.Test.Sdk (17.8.0), Microsoft.Extensions.* (Caching.Memory, Configuration, DI, Logging, Options, Http — all bumped from 9.0.10 → 10.0.7 by AZ-500 as a coordinated cycle-4 move), `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.7 (consumed transitively via the `ProjectReference` to `SatelliteProvider.Api`; AZ-487 added the dependency at 8.0.21, AZ-496 bumped it to 8.0.25, AZ-500 bumped it to 10.0.7), `SixLabors.ImageSharp` 3.1.11 (added by AZ-488 for the gate tests), `FluentValidation` + `FluentValidation.TestHelper` 12.0.0 (added cycle 7 — AZ-795; the test helper drives the `TestValidate(...)` assertions used by `InventoryRequestValidatorTests`).
- `appsettings.json` copied to output (used by Authentication tests for the `Jwt` section binding scenario).
## Consumers
- CI pipeline (`01-test.yml`) and `scripts/run-tests.sh --unit-only` run `dotnet test` against this project.
## Tests
This IS the test module. Cycle-2 added ~25 unit tests on top of the existing baseline; cycle-5 (AZ-503) added 6 more (3 in `Uuidv5Tests`, 3 in `UavTileFilePathTests`) plus 2 new methods in `UavTileUploadHandlerTests`. The full project executes in seconds (no external services required).
This IS the test module. Cycle-2 added ~25 unit tests on top of the existing baseline; cycle-5 (AZ-503) added 6 more (3 in `Uuidv5Tests`, 3 in `UavTileFilePathTests`) plus 2 new methods in `UavTileUploadHandlerTests`. Cycle 7 (AZ-795 + AZ-796) added 16 more in `InventoryRequestValidatorTests` covering every `RuleFor(...)` in the cycle's new validators. The full project executes in seconds (no external services required). Cycle 7 Step 11 reported the unit suite at 311 tests, all green.