[AZ-503] [AZ-504] Cycle 5 Steps 11-15 sync

Wrap up cycle 5 verification + documentation:
- Steps 10/11 wrap-up reports (implementation_completeness +
  implementation_report) for the AZ-503-foundation + AZ-504 batch.
- Step 12 test-spec sync: AZ-503-foundation/AZ-504 ACs appended;
  AZ-505 deferred ACs recorded.
- Step 13 update-docs: architecture, data-model, glossary, module-
  layout, uav-tile-upload contract (v1.1.0), DataAccess + Services
  + Tests module docs synced; new common_uuidv5.md module doc.
- Step 14 security audit: PASS_WITH_WARNINGS; 0 new Critical/High;
  2 new Low informational (F1 flightId provenance, F2 pgcrypto
  deploy gap).
- Step 15 performance test: PASS_WITH_INFRA_WARNINGS; PT-08
  passed twice (AZ-504 fix verified); PT-01/02 failed due to
  recurring local Docker/colima DNS cold-start (not an app
  regression). Cycle-3 perf-harness leftover stays OPEN with
  replay #5 documented.
- Autodev state moved to Step 16 (Deploy).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 18:01:27 +03:00
parent c646aa93e2
commit 61612044fb
27 changed files with 1075 additions and 50 deletions
+2 -1
View File
@@ -72,12 +72,13 @@ Axis-aligned bounding box defined by NW and SE corners.
Container for multiple geofence polygons.
- `Polygons` (List\<GeofencePolygon\>)
### UavTileMetadata (added AZ-488)
### UavTileMetadata (added AZ-488, extended AZ-503)
Per-tile metadata payload inside a UAV batch upload (`POST /api/satellite/upload`). Indexed-correlated with the multipart `IFormFileCollection`.
- `Latitude`, `Longitude` (double)
- `TileZoom` (int)
- `TileSizeMeters` (double)
- `CapturedAt` (DateTime, UTC; subject to AZ-488 Rule 4 future-skew / age checks)
- `FlightId` (Guid?, JSON: `"flightId"`) — AZ-503 optional flight identifier. When set, the per-item `tiles.id` becomes `Uuidv5(TileNamespace, "{z}/{x}/{y}/uav/{flightId}")`, the on-disk path is `./tiles/uav/{flightId}/{z}/{x}/{y}.jpg`, and the UPSERT conflict key separates this row from rows belonging to other flights at the same cell. When `null`, the per-item id uses the zero-UUID `00000000-0000-0000-0000-000000000000` placeholder and the on-disk path uses the literal `none` segment (`./tiles/uav/none/{z}/{x}/{y}.jpg`). The placeholder UUID is purely a key-space marker — it never lands in the `flight_id` column (which stays `NULL`); the UPSERT uses `COALESCE(flight_id, '00000000-...')` for the conflict check.
### UavTileBatchMetadataPayload (added AZ-488)
JSON envelope deserialized from the `metadata` form field of a UAV batch upload.
@@ -0,0 +1,76 @@
# Module: Common/Utils/Uuidv5
## Purpose
Deterministic UUIDv5 generator (RFC 9562 §5.5, SHA-1 namespace+name hashing) for tile identity. Pure C# implementation, ≤80 LoC, no third-party dependency. Owns the cross-repo `TileNamespace` constant that pins UUIDv5 outputs to be byte-identical between this workspace (C#) and the sibling `gps-denied-onboard` workspace (Python `uuid.uuid5`).
**csproj**: `SatelliteProvider.Common/Utils/Uuidv5.cs`
**Introduced**: AZ-503 (Cycle 5)
## Public Interface
All members are static on `Uuidv5`:
- `TileNamespace` (Guid, public const) — `5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c`. The shared namespace UUID used for every tile identity computation in this service and its onboard counterpart. **MUST NOT be changed** without coordinating a migration with `gps-denied-onboard/components/c6_tile_cache/_uuid.py`.
- `Create(Guid namespaceId, string name) → Guid` — produces a deterministic UUIDv5 by hashing `namespaceId.ToByteArrayBigEndian() || Encoding.UTF8.GetBytes(name)` with SHA-1, then assembling the 16 bytes per RFC 9562:
- bytes 03 are read as a big-endian uint32 (`time_low`)
- bytes 45 are read as a big-endian uint16 (`time_mid`)
- bytes 67 have their top 4 bits set to `0101` (version 5)
- byte 8 has its top 2 bits set to `10` (variant RFC 4122 / 9562)
- bytes 815 form the variant + clock_seq + node fields
- `Create(Guid namespaceId, ReadOnlySpan<byte> name) → Guid` — same as above but accepts a pre-encoded byte span; useful when the caller already has UTF-8 bytes or wants to avoid an intermediate string allocation.
## Internal Logic
- The .NET 10 `Guid.ToByteArray()` method emits the first three fields in little-endian (Microsoft historical behavior); RFC 9562 requires big-endian. The module uses a local `ToBigEndianByteArray(Guid)` helper that byte-swaps the first 4 bytes (time_low), the next 2 bytes (time_mid), and the next 2 bytes (time_hi_and_version) to produce the canonical big-endian layout before hashing. The same byte-swap is reversed when assembling the output `Guid` from the hash digest, so the in-memory `Guid` value still round-trips through `ToString()` to the expected hex form.
- SHA-1 is invoked via `SHA1.HashData(buffer)` (.NET 7+) which produces the 20-byte digest in one shot; only the first 16 bytes feed the resulting UUID (per RFC).
- The function is allocation-light for typical tile-key sizes: the hash input buffer is stack-allocated via `Span<byte>` when the namespace+name byte-length fits in 1024 bytes (always true for `{z}/{x}/{y}` and `{z}/{x}/{y}/{source}/{flight_id}` strings); larger payloads fall back to a pooled `byte[]`.
- The function is thread-safe (no shared mutable state).
## Reference Vectors
`SatelliteProvider.Tests/Uuidv5Tests.cs` pins 10 reference vectors generated by Python (`uuid.uuid5(TILE_NAMESPACE, name)`). Each vector pairs an input `name` with the expected `Guid` string. The C# implementation must produce byte-identical output. Two representative pairs:
| Name | Expected UUIDv5 |
|------|-----------------|
| `"18/12345/23456"` | `38b26f49-a966-5121-aaf4-9cc476f57869` |
| `"18/12345/23456/google_maps/00000000-0000-0000-0000-000000000000"` | `e228d1aa-25d4-556e-a72d-e0484756e165` |
The second value is observable end-to-end: a fresh `GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18` returns `tileId = e228d1aa-25d4-556e-a72d-e0484756e165` because `(47.461747, 37.647063)` maps to slippy `(z=18, x=158485, y=91707)` — and the integration test asserts that exact value.
## Dependencies
- `System.Security.Cryptography.SHA1`
- `System.Buffers.Binary.BinaryPrimitives` (for big-endian byte-swaps)
- `System.Buffers.ArrayPool<byte>` (for the >1024-byte fallback path)
No third-party packages. No NuGet additions for AZ-503.
## Consumers
- `SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity` — computes `Id` and `LocationHash` for every newly downloaded Google Maps tile.
- `SatelliteProvider.Services.TileDownloader.UavTileUploadHandler.PersistAsync` — computes `Id` and `LocationHash` for every UAV upload.
- `SatelliteProvider.IntegrationTests.UavUploadTests` — seeds `location_hash` values via raw SQL when bypassing the application code path.
- `SatelliteProvider.IntegrationTests.MigrationTests` — generates expected UUIDv5 outputs to validate migration 014's `pg_temp.uuidv5` PL/pgSQL backfill function.
## Data Models
Operates only on `Guid` and `string` / `Span<byte>`. No persistence model.
## Configuration
None. The namespace constant is pinned in source.
## External Integrations
None (pure computation).
## Security
The function is deterministic by design — it is NOT a cryptographic hash for security purposes. Two callers with the same `(namespace, name)` will always produce the same output. Treat the result as a content/location handle, not a secret. SHA-1 is used for RFC 9562 compatibility, not for collision resistance against an adversary.
## Tests
`SatelliteProvider.Tests/Uuidv5Tests.cs`:
- `Create_MatchesPythonReferenceVectors_AC1` — 10 reference vectors (AZ-503 AC-1).
- `Create_IsDeterministic` — re-running with the same inputs returns the same `Guid`.
- `Create_SetsVersionAndVariantBits` — asserts the version nibble is `5` and the variant top-2-bits are `10`.
@@ -23,7 +23,7 @@ Runs DbUp-based SQL migrations against PostgreSQL on application startup. Ensure
## Consumers
- `Program.cs` — instantiated directly (not via DI) and called during startup. If migration fails, the application throws and does not start.
## Migrations (13 scripts)
## Migrations (14 scripts)
1. `001_CreateTilesTable.sql`
2. `002_CreateRegionsTable.sql`
3. `003_CreateIndexes.sql`
@@ -37,6 +37,7 @@ Runs DbUp-based SQL migrations against PostgreSQL on application startup. Ensure
11. `011_AddTileCoordinates.sql`
12. `012_DropTileVersionConstraint.sql` — drops the legacy 5-col `(latitude, longitude, tile_zoom, tile_size_meters, version)` unique index, replaces with 4-col `idx_tiles_unique_location` (preparation for AZ-484).
13. `013_AddTileSourceAndCapturedAt.sql` — AZ-484 multi-source tile storage. Transactional. Adds `source` (VARCHAR(32) NOT NULL DEFAULT 'google_maps') and `captured_at` (TIMESTAMP NOT NULL) columns; backfills existing rows with `source='google_maps'`, `captured_at=created_at`; drops `idx_tiles_unique_location` and creates 5-col `idx_tiles_unique_location_source` on `(latitude, longitude, tile_zoom, tile_size_meters, source)`. Idempotent against partial replays.
14. `014_AddTileIdentityColumns.sql` — AZ-503 tile-identity foundation. Transactional. Enables the `pgcrypto` extension (`CREATE EXTENSION IF NOT EXISTS pgcrypto`) for the in-migration SHA-1 digest. Adds `flight_id` (UUID NULL), `location_hash` (UUID — backfilled then set NOT NULL), `content_sha256` (BYTEA NULL), `legacy_id` (UUID NULL). Defines a transactional `pg_temp.uuidv5(namespace, name)` PL/pgSQL function that mirrors `SatelliteProvider.Common.Utils.Uuidv5.Create` byte-for-byte, then backfills `location_hash = pg_temp.uuidv5(TILE_NAMESPACE, '{tile_zoom}/{tile_x}/{tile_y}')` and `legacy_id = id` for every pre-existing row. Drops AZ-484's `idx_tiles_unique_location_source` and creates `idx_tiles_unique_identity` UNIQUE on `(tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid))` plus a non-unique `idx_tiles_location_hash` on `(location_hash)`. Safe to replay on a partially-migrated database because column adds are `IF NOT EXISTS`-equivalent and `pg_temp.uuidv5` is deterministic — re-running yields the same `location_hash` values.
## Configuration
Receives connection string directly as constructor parameter.
@@ -7,12 +7,17 @@ Database entity classes that map directly to PostgreSQL tables via Dapper. Prope
### TileEntity
Maps to `tiles` table.
- `Id` (Guid), `TileZoom` (int), `TileX` (int), `TileY` (int)
- `Id` (Guid) — AZ-503: deterministic UUIDv5 of `{tile_zoom}/{tile_x}/{tile_y}/{source}/{flight_id or '00000000-0000-0000-0000-000000000000'}` under namespace `Uuidv5.TileNamespace`. Stable across re-ingests; preserved on UPSERT conflict.
- `TileZoom` (int), `TileX` (int), `TileY` (int)
- `Latitude`, `Longitude` (double), `TileSizeMeters` (double), `TileSizePixels` (int)
- `ImageType` (string), `MapsVersion` (string?), `Version` (int) — `MapsVersion`/`Version` are vestigial post-AZ-484 (kept nullable for backward compatibility; no longer part of the unique key)
- `Source` (string) — AZ-484 producer wire value, defaults to `TileSourceConverter.GoogleMapsWireValue` (`"google_maps"`). Stored as plain string (not the `TileSource` enum) due to Dapper issue #259 — see `_docs/LESSONS.md` L-001. Convert via `SatelliteProvider.Common.Enums.TileSourceConverter.{ToWireValue,FromWireValue}`.
- `CapturedAt` (DateTime, UTC) — AZ-484 imagery acquisition timestamp; drives the most-recent-across-sources selection.
- `FilePath` (string), `CreatedAt`, `UpdatedAt` (DateTime)
- `FlightId` (Guid?) — AZ-503: optional flight identifier. `null` for Google Maps tiles; populated from `UavTileMetadata.FlightId` on UAV uploads. Part of the AZ-503 UPSERT conflict key via `COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)`, so two flights uploading the same `(z, x, y)` cell produce two separate rows.
- `LocationHash` (Guid) — AZ-503 `NOT NULL`: deterministic UUIDv5 of `{tile_zoom}/{tile_x}/{tile_y}` under `Uuidv5.TileNamespace`. Identical across flights and sources for the same cell. Backfilled in migration 014 via a `pg_temp.uuidv5` PL/pgSQL function; subsequent inserts compute it in the application layer (`TileService` + `UavTileUploadHandler`). Reserved for AZ-505's Leaflet covering index (`POST /tiles/inventory`) — not yet on a unique constraint.
- `ContentSha256` (byte[]?) — AZ-503: SHA-256 digest of the JPEG body. Application code enforces `NOT NULL` for new writes via `TileService.BuildTileEntity` (Google Maps) and `UavTileUploadHandler.PersistAsync` (UAV). The DB column is `bytea NULL` because legacy pre-migration rows could not be backfilled reliably from disk (file paths are volatile). See `batch_02_cycle5_report.md` "Low maintainability finding" for the rationale.
- `LegacyId` (Guid?) — AZ-503: pre-migration `id` value, populated by migration 014 from every existing row's `id`. Preserves random-`Guid` provenance for one cycle (per AZ-503 Risk 1 mitigation) so external references to the old id can still be diagnosed before deletion.
### RegionEntity
Maps to `regions` table.
@@ -9,8 +9,8 @@ Dapper-based repository for the `tiles` table. Handles CRUD operations and spati
- `GetByIdAsync(Guid id) → Task<TileEntity?>`
- `GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY) → Task<TileEntity?>`: finds the most-recent tile across all sources for the given slippy coordinates. Selection rule: `ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1` (AZ-484 v1.0.0 contract).
- `GetTilesByRegionAsync(double lat, double lon, double sizeMeters, int zoomLevel) → Task<IEnumerable<TileEntity>>`: spatial bounding box query (expanded by 2 × tile size to cover edges); applies `DISTINCT ON (latitude, longitude, tile_zoom, tile_size_meters)` per AZ-484 to return at most one row per cell — the most-recent across sources — preserving the historical caller-facing order `latitude DESC, longitude ASC`.
- `InsertAsync(TileEntity tile) → Task<Guid>`: per-source UPSERT — `ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source) DO UPDATE file_path, tile_x, tile_y, captured_at, updated_at` (AZ-484 5-column unique key).
- `UpdateAsync(TileEntity tile) → Task<int>`: full row update by `id` including `source` and `captured_at`.
- `InsertAsync(TileEntity tile) → Task<Guid>`: AZ-503 integer-only + flight-aware UPSERT — `ON CONFLICT (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO UPDATE file_path, latitude, longitude, captured_at, location_hash, content_sha256, updated_at`. `id` is intentionally NOT overwritten on conflict — preserves AZ-503 AC-2 idempotence (same inputs ⇒ same `id`). Supersedes the AZ-484 5-column float-based unique key (`idx_tiles_unique_location_source`).
- `UpdateAsync(TileEntity tile) → Task<int>`: full row update by `id` including `source`, `captured_at`, `flight_id`, `location_hash`, and `content_sha256`.
- `DeleteAsync(Guid id) → Task<int>`
### TileRepository (implementation)
@@ -18,8 +18,8 @@ Constructs a new `NpgsqlConnection` per method call (no connection pooling at th
## Internal Logic
- `GetTilesByRegionAsync` calculates a bounding box by expanding the requested region by 2 × tile size to ensure edge tiles are included. Uses meters-to-degrees approximation via `GeoUtils` (post-AZ-377 — single source of truth for Earth constants).
- `InsertAsync` uses a per-source UPSERT pattern keyed on the 5-column unique index `idx_tiles_unique_location_source` (created by migration 013). Two producers (e.g., `google_maps` + `uav`) coexist for the same cell; same-source re-insert refreshes `captured_at` and `updated_at`.
- `GetByTileCoordinatesAsync` and `GetTilesByRegionAsync` apply the AZ-484 selection rule: most-recent across sources, deterministic tie-break on `(captured_at DESC, updated_at DESC, id DESC)`.
- `InsertAsync` uses the AZ-503 integer-only + flight-aware UPSERT keyed on `idx_tiles_unique_identity` (created by migration 014, replacing the AZ-484 `idx_tiles_unique_location_source`). The conflict key uses `COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)` so anonymous (`flight_id IS NULL`) and per-flight UAV rows share a flat key space. Two producers (`google_maps` + `uav`) at the same cell with the same `flight_id` (typically `NULL` for `google_maps`) still coexist via `source` discrimination. Same-source same-flight re-insert refreshes `file_path`, `latitude`, `longitude`, `captured_at`, `location_hash`, `content_sha256`, `updated_at` — but NOT `id` (idempotence — AZ-503 AC-2).
- `GetByTileCoordinatesAsync` and `GetTilesByRegionAsync` apply the AZ-484 selection rule unchanged: most-recent across sources, deterministic tie-break on `(captured_at DESC, updated_at DESC, id DESC)`. AZ-503 does NOT rewrite the read path to use `location_hash` — that's deferred to AZ-505 alongside the Leaflet covering index.
- `TileEntity.Source` is a plain `string` storing the snake_case wire value (`'google_maps'` | `'uav'`); enum<->wire conversion happens via `SatelliteProvider.Common.Enums.TileSourceConverter`. This avoids Dapper issue #259 (TypeHandler<T> bypass for enum reads — see `_docs/LESSONS.md` L-001).
- `FindExistingTileAsync` was removed by AZ-376 (see `_docs/04_refactoring/03-code-quality-refactoring/`).
@@ -30,7 +30,7 @@ Constructs a new `NpgsqlConnection` per method call (no connection pooling at th
- `Microsoft.Extensions.Logging`
## Contract
Implements the frozen v1.0.0 contract `_docs/02_document/contracts/data-access/tile-storage.md`. Schema invariants Inv-1..Inv-5 are enforced here (UPSERT key, selection rule, source value space).
Implements the frozen v1.0.0 contract `_docs/02_document/contracts/data-access/tile-storage.md` plus the AZ-503-introduced columns (`flight_id`, `location_hash`, `content_sha256`, `legacy_id`) and the integer-only UPSERT key. Schema invariants Inv-1..Inv-5 (UPSERT semantics, selection rule, source value space) are preserved; the only contract change is that the UPSERT conflict detection no longer depends on bit-identical float `latitude`/`longitude` (AZ-503 AC-4).
## Consumers
- `TileService` — all read/write operations
@@ -21,7 +21,8 @@ Orchestrates tile downloading and persistence. Bridges the downloader (Google Ma
## Internal Logic
- New rows write `Version = null` and `MapsVersion = null` (post-AZ-357 / AZ-373); the `version` and `maps_version` columns are retained for backward compatibility with pre-existing rows
- AZ-484: `BuildTileEntity` stamps every newly downloaded row with `Source = TileSourceConverter.ToWireValue(TileSource.GoogleMaps)` (wire value `"google_maps"`) and `CapturedAt = DateTime.UtcNow`. The Google Maps download path is the only producer of `'google_maps'` rows; UAV ingestion (separate task) is the only producer of `'uav'` rows.
- `MapToMetadata(TileEntity) → TileMetadata`: entity-to-DTO mapping (static helper); `MapsVersion` is no longer projected onto `TileMetadata` / `DownloadTileResponse`. `Source` and `CapturedAt` are not currently projected to the public DTO (no API contract change observable for AZ-484).
- AZ-503: `BuildTileEntity` computes deterministic identity fields — `Id = Uuidv5.Create(Uuidv5.TileNamespace, "{z}/{x}/{y}/google_maps/00000000-0000-0000-0000-000000000000")`, `LocationHash = Uuidv5.Create(Uuidv5.TileNamespace, "{z}/{x}/{y}")`, `ContentSha256 = SHA256.HashData(<jpeg bytes from disk>)`. `FlightId` is always `null` for Google Maps tiles. No `Guid.NewGuid()` remains on this path.
- `MapToMetadata(TileEntity) → TileMetadata`: entity-to-DTO mapping (static helper); `MapsVersion` is no longer projected onto `TileMetadata` / `DownloadTileResponse`. `Source`, `CapturedAt`, `FlightId`, `LocationHash`, `ContentSha256` are not currently projected to the public DTO (no API contract change observable for AZ-484 or AZ-503).
- `TileSizePixels` sourced from `MapConfig.TileSizePixels` (default 256, post-AZ-371); image type fixed at `"jpg"`
- `IMemoryCache` keyed by `(z, x, y)` with 1h absolute / 30min sliding expiration; populated on first hit and on downloader fallback
@@ -31,6 +32,8 @@ Orchestrates tile downloading and persistence. Bridges the downloader (Google Ma
- `IMemoryCache` (registered by `AddTileDownloader()`)
- `SatelliteProvider.Common.DTO` — GeoPoint, TileMetadata, TileBytes
- `SatelliteProvider.Common.Enums``TileSource`, `TileSourceConverter` (AZ-484)
- `SatelliteProvider.Common.Utils.Uuidv5` (AZ-503) — deterministic UUIDv5 generator + `TileNamespace` constant
- `System.Security.Cryptography.SHA256` (AZ-503) — content digest
- `SatelliteProvider.DataAccess.Models` — TileEntity
## Consumers
@@ -11,9 +11,9 @@ Console application that runs end-to-end integration tests against a live API in
- `BasicRouteTests` — route creation with intermediate points
- `ComplexRouteTests` — routes with geofencing
- `ExtendedRouteTests` — routes with `requestMaps: true` and tile ZIP creation
- `MigrationTests` — direct PostgreSQL schema/index validation (no HTTP). AZ-484 cycle added: `NewUniqueConstraintIncludesSourceColumn_AZ484_AC1`, `BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4`, `MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1`, `MostRecentAcrossSourcesSelection_AZ484_AC2`, `SameSourceUpsertReplacesPreviousRow_AZ484_AC3` (latter four use temp tables to keep production data untouched).
- `MigrationTests` — direct PostgreSQL schema/index validation (no HTTP). AZ-484 cycle added: `BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4`, `MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1`, `MostRecentAcrossSourcesSelection_AZ484_AC2`, `SameSourceUpsertReplacesPreviousRow_AZ484_AC3` (latter four use temp tables to keep production data untouched). AZ-503 (cycle 5) added: `Az503ColumnsExistAndLocationHashIsNotNull` (asserts the 4 new columns + `location_hash NOT NULL`), `Az503NewUniqueIndexCoversIntegerKeyAndFlightId` (asserts `idx_tiles_unique_identity` columns + `COALESCE(flight_id, ...)` predicate), `Az503LocationHashBackfillIsDeterministic` (computes `pg_temp.uuidv5("18/12345/23456")` and compares byte-for-byte against the C# `Uuidv5.Create` output on 3 sampled live rows); the AZ-484 supersession test was renamed to `Az503MigrationSupersedesAz484UniqueIndex` and asserts `idx_tiles_unique_location_source` no longer exists.
- `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) — `HappyPathSingleItem_PersistsRow`, `MixedBatch_ReturnsPerItemResults`, `MultiSourceCoexistence_AZ484_Cycle2`, `SameSourceUpsert_AZ484_Cycle2`, `NoToken_Returns401`, `ValidTokenWithoutGpsPermission_Returns403`, `OversizedBatch_Returns400`. 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 per-source unique index does not collide.
- `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.
### Supporting Classes
@@ -41,6 +41,7 @@ Console application that runs end-to-end integration tests against a live API in
- `ProjectReference` to `SatelliteProvider.TestSupport` (added by AZ-491; provides `JwtTokenFactory`. Added by AZ-493; provides `IntegrationTestResetGuard`).
- Communicates with the API exclusively via HTTP for end-to-end tests; communicates with PostgreSQL directly only via the dedicated DB-reset hook + the existing `MigrationTests` schema assertions.
- NuGet: `Npgsql` 9.0.2 (Postgres client for DB-reset + MigrationTests), `SixLabors.ImageSharp` 3.1.11 (UAV fixture image generation).
- ProjectReferences: `SatelliteProvider.Api` (running service for the integration runner), `SatelliteProvider.TestSupport` (canonical `JwtTokenFactory` + `IntegrationTestResetGuard`), `SatelliteProvider.Common` (added by AZ-503 so the `MultiSourceCoexistence_AZ484_Cycle2` seeder can compute `location_hash` via `Uuidv5.Create` instead of duplicating the UUIDv5 algorithm in T-SQL fixtures).
## Consumers
- `docker-compose.tests.yml` — runs as a container that depends on the API service
+6 -3
View File
@@ -15,11 +15,14 @@ Existing baseline (pre-cycle-2) test classes cover `TileService`, `RegionService
### AZ-488 — UAV tile upload
- `UavTileQualityGateTests` — one happy path + ≥ 1 reject path per rule (Rule 1 INVALID_FORMAT × 2, Rule 2 SIZE_OUT_OF_BAND × 2, Rule 3 WRONG_DIMENSIONS × 1, Rule 4 CAPTURED_AT_FUTURE / _TOO_OLD × 2, Rule 5 IMAGE_TOO_UNIFORM × 1) + rule-ordering determinism. Uses a `FixedTimeProvider` for Rule-4 isolation and `UavTileImageFactory` for deterministic JPEG fixtures.
- `UavTileUploadHandlerTests` — end-to-end with a mocked `ITileRepository`: 1-item happy path, 3-item mixed batch (file written + `InsertAsync` called only for accepted), per-source UPSERT pass-through.
- `UavTileFilePathTests` — verifies `BuildUavTileFilePath` produces `tiles/uav/{z}/{x}/{y}.jpg` for sample (z, x, y) tuples and that integer-typed coordinates make string-injection of path traversal impossible.
- `UavTileUploadHandlerTests` — end-to-end with a mocked `ITileRepository`. Cycle-2 baseline: 1-item happy path, 3-item mixed batch (file written + `InsertAsync` called only for accepted), per-source UPSERT pass-through. AZ-503 additions: `HandleAsync_TwoFlightsSameCell_ProduceDistinctIdsAndPathsButSameLocationHash` (multi-flight coexistence with shared `location_hash`); `HandleAsync_IdenticalUpload_ProducesIdenticalIdAndDeterministicContentSha` (idempotent re-insert preserves deterministic `id` + `content_sha256`).
- `Authentication/PermissionsRequirementTests``PermissionsAuthorizationHandler` correctly accepts a `permissions` claim shaped as a single string OR as a JSON array, rejects when the requested permission is absent, and short-circuits when the principal has no `permissions` claim at all.
- `TestUtilities/UavTileImageFactory` — programmatic JPEG factories used by the gate + handler tests: `CreateValidJpeg(width, height, seed)`, `CreateUniformJpeg`, `CreatePng` (for Rule 1 negative path).
### AZ-503 — Tile identity foundation
- `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.
## 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.
@@ -33,4 +36,4 @@ Existing baseline (pre-cycle-2) test classes cover `TileService`, `RegionService
- 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; 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`. The full project executes in seconds (no external services required).