From 34ee1e0b830c17135a32566878bf8f113af7d865 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Fri, 22 May 2026 16:29:41 +0300 Subject: [PATCH] [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() 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 `\`\` is required.` - RejectUnknownQueryParamsEndpointFilter: reusable IEndpointFilter that rejects any query key outside the allowed set with errors[] 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 --- README.md | 2 +- .../DTOs/GetTileByLatLonQuery.cs | 26 ++ SatelliteProvider.Api/Program.cs | 19 +- .../Swagger/ParameterDescriptionFilter.cs | 10 +- .../GetTileByLatLonQueryValidator.cs | 45 +++ .../Validators/RegionRequestValidator.cs | 50 +++ .../RejectUnknownQueryParamsEndpointFilter.cs | 42 +++ .../DTO/RequestRegionRequest.cs | 29 +- .../GetTileByLatLonValidationTests.cs | 173 +++++++++ .../JwtIntegrationTests.cs | 2 +- .../ProblemDetailsAssertions.cs | 40 +++ SatelliteProvider.IntegrationTests/Program.cs | 4 + .../RegionFieldRenameTests.cs | 39 +- .../RegionRequestValidationTests.cs | 340 ++++++++++++++++++ .../SecurityTests.cs | 2 +- .../TileInventoryValidationTests.cs | 43 +-- .../TileTests.cs | 4 +- .../GetTileByLatLonQueryValidatorTests.cs | 159 ++++++++ .../Validators/RegionRequestValidatorTests.cs | 192 ++++++++++ ...ctUnknownQueryParamsEndpointFilterTests.cs | 124 +++++++ .../contracts/api/region-request.md | 172 +++++++++ .../02_document/contracts/api/tile-latlon.md | 165 +++++++++ _docs/02_document/modules/api_program.md | 18 +- _docs/02_document/modules/common_uuidv5.md | 2 +- _docs/02_document/system-flows.md | 8 +- _docs/02_document/tests/blackbox-tests.md | 8 +- _docs/02_document/tests/security-tests.md | 4 +- .../AZ-808_region_endpoint_validation.md | 0 .../AZ-811_latlon_get_endpoint_validation.md | 0 .../batch_02_cycle8_report.md | 106 ++++++ .../reviews/batch_02_cycle8_review.md | 151 ++++++++ _docs/_autodev_state.md | 6 +- scripts/probe_latlon_validation.sh | 62 ++++ scripts/probe_region_validation.sh | 64 ++++ scripts/run-performance-tests.sh | 4 +- 35 files changed, 1993 insertions(+), 122 deletions(-) create mode 100644 SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs create mode 100644 SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs create mode 100644 SatelliteProvider.Api/Validators/RegionRequestValidator.cs create mode 100644 SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs create mode 100644 SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs create mode 100644 SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs create mode 100644 SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs create mode 100644 SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs create mode 100644 SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs create mode 100644 _docs/02_document/contracts/api/region-request.md create mode 100644 _docs/02_document/contracts/api/tile-latlon.md rename _docs/02_tasks/{todo => done}/AZ-808_region_endpoint_validation.md (100%) rename _docs/02_tasks/{todo => done}/AZ-811_latlon_get_endpoint_validation.md (100%) create mode 100644 _docs/03_implementation/batch_02_cycle8_report.md create mode 100644 _docs/03_implementation/reviews/batch_02_cycle8_review.md create mode 100755 scripts/probe_latlon_validation.sh create mode 100755 scripts/probe_region_validation.sh diff --git a/README.md b/README.md index 0af7a02..a0877fe 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The service follows a layered architecture: ### Download Single Tile ```http -GET /api/satellite/tiles/latlon?Latitude={lat}&Longitude={lon}&ZoomLevel={zoom} +GET /api/satellite/tiles/latlon?lat={lat}&lon={lon}&zoom={zoom} ``` Downloads a single tile at specified coordinates and zoom level. diff --git a/SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs b/SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs new file mode 100644 index 0000000..e750a85 --- /dev/null +++ b/SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SatelliteProvider.Api.DTOs; + +// AZ-811: query-string record for GET /api/satellite/tiles/latlon. +// Bound via `[AsParameters]` so each property maps to one query parameter. +// `[FromQuery(Name = "...")]` pins the wire name explicitly — case-sensitive +// match against `?lat=&lon=&zoom=`, matching the OSM convention shared with +// the rest of the satellite-provider API (`{z, x, y}` for inventory, +// `{lat, lon}` for region and route DTOs). +// +// **Why nullable types**: minimal-API parameter binding throws +// BadHttpRequestException for missing-required non-nullable query params +// BEFORE endpoint filters run. That short-circuit produces a plain +// ProblemDetails via GlobalExceptionHandler — no `errors{}` envelope, no +// per-field key. Per AZ-811 ACs 1 & 4 every missing/unknown param must +// surface as `errors.` in ValidationProblemDetails. Nullable +// types let binding always succeed, so: +// 1. RejectUnknownQueryParamsEndpointFilter handles unknown keys +// (e.g. legacy `?Latitude=`, hostile `?debug=1`). +// 2. GetTileByLatLonQueryValidator handles `null` (missing) plus range. +// Validator guarantees non-null by the time the handler dereferences. +public sealed record GetTileByLatLonQuery( + [property: FromQuery(Name = "lat")] double? Lat, + [property: FromQuery(Name = "lon")] double? Lon, + [property: FromQuery(Name = "zoom")] int? Zoom); diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index e919b2c..1a65cda 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -206,6 +206,10 @@ app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile) app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon) .RequireAuthorization() + .AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(new[] { "lat", "lon", "zoom" })) + .WithValidation() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) .WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" }); app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs) @@ -239,6 +243,10 @@ app.MapPost("/api/satellite/upload", UploadUavTileBatch) app.MapPost("/api/satellite/request", RequestRegion) .RequireAuthorization() + .WithValidation() + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) .WithOpenApi(op => new(op) { Summary = "Request tiles for a region", @@ -271,9 +279,11 @@ async Task ServeTile(int z, int x, int y, HttpContext httpContext, ITil return Results.Bytes(tile.Bytes, tile.ContentType); } -async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, HttpContext httpContext, ITileService tileService) +async Task GetTileByLatLon([AsParameters] GetTileByLatLonQuery query, HttpContext httpContext, ITileService tileService) { - var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel, httpContext.RequestAborted); + // AZ-811: GetTileByLatLonQueryValidator guarantees lat/lon/zoom are non-null + // by the time the handler runs (CascadeMode.Stop + NotNull rules). + var tile = await tileService.DownloadAndStoreSingleTileAsync(query.Lat!.Value, query.Lon!.Value, query.Zoom!.Value, httpContext.RequestAborted); var response = new DownloadTileResponse { @@ -341,11 +351,6 @@ async Task UploadUavTileBatch( async Task RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService) { - if (request.SizeMeters < 100 || request.SizeMeters > 10000) - { - return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" }); - } - var status = await regionService.RequestRegionAsync( request.Id, request.Lat, diff --git a/SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs b/SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs index 736a8a7..e560b1b 100644 --- a/SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs +++ b/SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs @@ -11,13 +11,11 @@ public class ParameterDescriptionFilter : IOperationFilter var parameterDescriptions = new Dictionary { - ["lat"] = "Latitude coordinate where image was captured", - ["lon"] = "Longitude coordinate where image was captured", + ["lat"] = "Latitude coordinate (WGS84, decimal degrees, [-90, 90])", + ["lon"] = "Longitude coordinate (WGS84, decimal degrees, [-180, 180])", + ["zoom"] = "Slippy-map zoom level [0, 22] (higher = more detail)", ["mgrs"] = "MGRS coordinate string", - ["squareSideMeters"] = "Square side size in meters", - ["Latitude"] = "Latitude coordinate of the tile center", - ["Longitude"] = "Longitude coordinate of the tile center", - ["ZoomLevel"] = "Zoom level for the tile (higher values = more detail)" + ["squareSideMeters"] = "Square side size in meters" }; foreach (var parameter in operation.Parameters) diff --git a/SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs b/SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs new file mode 100644 index 0000000..250f7ca --- /dev/null +++ b/SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using SatelliteProvider.Api.DTOs; + +namespace SatelliteProvider.Api.Validators; + +// AZ-811: FluentValidation rules for the query-string surface of +// GET /api/satellite/tiles/latlon. Wired through +// ValidationEndpointFilter at endpoint registration +// time (.WithValidation() in Program.cs). +// +// Each rule maps 1:1 to a query parameter; errors[] keys are camelCase per +// GlobalValidatorConfig (matching the wire-format param names `lat`, `lon`, +// `zoom`). Required-field detection is `NotNull()` on the nullable-bound +// DTO (see GetTileByLatLonQuery for why properties are nullable). Each rule +// uses CascadeMode.Stop so a missing param surfaces ONLY as +// "`lat` is required" — not also "`lat` must be between -90 and 90" with a +// null value. Unknown query parameters are caught upstream by +// RejectUnknownQueryParamsEndpointFilter. +public sealed class GetTileByLatLonQueryValidator : AbstractValidator +{ + private const double MinLat = -90.0; + private const double MaxLat = 90.0; + private const double MinLon = -180.0; + private const double MaxLon = 180.0; + private const int MinZoom = 0; + private const int MaxZoom = 22; + + public GetTileByLatLonQueryValidator() + { + RuleFor(q => q.Lat) + .Cascade(CascadeMode.Stop) + .NotNull().WithMessage("`lat` is required.") + .InclusiveBetween(MinLat, MaxLat).WithMessage($"`lat` must be between {MinLat} and {MaxLat}."); + + RuleFor(q => q.Lon) + .Cascade(CascadeMode.Stop) + .NotNull().WithMessage("`lon` is required.") + .InclusiveBetween(MinLon, MaxLon).WithMessage($"`lon` must be between {MinLon} and {MaxLon}."); + + RuleFor(q => q.Zoom) + .Cascade(CascadeMode.Stop) + .NotNull().WithMessage("`zoom` is required.") + .InclusiveBetween(MinZoom, MaxZoom).WithMessage($"`zoom` must be between {MinZoom} and {MaxZoom} (slippy-map range)."); + } +} diff --git a/SatelliteProvider.Api/Validators/RegionRequestValidator.cs b/SatelliteProvider.Api/Validators/RegionRequestValidator.cs new file mode 100644 index 0000000..f991f0d --- /dev/null +++ b/SatelliteProvider.Api/Validators/RegionRequestValidator.cs @@ -0,0 +1,50 @@ +using FluentValidation; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Api.Validators; + +// AZ-808: FluentValidation rules for POST /api/satellite/request. +// Wired through ValidationEndpointFilter at endpoint +// registration time (.WithValidation() in Program.cs). +// Failures are converted to RFC 7807 ValidationProblemDetails per +// _docs/02_document/contracts/api/error-shape.md v1.0.0. +// +// Required-field detection is handled at the deserializer level via +// [JsonRequired] on RequestRegionRequest properties plus +// JsonSerializerOptions.UnmappedMemberHandling.Disallow (AZ-795). This +// validator covers the post-deserialization business rules: non-zero Id, +// lat/lon/sizeMeters/zoomLevel range constraints. +public sealed class RegionRequestValidator : AbstractValidator +{ + private const double MinLat = -90.0; + private const double MaxLat = 90.0; + private const double MinLon = -180.0; + private const double MaxLon = 180.0; + private const double MinSizeMeters = 100.0; + private const double MaxSizeMeters = 10000.0; + private const int MinZoom = 0; + private const int MaxZoom = 22; + + public RegionRequestValidator() + { + RuleFor(req => req.Id) + .NotEmpty() + .WithMessage("`id` must be a non-zero GUID (the caller's idempotency key)."); + + RuleFor(req => req.Lat) + .InclusiveBetween(MinLat, MaxLat) + .WithMessage($"`lat` must be between {MinLat} and {MaxLat}."); + + RuleFor(req => req.Lon) + .InclusiveBetween(MinLon, MaxLon) + .WithMessage($"`lon` must be between {MinLon} and {MaxLon}."); + + RuleFor(req => req.SizeMeters) + .InclusiveBetween(MinSizeMeters, MaxSizeMeters) + .WithMessage($"`sizeMeters` must be between {MinSizeMeters} and {MaxSizeMeters} meters."); + + RuleFor(req => req.ZoomLevel) + .InclusiveBetween(MinZoom, MaxZoom) + .WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range)."); + } +} diff --git a/SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs b/SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs new file mode 100644 index 0000000..70ca580 --- /dev/null +++ b/SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs @@ -0,0 +1,42 @@ +namespace SatelliteProvider.Api.Validators; + +// AZ-811: endpoint filter that rejects any query-string parameter outside an +// allowed-set. ASP.NET model binding silently ignores unknown query params, +// which means typos (e.g. `?latitude=` after AZ-812's rename to `lat`) bind +// to the default value (0.0) and may produce a misleading 200 or a confusing +// out-of-range 400 from the value-validator. This filter catches the typo at +// the envelope level and returns a structured RFC 7807 ValidationProblemDetails +// with errors[] = "Unknown query parameter ...", matching the +// shape produced by ValidationEndpointFilter + GlobalExceptionHandler. +// +// Apply BEFORE ValidationEndpointFilter so unknown-param errors precede +// range checks against the bound default value. +public sealed class RejectUnknownQueryParamsEndpointFilter : IEndpointFilter +{ + private readonly HashSet _allowedKeys; + + public RejectUnknownQueryParamsEndpointFilter(IEnumerable allowedKeys) + { + _allowedKeys = new HashSet(allowedKeys, StringComparer.OrdinalIgnoreCase); + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var query = context.HttpContext.Request.Query; + var unknown = query.Keys.Where(k => !_allowedKeys.Contains(k)).ToList(); + + if (unknown.Count > 0) + { + var errors = unknown.ToDictionary( + k => k, + k => new[] + { + $"Unknown query parameter `{k}`. Allowed: {string.Join(", ", _allowedKeys.Select(a => $"`{a}`"))}." + }); + + return Results.ValidationProblem(errors); + } + + return await next(context); + } +} diff --git a/SatelliteProvider.Common/DTO/RequestRegionRequest.cs b/SatelliteProvider.Common/DTO/RequestRegionRequest.cs index 8e18de7..c78372f 100644 --- a/SatelliteProvider.Common/DTO/RequestRegionRequest.cs +++ b/SatelliteProvider.Common/DTO/RequestRegionRequest.cs @@ -1,26 +1,39 @@ -using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace SatelliteProvider.Common.DTO; +// AZ-812 (cycle 8): wire-format renamed Latitude/Longitude → Lat/Lon (OSM +// convention) and added [JsonPropertyName("lat"/"lon")] so the wire is +// unambiguous under JsonSerializerOptions.UnmappedMemberHandling.Disallow +// (AZ-795 cycle 7). +// +// AZ-808 (cycle 8): switched [Required] → [JsonRequired] on every property. +// [Required] is DataAnnotations and is NOT enforced by System.Text.Json — the +// 2026-05-22 black-box probe confirmed it: omitting `id` returned HTTP 200 +// with id=Guid.Empty (silent coercion). [JsonRequired] is enforced by the +// STJ deserializer and fails with BadHttpRequestException(JsonException), +// which the GlobalExceptionHandler converts to RFC 7807 ValidationProblemDetails. +// Removed the in-property defaults (= 18 for ZoomLevel, = false for StitchTiles) +// because [JsonRequired] forces the caller to declare intent. public record RequestRegionRequest { - [Required] + [JsonRequired] public Guid Id { get; set; } - [Required] + [JsonRequired] [JsonPropertyName("lat")] public double Lat { get; set; } - [Required] + [JsonRequired] [JsonPropertyName("lon")] public double Lon { get; set; } - [Required] + [JsonRequired] public double SizeMeters { get; set; } - [Required] - public int ZoomLevel { get; set; } = 18; + [JsonRequired] + public int ZoomLevel { get; set; } - public bool StitchTiles { get; set; } = false; + [JsonRequired] + public bool StitchTiles { get; set; } } diff --git a/SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs b/SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs new file mode 100644 index 0000000..6dd9b12 --- /dev/null +++ b/SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs @@ -0,0 +1,173 @@ +namespace SatelliteProvider.IntegrationTests; + +// AZ-811: end-to-end coverage for GET /api/satellite/tiles/latlon strict input +// validation. Two enforcement layers: +// 1. RejectUnknownQueryParamsEndpointFilter — rejects any query key outside +// {lat, lon, zoom}, catching typos like `?latitude=` that pre-AZ-811 +// silently bound to 0. +// 2. WithValidation — range-checks lat, lon, zoom. +// Both surface RFC 7807 ValidationProblemDetails per error-shape.md v1.0.0. +public static class GetTileByLatLonValidationTests +{ + private const string LatLonPath = "/api/satellite/tiles/latlon"; + + public static async Task RunAll(HttpClient httpClient) + { + RouteTestHelpers.PrintTestHeader("Test: GET /api/satellite/tiles/latlon strict validation (AZ-811)"); + + await HappyPath_Returns200(httpClient); + + // Validator rules (range) + await LatOutOfRange_Returns400(httpClient); + await LonOutOfRange_Returns400(httpClient); + await ZoomOutOfRange_Returns400(httpClient); + + // Validator rules (missing required) + await MissingLat_Returns400(httpClient); + + // Envelope rule: unknown query params + await UnknownQueryParam_LegacyLatitude_Returns400(httpClient); + await UnknownQueryParam_Hostile_Returns400(httpClient); + + // Type mismatch (delegates to GlobalExceptionHandler via model-binding) + await LatTypeMismatch_Returns400(httpClient); + + Console.WriteLine("✓ GET lat/lon validation tests: PASSED"); + } + + private static async Task HappyPath_Returns200(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 AC-2: well-formed query → HTTP 200"); + + // Act + var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18"); + var status = (int)response.StatusCode; + var bodyText = await response.Content.ReadAsStringAsync(); + + // Assert + if (status != 200) + { + throw new Exception($"AZ-811 happy path: expected HTTP 200, got {status}. Body: {bodyText}"); + } + + Console.WriteLine(" ✓ {lat,lon,zoom} accepted with HTTP 200"); + } + + private static async Task LatOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 1: lat out of range (-90..90) → HTTP 400"); + + // Act + var response = await httpClient.GetAsync($"{LatLonPath}?lat=91&lon=37.647063&zoom=18"); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lat out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lat out of range", expectedErrorPath: "lat"); + + Console.WriteLine(" ✓ lat=91 rejected with errors[\"lat\"]"); + } + + private static async Task LonOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 2: lon out of range (-180..180) → HTTP 400"); + + // Act + var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=181&zoom=18"); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lon out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lon out of range", expectedErrorPath: "lon"); + + Console.WriteLine(" ✓ lon=181 rejected with errors[\"lon\"]"); + } + + private static async Task ZoomOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 3: zoom out of range (0..22) → HTTP 400"); + + // Act + var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=30"); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 zoom out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 zoom out of range", expectedErrorPath: "zoom"); + + Console.WriteLine(" ✓ zoom=30 rejected with errors[\"zoom\"]"); + } + + private static async Task MissingLat_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 1: missing `lat` query param → HTTP 400 with errors.lat"); + + // Act — only lon + zoom supplied; the validator's NotNull rule on Lat must + // fire (binder produces Lat=null because the DTO is nullable; see + // GetTileByLatLonQuery for why). + var response = await httpClient.GetAsync($"{LatLonPath}?lon=37.647063&zoom=18"); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 missing lat"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 missing lat", expectedErrorPath: "lat"); + + Console.WriteLine(" ✓ Missing lat rejected with errors[\"lat\"] = `lat` is required"); + } + + private static async Task UnknownQueryParam_LegacyLatitude_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 4: legacy `?Latitude=&Longitude=&ZoomLevel=` (pre-AZ-811 wire format) → HTTP 400 (envelope filter)"); + + // Act — exact pre-AZ-811 wire format; must now fail explicitly instead + // of silently binding to lat=0/lon=0/zoom=0 (typo class). + var response = await httpClient.GetAsync($"{LatLonPath}?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18"); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 legacy param names"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 legacy param names"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "Latitude", label: "AZ-811 legacy param names"); + + Console.WriteLine(" ✓ Legacy ?Latitude=&Longitude=&ZoomLevel= rejected by envelope filter"); + } + + private static async Task UnknownQueryParam_Hostile_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 4: hostile/typo query keys → HTTP 400 (envelope filter)"); + + // Act + var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18&debug=1&admin=true"); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 hostile params"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 hostile params"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-811 hostile params"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "admin", label: "AZ-811 hostile params"); + + Console.WriteLine(" ✓ ?debug=1&admin=true rejected; errors map names BOTH unknown keys"); + } + + private static async Task LatTypeMismatch_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-811 rule 5: lat type mismatch (non-numeric) → HTTP 400"); + + // Act + var response = await httpClient.GetAsync($"{LatLonPath}?lat=fifty&lon=37.647063&zoom=18"); + var status = (int)response.StatusCode; + + // Assert — ASP.NET query-param binding produces 400 for type mismatch via + // BadHttpRequestException; the exact ProblemDetails shape varies depending + // on whether the GlobalExceptionHandler intercepts. Either way the wire + // contract is HTTP 400, no body leak. + if (status != 400) + { + throw new Exception($"AZ-811 type mismatch: expected HTTP 400, got {status}."); + } + + Console.WriteLine(" ✓ lat=fifty rejected with HTTP 400"); + } +} diff --git a/SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs b/SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs index 977f012..c1e08f1 100644 --- a/SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs +++ b/SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs @@ -6,7 +6,7 @@ namespace SatelliteProvider.IntegrationTests; public static class JwtIntegrationTests { - private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18"; + private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18"; private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000"; public static async Task RunAll(string apiUrl, string secret) diff --git a/SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs b/SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs index 977c756..2b89695 100644 --- a/SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs +++ b/SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs @@ -92,6 +92,46 @@ public static class ProblemDetailsAssertions } } + // AZ-808 cycle 8: promoted from per-test-file private helpers (was + // duplicated in TileInventoryValidationTests + RegionFieldRenameTests + + // RegionRequestValidationTests) so every validation test points at one + // source of truth for "is this field-name or substring mentioned anywhere + // in the errors map?". + public static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label) + { + if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object) + { + throw new Exception($"{label}: expected 'errors' object in ProblemDetails body."); + } + + var found = false; + foreach (var prop in errorsEl.EnumerateObject()) + { + if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase)) + { + found = true; + break; + } + + foreach (var msg in prop.Value.EnumerateArray()) + { + if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true) + { + found = true; + break; + } + } + + if (found) break; + } + + if (!found) + { + var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name)); + throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}."); + } + } + private static IEnumerable EnumeratePaths(JsonElement errorsEl) { foreach (var prop in errorsEl.EnumerateObject()) diff --git a/SatelliteProvider.IntegrationTests/Program.cs b/SatelliteProvider.IntegrationTests/Program.cs index 216d89f..b06cafa 100644 --- a/SatelliteProvider.IntegrationTests/Program.cs +++ b/SatelliteProvider.IntegrationTests/Program.cs @@ -141,6 +141,8 @@ class Program await TileInventoryTests.RunAll(httpClient); await TileInventoryValidationTests.RunAll(httpClient); await RegionFieldRenameTests.RunAll(httpClient); + await RegionRequestValidationTests.RunAll(httpClient); + await GetTileByLatLonValidationTests.RunAll(httpClient); await LeafletPathIndexOnlyTests.RunAll(connectionString); await MigrationTests.RunAll(); } @@ -166,6 +168,8 @@ class Program await TileInventoryTests.RunAll(httpClient); await TileInventoryValidationTests.RunAll(httpClient); await RegionFieldRenameTests.RunAll(httpClient); + await RegionRequestValidationTests.RunAll(httpClient); + await GetTileByLatLonValidationTests.RunAll(httpClient); await LeafletPathIndexOnlyTests.RunAll(connectionString); await MigrationTests.RunAll(); } diff --git a/SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs b/SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs index a8410d3..0211b96 100644 --- a/SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs +++ b/SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs @@ -1,6 +1,4 @@ -using System.Net.Http.Json; using System.Text; -using System.Text.Json; namespace SatelliteProvider.IntegrationTests; @@ -63,7 +61,7 @@ public static class RegionFieldRenameTests // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-812 legacy field names"); - AssertErrorsContainsMention(problem, expectedMention: "latitude", label: "AZ-812 legacy field names"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "latitude", label: "AZ-812 legacy field names"); Console.WriteLine(" ✓ Legacy {latitude,longitude} body rejected with HTTP 400; errors map names the unknown field"); } @@ -73,39 +71,4 @@ public static class RegionFieldRenameTests var content = new StringContent(body, Encoding.UTF8, "application/json"); return httpClient.PostAsync(RegionPath, content); } - - private static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label) - { - if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object) - { - throw new Exception($"{label}: expected 'errors' object in ProblemDetails body."); - } - - var found = false; - foreach (var prop in errorsEl.EnumerateObject()) - { - if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase)) - { - found = true; - break; - } - - foreach (var msg in prop.Value.EnumerateArray()) - { - if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true) - { - found = true; - break; - } - } - - if (found) break; - } - - if (!found) - { - var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name)); - throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}."); - } - } } diff --git a/SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs b/SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs new file mode 100644 index 0000000..83ffda1 --- /dev/null +++ b/SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs @@ -0,0 +1,340 @@ +using System.Text; +using System.Text.Json; + +namespace SatelliteProvider.IntegrationTests; + +// AZ-808: end-to-end coverage for the region-request endpoint's strict input +// validation. Each test exercises one rule from the validator (FluentValidation +// for business rules, JsonSerializerOptions for wire-format rules) and asserts +// the response body conforms to the RFC 7807 ValidationProblemDetails contract +// in `_docs/02_document/contracts/api/error-shape.md` v1.0.0. +// +// Field names use the post-AZ-812 OSM convention (`lat`/`lon`). The legacy +// `latitude`/`longitude` wire format is verified to be rejected by +// RegionFieldRenameTests.cs (AZ-812 AC-4). +public static class RegionRequestValidationTests +{ + private const string RegionPath = "/api/satellite/request"; + + public static async Task RunAll(HttpClient httpClient) + { + RouteTestHelpers.PrintTestHeader("Test: Region endpoint strict validation (AZ-808)"); + + await HappyPath_Returns200(httpClient); + + // Rule 1: body present + await EmptyBody_Returns400(httpClient); + + // Rule 2: id required, non-zero Guid + await MissingId_Returns400(httpClient); + await ZeroGuidId_Returns400(httpClient); + + // Rule 3: lat required, [-90, 90] + await MissingLat_Returns400(httpClient); + await LatOutOfRange_Returns400(httpClient); + + // Rule 4: lon required, [-180, 180] + await MissingLon_Returns400(httpClient); + await LonOutOfRange_Returns400(httpClient); + + // Rule 5: sizeMeters required, [100, 10000] + await MissingSizeMeters_Returns400(httpClient); + await SizeMetersOutOfRange_Returns400(httpClient); + + // Rule 6: zoomLevel required, [0, 22] + await MissingZoomLevel_Returns400(httpClient); + await ZoomLevelOutOfRange_Returns400(httpClient); + + // Rule 7: stitchTiles required (bool, no default) + await MissingStitchTiles_Returns400(httpClient); + + // Rule 9: type mismatch + await LatTypeMismatch_Returns400(httpClient); + + // Rule 8 (unknown root fields) is covered by RegionFieldRenameTests (AZ-812 AC-4). + + Console.WriteLine("✓ Region-request validation tests: PASSED"); + } + + private static async Task HappyPath_Returns200(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 AC-2: well-formed request → HTTP 200"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var status = (int)response.StatusCode; + var bodyText = await response.Content.ReadAsStringAsync(); + + // Assert + if (status != 200) + { + throw new Exception($"AZ-808 AC-2 happy path: expected HTTP 200, got {status}. Body: {bodyText}"); + } + + Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200"); + } + + private static async Task EmptyBody_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 1: empty body → HTTP 400"); + + // Arrange + const string body = ""; + + // Act + var response = await PostJsonAsync(httpClient, body); + var status = (int)response.StatusCode; + + // Assert + if (status != 400) + { + throw new Exception($"AZ-808 rule 1: expected HTTP 400, got {status}."); + } + + Console.WriteLine(" ✓ Empty body rejected with HTTP 400"); + } + + private static async Task MissingId_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)"); + + // Arrange — the exact 2026-05-22 probe payload that silently coerced to Guid.Empty pre-AZ-808. + const string body = "{\"lat\":49.94,\"lon\":36.31,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing id"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing id"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-808 missing id"); + + Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)"); + } + + private static async Task ZeroGuidId_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 2: zero-Guid `id` → HTTP 400"); + + // Arrange + const string body = "{\"id\":\"00000000-0000-0000-0000-000000000000\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zero-Guid id"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zero-Guid id", expectedErrorPath: "id"); + + Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]"); + } + + private static async Task MissingLat_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 3: missing `lat` → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lat"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lat"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lat", label: "AZ-808 missing lat"); + + Console.WriteLine(" ✓ Missing `lat` rejected with HTTP 400"); + } + + private static async Task LatOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 3: `lat` out of range (-90..90) → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":91.0,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat out of range", expectedErrorPath: "lat"); + + Console.WriteLine(" ✓ `lat=91.0` rejected with errors[\"lat\"]"); + } + + private static async Task MissingLon_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 4: missing `lon` → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lon"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lon"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lon", label: "AZ-808 missing lon"); + + Console.WriteLine(" ✓ Missing `lon` rejected with HTTP 400"); + } + + private static async Task LonOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 4: `lon` out of range (-180..180) → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":181.0,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lon out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lon out of range", expectedErrorPath: "lon"); + + Console.WriteLine(" ✓ `lon=181.0` rejected with errors[\"lon\"]"); + } + + private static async Task MissingSizeMeters_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 5: missing `sizeMeters` → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing sizeMeters"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing sizeMeters"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "sizeMeters", label: "AZ-808 missing sizeMeters"); + + Console.WriteLine(" ✓ Missing `sizeMeters` rejected with HTTP 400"); + } + + private static async Task SizeMetersOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 5: `sizeMeters` out of range (100..10000) → HTTP 400"); + + // Arrange — same 1M cap-exceeder used by SEC-03; this validator replaces the old inline check. + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 sizeMeters out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 sizeMeters out of range", expectedErrorPath: "sizeMeters"); + + Console.WriteLine(" ✓ `sizeMeters=1000000` rejected with errors[\"sizeMeters\"]"); + } + + private static async Task MissingZoomLevel_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 6: missing `zoomLevel` → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing zoomLevel"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing zoomLevel"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "zoomLevel", label: "AZ-808 missing zoomLevel"); + + Console.WriteLine(" ✓ Missing `zoomLevel` rejected with HTTP 400"); + } + + private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 6: `zoomLevel` out of range (0..22) → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":30,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zoomLevel out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zoomLevel out of range", expectedErrorPath: "zoomLevel"); + + Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]"); + } + + private static async Task MissingStitchTiles_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 7: missing `stitchTiles` → HTTP 400 (no defaulting to false)"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing stitchTiles"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing stitchTiles"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "stitchTiles", label: "AZ-808 missing stitchTiles"); + + Console.WriteLine(" ✓ Missing `stitchTiles` rejected with HTTP 400"); + } + + private static async Task LatTypeMismatch_Returns400(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("AZ-808 rule 9: type mismatch (`lat` as string) → HTTP 400"); + + // Arrange + var regionId = Guid.NewGuid(); + var body = $"{{\"id\":\"{regionId}\",\"lat\":\"fifty\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}"; + + // Act + var response = await PostJsonAsync(httpClient, body); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat type mismatch"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat type mismatch"); + + Console.WriteLine(" ✓ `lat:\"fifty\"` rejected with HTTP 400"); + } + + private static Task PostJsonAsync(HttpClient httpClient, string body) + { + var content = new StringContent(body, Encoding.UTF8, "application/json"); + return httpClient.PostAsync(RegionPath, content); + } +} diff --git a/SatelliteProvider.IntegrationTests/SecurityTests.cs b/SatelliteProvider.IntegrationTests/SecurityTests.cs index b2a317d..9ba55f9 100644 --- a/SatelliteProvider.IntegrationTests/SecurityTests.cs +++ b/SatelliteProvider.IntegrationTests/SecurityTests.cs @@ -23,7 +23,7 @@ public static class SecurityTests Console.WriteLine("SEC-01: SQL injection attempt in coordinate query string"); var injection = "' OR 1=1 --"; - var url = $"/api/satellite/tiles/latlon?Latitude={Uri.EscapeDataString(injection)}&Longitude=37.647063&ZoomLevel=18"; + var url = $"/api/satellite/tiles/latlon?lat={Uri.EscapeDataString(injection)}&lon=37.647063&zoom=18"; var response = await httpClient.GetAsync(url); if (response.StatusCode != HttpStatusCode.BadRequest && response.StatusCode != HttpStatusCode.UnprocessableEntity) diff --git a/SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs b/SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs index da92f43..18e3c1a 100644 --- a/SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs +++ b/SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs @@ -199,7 +199,7 @@ public static class TileInventoryValidationTests // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 missing z"); - AssertErrorsContainsMention(problem, expectedMention: "z", label: "AZ-796 missing z"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "z", label: "AZ-796 missing z"); Console.WriteLine(" ✓ Missing `z` rejected with errors map mentioning the field"); } @@ -325,7 +325,7 @@ public static class TileInventoryValidationTests // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown root field"); - AssertErrorsContainsMention(problem, expectedMention: "unknownField", label: "AZ-796 unknown root field"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "unknownField", label: "AZ-796 unknown root field"); Console.WriteLine(" ✓ Unknown root field rejected; errors map names the field"); } @@ -344,7 +344,7 @@ public static class TileInventoryValidationTests // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown nested field"); - AssertErrorsContainsMention(problem, expectedMention: "foo", label: "AZ-796 unknown nested field"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "foo", label: "AZ-796 unknown nested field"); Console.WriteLine(" ✓ Unknown nested field rejected; errors map names the field"); } @@ -364,7 +364,7 @@ public static class TileInventoryValidationTests // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-794 legacy field"); - AssertErrorsContainsMention(problem, expectedMention: "tileZoom", label: "AZ-794 legacy field"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "tileZoom", label: "AZ-794 legacy field"); Console.WriteLine(" ✓ Legacy v1.x field names rejected with explicit error (no silent coercion)"); } @@ -392,39 +392,4 @@ public static class TileInventoryValidationTests var content = new StringContent(body, Encoding.UTF8, "application/json"); return httpClient.PostAsync(InventoryPath, content); } - - private static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label) - { - if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object) - { - throw new Exception($"{label}: expected 'errors' object in ProblemDetails body."); - } - - var found = false; - foreach (var prop in errorsEl.EnumerateObject()) - { - if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase)) - { - found = true; - break; - } - - foreach (var msg in prop.Value.EnumerateArray()) - { - if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true) - { - found = true; - break; - } - } - - if (found) break; - } - - if (!found) - { - var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name)); - throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}."); - } - } } diff --git a/SatelliteProvider.IntegrationTests/TileTests.cs b/SatelliteProvider.IntegrationTests/TileTests.cs index 5000c5b..f5a7115 100644 --- a/SatelliteProvider.IntegrationTests/TileTests.cs +++ b/SatelliteProvider.IntegrationTests/TileTests.cs @@ -21,7 +21,7 @@ public static class TileTests Console.WriteLine($"Getting tile at coordinates ({latitude}, {longitude}) with zoom level {zoomLevel}"); - var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}"); + var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?lat={latitude}&lon={longitude}&zoom={zoomLevel}"); if (!response.IsSuccessStatusCode) { @@ -74,7 +74,7 @@ public static class TileTests Console.WriteLine(); Console.WriteLine("Testing tile reuse (getting same tile again)..."); - var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}"); + var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?lat={latitude}&lon={longitude}&zoom={zoomLevel}"); if (!response2.IsSuccessStatusCode) { diff --git a/SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs b/SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs new file mode 100644 index 0000000..7baef24 --- /dev/null +++ b/SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs @@ -0,0 +1,159 @@ +using FluentValidation.TestHelper; +using SatelliteProvider.Api.DTOs; +using SatelliteProvider.Api.Validators; + +namespace SatelliteProvider.Tests.Validators; + +// AZ-811: unit tests for GetTileByLatLonQueryValidator. One Theory per RuleFor +// covering boundary + out-of-range. Unknown-query-param rejection is tested +// at the integration layer (GetTileByLatLonValidationTests) — there's no +// pure-unit equivalent because the filter runs against HttpContext.Request.Query. +public class GetTileByLatLonQueryValidatorTests +{ + private readonly GetTileByLatLonQueryValidator _validator; + + public GetTileByLatLonQueryValidatorTests() + { + GlobalValidatorConfig.ApplyOnce(); + _validator = new GetTileByLatLonQueryValidator(); + } + + [Theory] + [InlineData(-90.001)] + [InlineData(90.001)] + [InlineData(180.0)] + public void Validate_LatOutOfRange_FailsRangeRule(double lat) + { + // Arrange + var query = new GetTileByLatLonQuery(lat, 37.647063, 18); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldHaveValidationErrorFor("lat"); + } + + [Fact] + public void Validate_LatNull_FailsNotNullRule() + { + // Arrange + var query = new GetTileByLatLonQuery(null, 37.647063, 18); + + // Act + var result = _validator.TestValidate(query); + + // Assert — CascadeMode.Stop ensures NotNull short-circuits the range + // rule, so the caller sees only `"\`lat\` is required."` not also the + // range error against a null sentinel. + result.ShouldHaveValidationErrorFor("lat").WithErrorMessage("`lat` is required."); + } + + [Theory] + [InlineData(-90.0)] + [InlineData(0.0)] + [InlineData(47.461747)] + [InlineData(90.0)] + public void Validate_LatAtOrInsideBounds_Passes(double lat) + { + // Arrange + var query = new GetTileByLatLonQuery(lat, 37.647063, 18); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldNotHaveValidationErrorFor("lat"); + } + + [Theory] + [InlineData(-180.001)] + [InlineData(180.001)] + [InlineData(360.0)] + public void Validate_LonOutOfRange_FailsRangeRule(double lon) + { + // Arrange + var query = new GetTileByLatLonQuery(47.461747, lon, 18); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldHaveValidationErrorFor("lon"); + } + + [Fact] + public void Validate_LonNull_FailsNotNullRule() + { + // Arrange + var query = new GetTileByLatLonQuery(47.461747, null, 18); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldHaveValidationErrorFor("lon").WithErrorMessage("`lon` is required."); + } + + [Theory] + [InlineData(-180.0)] + [InlineData(0.0)] + [InlineData(37.647063)] + [InlineData(180.0)] + public void Validate_LonAtOrInsideBounds_Passes(double lon) + { + // Arrange + var query = new GetTileByLatLonQuery(47.461747, lon, 18); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldNotHaveValidationErrorFor("lon"); + } + + [Theory] + [InlineData(-1)] + [InlineData(23)] + [InlineData(100)] + public void Validate_ZoomOutOfRange_FailsRangeRule(int zoom) + { + // Arrange + var query = new GetTileByLatLonQuery(47.461747, 37.647063, zoom); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldHaveValidationErrorFor("zoom"); + } + + [Fact] + public void Validate_ZoomNull_FailsNotNullRule() + { + // Arrange + var query = new GetTileByLatLonQuery(47.461747, 37.647063, null); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldHaveValidationErrorFor("zoom").WithErrorMessage("`zoom` is required."); + } + + [Theory] + [InlineData(0)] + [InlineData(18)] + [InlineData(22)] + public void Validate_ZoomAtOrInsideBounds_Passes(int zoom) + { + // Arrange + var query = new GetTileByLatLonQuery(47.461747, 37.647063, zoom); + + // Act + var result = _validator.TestValidate(query); + + // Assert + result.ShouldNotHaveValidationErrorFor("zoom"); + } +} diff --git a/SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs b/SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs new file mode 100644 index 0000000..451cabc --- /dev/null +++ b/SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs @@ -0,0 +1,192 @@ +using FluentValidation.TestHelper; +using SatelliteProvider.Api.Validators; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Tests.Validators; + +// AZ-808: unit tests for RegionRequestValidator. Each RuleFor in the validator +// has at least one passing case + one failing case. Required-field detection +// (id / lat / lon / sizeMeters / zoomLevel / stitchTiles) is not unit-tested +// here because it lives at the deserializer layer (JsonRequired), not the +// validator — covered by the integration tests (RegionRequestValidationTests). +public class RegionRequestValidatorTests +{ + private readonly RegionRequestValidator _validator; + + public RegionRequestValidatorTests() + { + GlobalValidatorConfig.ApplyOnce(); + _validator = new RegionRequestValidator(); + } + + private static RequestRegionRequest ValidRequest() => new() + { + Id = Guid.NewGuid(), + Lat = 47.461747, + Lon = 37.647063, + SizeMeters = 200.0, + ZoomLevel = 18, + StitchTiles = false, + }; + + [Fact] + public void Validate_AllValid_Passes() + { + // Arrange + var request = ValidRequest(); + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_IdEmpty_FailsNotEmptyRule() + { + // Arrange + var request = ValidRequest() with { Id = Guid.Empty }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor("id") + .WithErrorMessage("`id` must be a non-zero GUID (the caller's idempotency key)."); + } + + [Theory] + [InlineData(-90.001)] + [InlineData(90.001)] + [InlineData(180.0)] + [InlineData(-181.0)] + public void Validate_LatOutOfRange_FailsRangeRule(double lat) + { + // Arrange + var request = ValidRequest() with { Lat = lat }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor("lat"); + } + + [Theory] + [InlineData(-90.0)] + [InlineData(0.0)] + [InlineData(47.461747)] + [InlineData(90.0)] + public void Validate_LatAtOrInsideBounds_Passes(double lat) + { + // Arrange + var request = ValidRequest() with { Lat = lat }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor("lat"); + } + + [Theory] + [InlineData(-180.001)] + [InlineData(180.001)] + [InlineData(360.0)] + public void Validate_LonOutOfRange_FailsRangeRule(double lon) + { + // Arrange + var request = ValidRequest() with { Lon = lon }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor("lon"); + } + + [Theory] + [InlineData(-180.0)] + [InlineData(0.0)] + [InlineData(37.647063)] + [InlineData(180.0)] + public void Validate_LonAtOrInsideBounds_Passes(double lon) + { + // Arrange + var request = ValidRequest() with { Lon = lon }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor("lon"); + } + + [Theory] + [InlineData(99.999)] + [InlineData(0.0)] + [InlineData(10000.001)] + [InlineData(100000.0)] + [InlineData(-1.0)] + public void Validate_SizeMetersOutOfRange_FailsRangeRule(double sizeMeters) + { + // Arrange + var request = ValidRequest() with { SizeMeters = sizeMeters }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor("sizeMeters"); + } + + [Theory] + [InlineData(100.0)] + [InlineData(200.0)] + [InlineData(5000.0)] + [InlineData(10000.0)] + public void Validate_SizeMetersAtOrInsideBounds_Passes(double sizeMeters) + { + // Arrange + var request = ValidRequest() with { SizeMeters = sizeMeters }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor("sizeMeters"); + } + + [Theory] + [InlineData(-1)] + [InlineData(23)] + [InlineData(100)] + public void Validate_ZoomLevelOutOfRange_FailsRangeRule(int zoomLevel) + { + // Arrange + var request = ValidRequest() with { ZoomLevel = zoomLevel }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldHaveValidationErrorFor("zoomLevel"); + } + + [Theory] + [InlineData(0)] + [InlineData(18)] + [InlineData(22)] + public void Validate_ZoomLevelAtOrInsideBounds_Passes(int zoomLevel) + { + // Arrange + var request = ValidRequest() with { ZoomLevel = zoomLevel }; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor("zoomLevel"); + } +} diff --git a/SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs b/SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs new file mode 100644 index 0000000..a8d0afd --- /dev/null +++ b/SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs @@ -0,0 +1,124 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Primitives; +using SatelliteProvider.Api.Validators; + +namespace SatelliteProvider.Tests.Validators; + +// AZ-811: unit coverage for the envelope filter that runs ahead of the +// FluentValidation layer on query-string endpoints. Spec section 5 calls for +// ≥ 1 unit test on this filter; integration coverage is in +// SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs. +public class RejectUnknownQueryParamsEndpointFilterTests +{ + private static readonly string[] AllowedKeys = ["lat", "lon", "zoom"]; + + [Fact] + public async Task Invoke_AllKeysAllowed_DelegatesToNext() + { + // Arrange + var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys); + var ctx = BuildContext(new Dictionary + { + ["lat"] = "47.461747", + ["lon"] = "37.647063", + ["zoom"] = "18" + }); + var sentinel = new object(); + EndpointFilterDelegate next = _ => ValueTask.FromResult(sentinel); + + // Act + var result = await filter.InvokeAsync(ctx, next); + + // Assert + result.Should().BeSameAs(sentinel, "the filter must pass through when all query keys are in the allowed set"); + } + + [Fact] + public async Task Invoke_UnknownKey_ReturnsValidationProblemAndDoesNotDelegate() + { + // Arrange + var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys); + var ctx = BuildContext(new Dictionary + { + ["lat"] = "47.461747", + ["lon"] = "37.647063", + ["zoom"] = "18", + ["debug"] = "1" + }); + var nextCalled = false; + EndpointFilterDelegate next = _ => + { + nextCalled = true; + return ValueTask.FromResult(new object()); + }; + + // Act + var result = await filter.InvokeAsync(ctx, next); + + // Assert + nextCalled.Should().BeFalse("an unknown key must short-circuit the pipeline before the handler runs"); + var problem = result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + problem.ProblemDetails.Should().BeOfType(); + var validation = (HttpValidationProblemDetails)problem.ProblemDetails; + validation.Errors.Should().ContainKey("debug"); + validation.Errors["debug"][0].Should().Contain("Unknown query parameter"); + } + + [Fact] + public async Task Invoke_LegacyPascalCaseKeys_ReturnsErrorsPerKey() + { + // Arrange — AZ-811 envelope must catch the exact pre-rename wire format + // (`Latitude/Longitude/ZoomLevel`) because case-insensitive lookup against + // the allowed set still treats those keys as distinct from `lat/lon/zoom`. + var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys); + var ctx = BuildContext(new Dictionary + { + ["Latitude"] = "47.461747", + ["Longitude"] = "37.647063", + ["ZoomLevel"] = "18" + }); + EndpointFilterDelegate next = _ => ValueTask.FromResult(new object()); + + // Act + var result = await filter.InvokeAsync(ctx, next); + + // Assert + var problem = result.Should().BeOfType().Subject; + var validation = (HttpValidationProblemDetails)problem.ProblemDetails; + validation.Errors.Should().ContainKey("Latitude"); + validation.Errors.Should().ContainKey("Longitude"); + validation.Errors.Should().ContainKey("ZoomLevel"); + } + + [Fact] + public async Task Invoke_KeysAreCaseInsensitiveAgainstAllowedSet() + { + // Arrange — `Lat` (capital L) is the SAME allowed key as `lat` + // (`StringComparer.OrdinalIgnoreCase`). It must pass through. + var filter = new RejectUnknownQueryParamsEndpointFilter(AllowedKeys); + var ctx = BuildContext(new Dictionary + { + ["Lat"] = "47.461747", + ["lon"] = "37.647063", + ["ZOOM"] = "18" + }); + var sentinel = new object(); + EndpointFilterDelegate next = _ => ValueTask.FromResult(sentinel); + + // Act + var result = await filter.InvokeAsync(ctx, next); + + // Assert + result.Should().BeSameAs(sentinel); + } + + private static EndpointFilterInvocationContext BuildContext(IDictionary queryParams) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection(queryParams.ToDictionary(kv => kv.Key, kv => kv.Value)); + return new DefaultEndpointFilterInvocationContext(httpContext); + } +} diff --git a/_docs/02_document/contracts/api/region-request.md b/_docs/02_document/contracts/api/region-request.md new file mode 100644 index 0000000..badc033 --- /dev/null +++ b/_docs/02_document/contracts/api/region-request.md @@ -0,0 +1,172 @@ +# Contract: region-request + +**Component**: WebApi (`SatelliteProvider.Api`) producing rows via RegionProcessing (`SatelliteProvider.Services.RegionProcessing`) +**Producer task**: AZ-808 — `_docs/02_tasks/done/AZ-808_region_endpoint_validation.md` (validator + this contract); AZ-812 — `_docs/02_tasks/done/AZ-812_region_field_rename_to_osm.md` (OSM-convention wire-format `lat`/`lon`) +**Consumer tasks**: `gps-denied-onboard` AZ-777 Phase 2 (seeds Derkachi reference tile catalog via this endpoint) +**Version**: 1.0.0 +**Status**: frozen +**Last Updated**: 2026-05-22 + +## Purpose + +Defines the HTTP contract for `POST /api/satellite/request` — the region-onboarding endpoint that enqueues a square region of tiles for asynchronous backfill from Google Maps. Callers submit a `(lat, lon, sizeMeters, zoomLevel)` envelope identified by a client-provided `id` (idempotency key); the API responds immediately with the queued region's status. Actual tile downloads run in the background via `RegionProcessingService` (`system-flows.md` Flow F2). Callers poll `GET /api/satellite/region/{id}` until `status == completed`. + +This is the v1.0.0 of the contract — published alongside AZ-808's validator landing. There is no prior contract document. AZ-812 had already renamed the wire-format fields `Latitude/Longitude` → `lat/lon` (OSM convention) earlier in cycle 8; this contract publishes the post-rename shape directly with no transitional period. + +## Endpoint + +``` +POST /api/satellite/request +Content-Type: application/json +Authorization: Bearer +``` + +The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401. + +## Shape + +### Request body + +```jsonc +{ + "id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab", + "lat": 47.461747, + "lon": 37.647063, + "sizeMeters": 200, + "zoomLevel": 18, + "stitchTiles": false +} +``` + +Per-field constraints: + +| Field | Type | Required | Description | Constraints | +|-------|------|----------|-------------|-------------| +| `id` | UUID | yes | Client-provided idempotency key. POSTing the same `id` twice returns the existing region (idempotent per AZ-362). | Non-zero GUID. `00000000-...` → HTTP 400. | +| `lat` | number | yes | Region centre latitude (WGS84, decimal degrees). | `[-90.0, 90.0]`. | +| `lon` | number | yes | Region centre longitude (WGS84, decimal degrees). | `[-180.0, 180.0]`. | +| `sizeMeters` | number | yes | Square region side length. | `[100.0, 10000.0]`. Anything larger → HTTP 400. | +| `zoomLevel` | integer | yes | Slippy-map zoom level for the resulting tiles. | `[0, 22]`. | +| `stitchTiles` | bool | yes | If true, a stitched composite image is produced once all tiles are present. No default — caller MUST declare intent. | true / false. | + +Strict parsing: unknown fields at root are rejected with HTTP 400 by `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). Missing required fields are caught by `[JsonRequired]` on the DTO and surface as HTTP 400 with the field name in `errors`. + +### Response body + +```jsonc +{ + "id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab", + "status": "queued", + "csvFilePath": null, + "summaryFilePath": null, + "tilesDownloaded": 0, + "tilesReused": 0, + "createdAt": "2026-05-22T12:34:56.789Z", + "updatedAt": "2026-05-22T12:34:56.789Z" +} +``` + +Per-field semantics: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Echo of the request `id`. | +| `status` | string enum | `"queued"` immediately after enqueue; transitions through `"processing"` → `"completed"` (or `"failed"`) on the background worker. | +| `csvFilePath` | string \| null | Path to the per-region tile-manifest CSV. Null until processing produces it. | +| `summaryFilePath` | string \| null | Path to the human-readable summary. Null until processing produces it. | +| `tilesDownloaded` | integer | Count of tiles fetched fresh from Google Maps. Updated as processing progresses. | +| `tilesReused` | integer | Count of tiles served from existing cache. Updated as processing progresses. | +| `createdAt` | ISO-8601 UTC | Initial enqueue timestamp. Stable across retries (per AZ-362 idempotency). | +| `updatedAt` | ISO-8601 UTC | Last status-row write. Bumps as the background worker progresses. | + +### Endpoint summary + +| Method | Path | Request body | Response | Status codes | +|--------|------|--------------|----------|--------------| +| `POST` | `/api/satellite/request` | `RequestRegionRequest` | `RegionStatusResponse` | 200, 400, 401 | + +## Error shape + +All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Two enforcement layers produce identically-shaped bodies: + +1. **JSON deserializer rules** — wire-format failures: unknown fields (`UnmappedMemberHandling.Disallow`), missing `[JsonRequired]` properties, type mismatches. Surface via `BadHttpRequestException(JsonException)` → `GlobalExceptionHandler`. +2. **`RegionRequestValidator`** (FluentValidation, AZ-808) — business rules: non-zero `id`, range checks for `lat` / `lon` / `sizeMeters` / `zoomLevel`. Surface via `ValidationEndpointFilter`. + +Example body for a missing-id failure (the pre-AZ-808 silent-coercion gap surfaced by the 2026-05-22 black-box probe): + +```jsonc +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "id": ["The id field is required."] + } +} +``` + +Example body for a zero-Guid id (validator-level rejection): + +```jsonc +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "id": ["`id` must be a non-zero GUID (the caller's idempotency key)."] + } +} +``` + +## Invariants + +- **Inv-1**: `id` MUST be a non-zero GUID. Pre-AZ-808, omitting `id` silently coerced to `Guid.Empty` and queued a region under the zero key; AZ-808 fails this with HTTP 400 and `errors["id"]`. +- **Inv-2**: `lat ∈ [-90.0, 90.0]`. Out-of-range → 400 with `errors["lat"]`. +- **Inv-3**: `lon ∈ [-180.0, 180.0]`. Out-of-range → 400 with `errors["lon"]`. +- **Inv-4**: `sizeMeters ∈ [100.0, 10000.0]`. Out-of-range → 400 with `errors["sizeMeters"]`. Pre-AZ-808 this rule lived as an inline `if` in the handler; AZ-808 moves it into the validator. +- **Inv-5**: `zoomLevel ∈ [0, 22]` (slippy-map zoom range, matching `tile-inventory.md` Inv-8). +- **Inv-6**: `stitchTiles` MUST be explicitly provided. No defaulting to `false` — callers declare intent. +- **Inv-7**: Unknown fields at root are rejected with HTTP 400 + the field name in `errors`. +- **Inv-8** (idempotency, AZ-362): Two POSTs with the same `id` return the existing region resource with HTTP 200 and do NOT enqueue duplicate background processing. The post-rename `lat`/`lon` wire format does not affect this invariant. +- **Inv-9** (async semantics): The endpoint returns immediately after enqueuing. Status transitions to `completed`/`failed` happen on the background `RegionProcessingService`. Callers MUST poll `GET /api/satellite/region/{id}` to observe completion. + +## Non-Goals + +- **Not covered**: tile body fetch. The background worker writes tiles into the `tiles` table; callers fetch bodies via `GET /tiles/{z}/{x}/{y}` after polling shows `status == completed`. +- **Not covered**: backward-compatibility shim for `Latitude/Longitude` wire field names. AZ-812 ships v1.0.0 of this contract directly with the post-rename names; pre-rename callers receive HTTP 400 with `errors["latitude"]: ["could not be mapped"]`. There is no transitional accept-both period. +- **Not covered**: geofencing semantics. Geofences are a Route concern, not a Region concern; documented in `route-create.md` (forthcoming, AZ-809). +- **Not covered**: cancellation of a queued region. The current API has no DELETE / cancel verb. Tracked separately if needed. + +## Versioning Rules + +- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behaviour. +- **Minor (1.x.0)**: Adding an optional response field consumers may safely ignore (e.g. ETA estimate); relaxing a range constraint within `[-90,90]` / `[-180,180]` envelope (e.g. accepting decimal degrees with extra precision). +- **Major (2.0.0)**: Changing a field name; tightening a range constraint (breaks today's valid callers); making `stitchTiles` optional with a default again; removing idempotency. + +## Test Cases + +| Case | Input | Expected | Notes | +|------|-------|----------|-------| +| happy-path | `{id:, lat:47.46, lon:37.64, sizeMeters:200, zoomLevel:18, stitchTiles:false}` | HTTP 200 + RegionStatusResponse(status="queued") | AC-2 | +| missing-id | body without `id` field | HTTP 400 + `errors["id"]` | Inv-1 (probe gap) | +| zero-guid-id | `id: "00000000-..."` | HTTP 400 + `errors["id"]` | Inv-1 | +| missing-lat | body without `lat` | HTTP 400 + `errors["lat"]` | JsonRequired | +| lat-out-of-range | `lat: 91` | HTTP 400 + `errors["lat"]` | Inv-2 | +| missing-lon | body without `lon` | HTTP 400 + `errors["lon"]` | JsonRequired | +| lon-out-of-range | `lon: 181` | HTTP 400 + `errors["lon"]` | Inv-3 | +| missing-sizeMeters | body without `sizeMeters` | HTTP 400 + `errors["sizeMeters"]` | JsonRequired | +| sizeMeters-out-of-range | `sizeMeters: 1000000` | HTTP 400 + `errors["sizeMeters"]` | Inv-4 | +| missing-zoomLevel | body without `zoomLevel` | HTTP 400 + `errors["zoomLevel"]` | JsonRequired | +| zoomLevel-out-of-range | `zoomLevel: 30` | HTTP 400 + `errors["zoomLevel"]` | Inv-5 | +| missing-stitchTiles | body without `stitchTiles` | HTTP 400 + `errors["stitchTiles"]` | Inv-6 | +| lat-type-mismatch | `lat: "fifty"` | HTTP 400 (deserializer JsonException) | wire-format failure | +| unknown-root-field | body with `unknownField: 1` | HTTP 400 + `errors["unknownField"]` | Inv-7 | +| legacy-latitude-name | body with `latitude:` instead of `lat:` | HTTP 400 + `errors["latitude"]` | AZ-812 hard switch | +| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline | +| idempotent-double-post | same body POSTed twice | both HTTP 200; same `createdAt`; no duplicate background work | AC-2 + AZ-362 | + +## Change Log + +| Version | Date | Change | Author | +|---------|------|--------|--------| +| 1.0.0 | 2026-05-22 | Initial contract for `POST /api/satellite/request`. Publishes the post-AZ-812 OSM-convention wire format (`lat`/`lon`) and the AZ-808 strict-validation rules (non-zero `id`, range-checked `lat`/`lon`/`sizeMeters`/`zoomLevel`, explicit `stitchTiles`, unknown-field rejection). References `error-shape.md` v1.0.0 for the 400 body shape and `tile-inventory.md` v2.0.0 for the downstream read path (callers seed via region, then read via inventory). | autodev (Step 10, cycle 8) | diff --git a/_docs/02_document/contracts/api/tile-latlon.md b/_docs/02_document/contracts/api/tile-latlon.md new file mode 100644 index 0000000..b22b9ec --- /dev/null +++ b/_docs/02_document/contracts/api/tile-latlon.md @@ -0,0 +1,165 @@ +# Contract: tile-latlon + +**Component**: WebApi (`SatelliteProvider.Api`) producing rows via TileDownloader (`SatelliteProvider.Services.TileDownloader`) +**Producer task**: AZ-811 — `_docs/02_tasks/done/AZ-811_latlon_get_endpoint_validation.md` (validator + this contract; renames query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` for OSM consistency) +**Consumer tasks**: dev / debug clients, future mission-planner UI single-tile-by-click flows; NOT currently consumed by `gps-denied-onboard` (the onboard side uses `GET /tiles/{z}/{x}/{y}` with pre-computed coords from inventory) +**Version**: 1.0.0 +**Status**: frozen +**Last Updated**: 2026-05-22 + +## Purpose + +Defines the HTTP contract for `GET /api/satellite/tiles/latlon` — the single-tile-by-coordinate read endpoint that converts a `(lat, lon, zoom)` triple to a slippy-map `(z, x, y)`, downloads the tile from Google Maps if not already cached, persists it, and returns the row's metadata as `DownloadTileResponse`. The actual tile bytes are served separately via `GET /tiles/{z}/{x}/{y}` once the caller has the resulting `(z, x, y)` (or the equivalent `tilePath` from the response). + +This is the v1.0.0 of the contract — published alongside AZ-811's validator landing. There is no prior contract document; the producer-doc surface before AZ-811 was `modules/api_program.md::GetTileByLatLon Handler` only. + +## Endpoint + +``` +GET /api/satellite/tiles/latlon?lat=&lon=&zoom= +Authorization: Bearer +``` + +The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required. Anonymous requests are rejected with HTTP 401. + +## Shape + +### Query parameters + +``` +?lat=47.461747&lon=37.647063&zoom=18 +``` + +Per-parameter constraints: + +| Param | Type | Required | Description | Constraints | +|-------|------|----------|-------------|-------------| +| `lat` | number | yes | WGS84 latitude (decimal degrees). | `[-90.0, 90.0]`. | +| `lon` | number | yes | WGS84 longitude (decimal degrees). | `[-180.0, 180.0]`. | +| `zoom` | integer | yes | Slippy-map zoom level. | `[0, 22]`. | + +Strict shape: any query-string parameter outside `{lat, lon, zoom}` is rejected by `RejectUnknownQueryParamsEndpointFilter` with HTTP 400 + the unknown key name in `errors`. This catches typos like `?latitude=` (pre-AZ-811 wire name) that ASP.NET model binding would otherwise silently ignore, and it also rejects hostile fingerprinting probes like `?debug=1&admin=true`. + +**Required-field detection**: the bound DTO (`GetTileByLatLonQuery`) declares `lat` / `lon` / `zoom` as nullable (`double?`, `double?`, `int?`). Missing a query param therefore binds to `null` rather than throwing `BadHttpRequestException` from the framework binder — the request reaches the endpoint filters in all cases. `GetTileByLatLonQueryValidator` then enforces `NotNull` (chained `CascadeMode.Stop` ahead of the range rule) so a missing param surfaces as `errors[]: ["\`\` is required."]` exactly like any other validation failure. The handler dereferences `.Value` only after the validator filter has passed, guaranteed by the filter ordering. + +### Response body + +```jsonc +{ + "id": "e228d1aa-25d4-556e-a72d-e0484756e165", + "zoomLevel": 18, + "latitude": 47.461747, + "longitude": 37.647063, + "tileSizeMeters": 39.84, + "tileSizePixels": 256, + "imageType": "jpg", + "version": 1, + "filePath": "tiles/18/158485/91707.jpg", + "createdAt": "2026-05-22T12:34:56.789Z", + "updatedAt": "2026-05-22T12:34:56.789Z" +} +``` + +Per-field semantics: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Deterministic UUIDv5 of the tile (`Uuidv5.TileNamespace, "{z}/{x}/{y}"`). | +| `zoomLevel` | integer | Echoes the request `zoom` param. | +| `latitude` | number | Tile centre latitude (server-resolved from slippy `(z,x,y)`; may differ from the request `lat` by up to half a tile). | +| `longitude` | number | Tile centre longitude. | +| `tileSizeMeters` | number | Approximate ground footprint of the tile at this zoom and latitude. | +| `tileSizePixels` | integer | Fixed at 256 (slippy-map convention). | +| `imageType` | string | Always `"jpg"`. | +| `version` | integer | Tile row version (bumps on each refresh). | +| `filePath` | string | Relative path under the tile cache root (`tiles/{z}/{x}/{y}.jpg`). | +| `createdAt` | ISO-8601 UTC | Tile row creation timestamp. | +| `updatedAt` | ISO-8601 UTC | Tile row last-modification timestamp. | + +Response field names are intentionally LEGACY (`zoomLevel`, `latitude`, `longitude`) — only the request shape (query params) was renamed by AZ-811. The response is shared with `tile-storage.md` for caller consistency. + +### Endpoint summary + +| Method | Path | Request | Response | Status codes | +|--------|------|---------|----------|--------------| +| `GET` | `/api/satellite/tiles/latlon` | query string `?lat&lon&zoom` | `DownloadTileResponse` | 200, 400, 401 | + +## Error shape + +All `400` responses conform to `_docs/02_document/contracts/api/error-shape.md` v1.0.0. Two enforcement layers produce identically-shaped bodies: + +1. **`RejectUnknownQueryParamsEndpointFilter`** (envelope, runs first) — rejects any query key outside `{lat, lon, zoom}` with `errors[]: ["Unknown query parameter ..."]`. Catches typos and hostile probes. +2. **`GetTileByLatLonQueryValidator`** (FluentValidation, runs second) — range-checks `lat` / `lon` / `zoom` with `errors[]: ["... must be between ..."]`. + +Example body for a legacy-param-name failure (pre-AZ-811 wire format): + +```jsonc +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Latitude": ["Unknown query parameter `Latitude`. Allowed: `lat`, `lon`, `zoom`."], + "Longitude": ["Unknown query parameter `Longitude`. Allowed: `lat`, `lon`, `zoom`."], + "ZoomLevel": ["Unknown query parameter `ZoomLevel`. Allowed: `lat`, `lon`, `zoom`."] + } +} +``` + +Example body for an out-of-range failure: + +```jsonc +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "lat": ["`lat` must be between -90 and 90."] + } +} +``` + +## Invariants + +- **Inv-1**: `lat ∈ [-90.0, 90.0]`. Out-of-range → 400 with `errors["lat"]`. +- **Inv-2**: `lon ∈ [-180.0, 180.0]`. Out-of-range → 400 with `errors["lon"]`. +- **Inv-3**: `zoom ∈ [0, 22]` (slippy-map zoom range, matching `tile-inventory.md` Inv-8 and `region-request.md` Inv-5). +- **Inv-4** (AZ-811 envelope filter): Any query-string key outside `{lat, lon, zoom}` → 400 with `errors[]`. This is the novel envelope-strictness layer introduced by AZ-811; reuse the filter on future query-string endpoints by passing a fresh allowed-keys set. +- **Inv-5** (deterministic mapping): `(lat, lon, zoom)` deterministically resolves to a single slippy-map `(z, x, y)` and therefore to a single `Uuidv5.TileNamespace`-derived tile `id`. Re-requesting the same triple returns the SAME `id` (cache hit if the tile already exists). Cross-referenced from `common_uuidv5.md`. +- **Inv-6** (cache reuse): If the resolved `(z, x, y)` already has a row in `tiles`, no new Google-Maps fetch occurs; the existing row's metadata is returned. The handler delegates this decision to `ITileService.DownloadAndStoreSingleTileAsync`. + +## Non-Goals + +- **Not covered**: tile body fetch. This endpoint returns metadata only. Bytes are served via `GET /tiles/{z}/{x}/{y}` (slippy-map URL). +- **Not covered**: bulk download. Use `POST /api/satellite/tiles/inventory` for batch-lookup or `POST /api/satellite/request` for region pre-fetch. +- **Not covered**: MGRS-based input. See `GET /api/satellite/tiles/mgrs` (stub, 501). +- **Not covered**: backward-compatibility shim for `Latitude/Longitude/ZoomLevel` query param names. AZ-811 ships v1.0.0 directly with the post-rename names; pre-rename callers receive HTTP 400 from the envelope filter naming each unknown key. There is no transitional accept-both period. +- **Not covered**: path-parameter validation on `GET /tiles/{z}/{x}/{y}` (the slippy-map body endpoint). That endpoint uses integer-binding which framework-validates the type but not the range; a separate task may add range checks if needed. + +## Versioning Rules + +- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behaviour. +- **Minor (1.x.0)**: Adding an optional query param consumers may safely omit (e.g. `?format=png` if a non-jpg variant is later supported); adding an optional response field. +- **Major (2.0.0)**: Changing any query-param name; tightening a range constraint that breaks current callers; removing `tileSizeMeters` from the response. + +## Test Cases + +| Case | Input | Expected | Notes | +|------|-------|----------|-------| +| happy-path | `?lat=47.461747&lon=37.647063&zoom=18` | HTTP 200 + DownloadTileResponse | AC-2 | +| missing-lat | `?lon=37.647063&zoom=18` | HTTP 400 + `errors["lat"]: ["\`lat\` is required."]` | Inv-1 (NotNull rule) | +| lat-out-of-range | `?lat=91&lon=37.647063&zoom=18` | HTTP 400 + `errors["lat"]` | Inv-1 (range rule) | +| lon-out-of-range | `?lat=47.461747&lon=181&zoom=18` | HTTP 400 + `errors["lon"]` | Inv-2 | +| zoom-out-of-range | `?lat=47.461747&lon=37.647063&zoom=30` | HTTP 400 + `errors["zoom"]` | Inv-3 | +| legacy-param-names | `?Latitude=47.46&Longitude=37.64&ZoomLevel=18` (pre-AZ-811 wire format) | HTTP 400 + `errors["Latitude","Longitude","ZoomLevel"]` | Inv-4 (AZ-811 envelope) | +| hostile-extra-keys | `?lat=...&lon=...&zoom=18&debug=1&admin=true` | HTTP 400 + `errors["debug","admin"]` | Inv-4 | +| typo-zooom | `?lat=...&lon=...&zooom=18` | HTTP 400 + `errors["zooom"]` | Inv-4 | +| lat-type-mismatch | `?lat=fifty&lon=...&zoom=18` | HTTP 400 (model-binder JsonException-equivalent) | Wire-format failure | +| cache-reuse | repeat happy-path | HTTP 200; same `id`; no new GET to Google Maps | Inv-5 + Inv-6 | +| auth-anonymous | no Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline | + +## Change Log + +| Version | Date | Change | Author | +|---------|------|--------|--------| +| 1.0.0 | 2026-05-22 | Initial contract for `GET /api/satellite/tiles/latlon`. Publishes the post-AZ-811 OSM-convention query params (`lat`/`lon`/`zoom`) and the AZ-811 two-layer strict validation (envelope filter for unknown-keys + value-validator for range checks). References `error-shape.md` v1.0.0 for the 400 body shape and `tile-inventory.md` v2.0.0 for the bulk-lookup alternative. Pre-AZ-811 query-param names (`Latitude/Longitude/ZoomLevel`) are explicitly rejected by the envelope filter — no transitional shim. | autodev (Step 10, cycle 8) | diff --git a/_docs/02_document/modules/api_program.md b/_docs/02_document/modules/api_program.md index bf1956b..8133ab1 100644 --- a/_docs/02_document/modules/api_program.md +++ b/_docs/02_document/modules/api_program.md @@ -9,7 +9,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi | Method | Route | Handler | Description | |--------|-------|---------|-------------| | 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 | +| GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` (OSM convention) and added strict validation: range-checked `lat`/`lon`/`zoom` via `WithValidation()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | | POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY` → `z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation()` 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. | @@ -21,7 +21,14 @@ Application entry point. Configures DI container, sets up middleware, defines mi ### Local Records (defined in Program.cs) - `GetSatelliteTilesResponse`, `SatelliteTile` — MGRS response stubs - `DownloadTileResponse` — tile download response -- `ParameterDescriptionFilter` — Swagger operation filter +- `ParameterDescriptionFilter` — Swagger operation filter (AZ-811 cycle 8 trimmed the obsolete `Latitude`/`Longitude`/`ZoomLevel` entries; the surviving `lat`/`lon`/`mgrs`/`squareSideMeters` keys still annotate query-string params) + +### Api/Validators (AZ-795 epic, AZ-811 cycle 8) +- `RejectUnknownQueryParamsEndpointFilter` — `IEndpointFilter` parameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807 `ValidationProblemDetails`. Apply BEFORE `WithValidation()` so unknown-param errors precede range checks against the bound default value. +- `GetTileByLatLonQueryValidator` — `AbstractValidator` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`\` is required."` (no spurious range error against a null sentinel). + +### Api/DTOs (AZ-811 cycle 8) +- `GetTileByLatLonQuery` — `record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the `GetTileByLatLon` handler. **Nullable on purpose**: minimal-API binding throws `BadHttpRequestException` for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` with no `errors{}` envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per `error-shape.md` v1.0.0. The handler dereferences `.Value` only after the validator filter passes. ### Common/DTO (region API) - `RequestRegionRequest` — `POST /api/satellite/request` body. Moved out of Program.cs by AZ-369. Fields: `Id` (Guid), `Lat`/`Lon` (double, JSON `lat`/`lon` per AZ-812 cycle 8 OSM rename), `SizeMeters`, `ZoomLevel` (int, default 18), `StitchTiles` (bool, default false). @@ -89,7 +96,12 @@ Application entry point. Configures DI container, sets up middleware, defines mi 5. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests). ### GetTileByLatLon Handler -Downloads a tile, persists it, returns metadata as `DownloadTileResponse`. +Binds `[AsParameters] GetTileByLatLonQuery` (record with nullable `[FromQuery(Name="lat"|"lon"|"zoom")]` properties — see `Api/DTOs` for nullability rationale). Wire-format params are OSM-short `lat`/`lon`/`zoom` post-AZ-811. Strict validation is layered: +1. `RejectUnknownQueryParamsEndpointFilter(new[] {"lat","lon","zoom"})` runs first — rejects any unexpected query key (e.g. `?latitude=` typo, or hostile fingerprinting probes) with RFC 7807 `ValidationProblemDetails` and an `errors[]` entry. +2. `WithValidation()` runs second — checks `NotNull` (missing param → `errors[]: "\`\` is required."`) and `InclusiveBetween` (`lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `zoom` ∈ [0, 22]). `CascadeMode.Stop` ensures null short-circuits the range check. +3. Handler dereferences `query.Lat!.Value`, `query.Lon!.Value`, `query.Zoom!.Value` (validator guarantees non-null), delegates to `ITileService.DownloadAndStoreSingleTileAsync(lat, lon, zoom)`, and returns `DownloadTileResponse`. + +The two filter layers produce identically-shaped ProblemDetails bodies. The `RejectUnknownQueryParamsEndpointFilter` is reusable — register it once per allowed-key set on any future query-string endpoint that needs the same shape-strictness. ### RequestRegion Handler Validates size (100–10000m), delegates to `IRegionService.RequestRegionAsync`. diff --git a/_docs/02_document/modules/common_uuidv5.md b/_docs/02_document/modules/common_uuidv5.md index 1509f26..95475d4 100644 --- a/_docs/02_document/modules/common_uuidv5.md +++ b/_docs/02_document/modules/common_uuidv5.md @@ -35,7 +35,7 @@ All members are static on `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. +The second value is observable end-to-end: a fresh `GET /api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=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. (AZ-811 cycle 8 renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` for OSM consistency.) ## Dependencies diff --git a/_docs/02_document/system-flows.md b/_docs/02_document/system-flows.md index ae9c78a..a7cf97a 100644 --- a/_docs/02_document/system-flows.md +++ b/_docs/02_document/system-flows.md @@ -32,11 +32,11 @@ ### Description -Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata. +Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata. The wire-format contract is `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0. ### Preconditions -- Valid latitude, longitude, and zoom level provided +- Query params `lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `zoom` ∈ [0, 22]. Any unknown query key (e.g. legacy `?Latitude=` typo) is rejected by `RejectUnknownQueryParamsEndpointFilter` (AZ-811 cycle 8) with HTTP 400. Range checks via `GetTileByLatLonQueryValidator`. - Google Maps session token configured ### Sequence Diagram @@ -80,11 +80,11 @@ sequenceDiagram ### Description -Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing. +Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing. The wire-format contract is `_docs/02_document/contracts/api/region-request.md` v1.0.0; failure responses follow `error-shape.md` v1.0.0. ### Preconditions -- Valid region parameters (lat, lon, size_meters, zoom_level) +- Valid region parameters: non-zero `id` (UUID), `lat` ∈ [-90, 90], `lon` ∈ [-180, 180], `sizeMeters` ∈ [100, 10000], `zoomLevel` ∈ [0, 22], explicit `stitchTiles` (bool, no default). Enforced by `RegionRequestValidator` + `[JsonRequired]` at the API edge (AZ-808 cycle 8). ### Sequence Diagram diff --git a/_docs/02_document/tests/blackbox-tests.md b/_docs/02_document/tests/blackbox-tests.md index 47fcbe8..aec7652 100644 --- a/_docs/02_document/tests/blackbox-tests.md +++ b/_docs/02_document/tests/blackbox-tests.md @@ -2,7 +2,7 @@ ## BT-01: Single Tile Download -**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18 +**Trigger**: GET /api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18 **Precondition**: Tile not in cache **Expected**: HTTP 200; JSON with zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath matching pattern `tiles/18/*/...` **Pass criterion**: All fields present and correct values @@ -86,13 +86,13 @@ ## BT-N01: Invalid Coordinates (out of range) -**Trigger**: GET /api/satellite/tiles/latlon?Latitude=91&Longitude=181&ZoomLevel=18 +**Trigger**: GET /api/satellite/tiles/latlon?lat=91&lon=181&zoom=18 **Expected**: Error response **Pass criterion**: HTTP 4xx or error in response body ## BT-N02: Invalid Zoom Level -**Trigger**: GET /api/satellite/tiles/latlon?Latitude=47.46&Longitude=37.64&ZoomLevel=25 +**Trigger**: GET /api/satellite/tiles/latlon?lat=47.46&lon=37.64&zoom=25 **Expected**: Error response **Pass criterion**: HTTP 4xx or error indicating invalid zoom @@ -163,7 +163,7 @@ All Cycle-2 UAV scenarios run with a JWT containing `permissions: ["GPS"]` (per ## BT-18: Existing Tile Endpoint Returns Identical Body with Valid Bearer -**Trigger**: GET `/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18` with a valid Bearer token. +**Trigger**: GET `/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18` with a valid Bearer token. **Precondition**: Tile may or may not be cached. **Expected**: Response body is structurally identical to BT-01 (`tileId`, `zoomLevel == 18`, `tileSizePixels == 256`, `imageType == "jpg"`, `filePath` matches `tiles/18/*/*`). **Pass criterion**: status == 200 AND BT-01's pass criterion AND no behavioral change vs pre-AZ-487 baseline. diff --git a/_docs/02_document/tests/security-tests.md b/_docs/02_document/tests/security-tests.md index 5b0aa3d..c3dc60e 100644 --- a/_docs/02_document/tests/security-tests.md +++ b/_docs/02_document/tests/security-tests.md @@ -2,7 +2,7 @@ ## SEC-01: SQL Injection via Coordinate Parameters -**Trigger**: GET /api/satellite/tiles/latlon?Latitude=1;DROP TABLE tiles--&Longitude=1&ZoomLevel=18 +**Trigger**: GET /api/satellite/tiles/latlon?lat=1;DROP TABLE tiles--&lon=1&zoom=18 **Expected**: Request rejected or treated as invalid parameter **Pass criterion**: HTTP 400 or parameter parsing error; no database damage; tiles table intact @@ -32,7 +32,7 @@ The pre-AZ-487 assumption "no authentication" is superseded by these scenarios. ## SEC-05: Anonymous Request to Any Authenticated Endpoint Returns 401 -**Trigger**: GET `/api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=18` (or any `/api/satellite/*` endpoint) with NO `Authorization` header. +**Trigger**: GET `/api/satellite/tiles/latlon?lat=...&lon=...&zoom=18` (or any `/api/satellite/*` endpoint) with NO `Authorization` header. **Precondition**: API running with `JWT_SECRET` configured. **Expected**: HTTP 401 Unauthorized; `WWW-Authenticate: Bearer` header present; response body does not leak validation internals. **Pass criterion**: status == 401 AND `WWW-Authenticate` header starts with `Bearer`. diff --git a/_docs/02_tasks/todo/AZ-808_region_endpoint_validation.md b/_docs/02_tasks/done/AZ-808_region_endpoint_validation.md similarity index 100% rename from _docs/02_tasks/todo/AZ-808_region_endpoint_validation.md rename to _docs/02_tasks/done/AZ-808_region_endpoint_validation.md diff --git a/_docs/02_tasks/todo/AZ-811_latlon_get_endpoint_validation.md b/_docs/02_tasks/done/AZ-811_latlon_get_endpoint_validation.md similarity index 100% rename from _docs/02_tasks/todo/AZ-811_latlon_get_endpoint_validation.md rename to _docs/02_tasks/done/AZ-811_latlon_get_endpoint_validation.md diff --git a/_docs/03_implementation/batch_02_cycle8_report.md b/_docs/03_implementation/batch_02_cycle8_report.md new file mode 100644 index 0000000..7039d98 --- /dev/null +++ b/_docs/03_implementation/batch_02_cycle8_report.md @@ -0,0 +1,106 @@ +# Batch Report + +**Batch**: 02 (cycle 8) +**Tasks**: AZ-808 (Region POST strict validation) + AZ-811 (lat/lon GET strict validation) +**Date**: 2026-05-22 + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|---------------|-------|-------------|--------| +| AZ-808_region_endpoint_validation | Done | 10 files (4 new) | smoke pass (mode=smoke, exit 0); 10 integration tests added | 8/8 ACs covered | none | +| AZ-811_latlon_get_endpoint_validation | Done | 19 files (8 new) | smoke pass; 8 integration tests + 4 filter unit tests + 9 validator unit tests added | 9/9 ACs covered | 1 Info (nullable DTO rationale, documented) | + +## AC Test Coverage + +### AZ-808 (8/8 ACs) +| AC | Coverage | +|----|----------| +| AC-1 | `RegionRequestValidator` exists at `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` with rules for `id` (non-empty), `lat` (`[-90, 90]`), `lon` (`[-180, 180]`), `sizeMeters` (`[100, 10000]`), `zoomLevel` (`[0, 22]`). | +| AC-2 | Happy path: `RegionRequestValidationTests.HappyPath_Returns200` returns HTTP 200. Smoke green. | +| AC-3 | Wired via `.WithValidation()` in `Program.cs` MapPost chain. | +| AC-4 | `RequestRegionRequest` has `[JsonRequired]` on every property (id, lat, lon, sizeMeters, zoomLevel, stitchTiles); missing-required produces `errors[]` via `GlobalExceptionHandler`'s `JsonException` path. Tested by `MissingId_Returns400` and `MissingStitchTiles_Returns400`. | +| AC-5 | Unit tests `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` — 11 methods covering each rule with positive + negative cases. | +| AC-6 | Integration tests `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` — 10 methods covering happy + 9 failure modes; all green in smoke. | +| AC-7 | New contract `_docs/02_document/contracts/api/region-request.md` v1.0.0 published. References `error-shape.md` v1.0.0 for 400 body shape. | +| AC-8 | Probe script `scripts/probe_region_validation.sh` covers happy + each failure mode via curl. | + +### AZ-811 (9/9 ACs) +| AC | Coverage | +|----|----------| +| AC-1 | 5 validations enforced: lat/lon/zoom range (validator), unknown-key (envelope filter), type-mismatch (model binder via `GlobalExceptionHandler`). All produce HTTP 400 + ValidationProblemDetails per `error-shape.md` v1.0.0. | +| AC-2 | Happy path: `GetTileByLatLonValidationTests.HappyPath_Returns200` returns HTTP 200 + `DownloadTileResponse`. Smoke green. | +| AC-3 | `GetTileByLatLonQueryValidator` lives at `SatelliteProvider.Api/Validators/`; unit tests cover 9 methods (3 per RuleFor + 3 null cases). | +| AC-4 | Integration tests cover 8 methods: happy + 3 range + 1 missing + 2 unknown (legacy + hostile) + 1 type-mismatch. | +| AC-5 | New contract `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 published. References `error-shape.md` v1.0.0 + `tile-inventory.md` v2.0.0. | +| AC-6 | `_docs/02_document/modules/api_program.md::GetTileByLatLon Handler` updated; references the validator + new contract + the envelope filter ordering. | +| AC-7 | OpenAPI: `.Accepts<>` not needed for GET; `.Produces(200)` + `.ProducesProblem(400)` declared on the endpoint chain. Swagger `ParameterDescriptionFilter` updated to describe lat/lon/zoom (post-rename). | +| AC-8 | Probe script `scripts/probe_latlon_validation.sh` covers happy + missing-lat/lon/zoom + 3 out-of-range + 3 unknown-key + 1 type-mismatch = 11 probes. | +| AC-9 | `RejectUnknownQueryParamsEndpointFilter` documented in `_docs/02_document/modules/api_program.md::Api/Validators` as a reusable component for the next query-param endpoint. | + +## Code Review Verdict: PASS_WITH_NOTES +See `_docs/03_implementation/reviews/batch_02_cycle8_review.md` for the single Info finding (nullable DTO rationale, documented in code + doc). + +## Auto-Fix Attempts: 1 (mid-batch) +- AZ-811 initially used non-nullable types on `GetTileByLatLonQuery`. The first smoke run uncovered the failing case `UnknownQueryParam_LegacyLatitude_Returns400`: minimal-API binding threw `BadHttpRequestException` for missing `lat` BEFORE the envelope filter could run, producing a plain `ProblemDetails` (no `errors{}` envelope) — a spec-AC violation. +- Root-cause investigation via diagnostic instrumentation (`Console.Error.WriteLine` in the filter + `Console.WriteLine` of the raw body in the failing test) confirmed the binder short-circuit before the filter. +- Fix: nullable types on the DTO + `NotNull` + `CascadeMode.Stop` in the validator + `.Value` dereference in the handler. Rationale documented in `GetTileByLatLonQuery.cs` and `api_program.md::Api/DTOs`. +- Smoke re-run after fix: all green (no skipped tests, no flakes). + +## Stuck Agents: None + +## Files Modified + +### AZ-808 +| Path | Kind | +|------|------| +| `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` | `[JsonRequired]` on every property + removed implicit defaults | +| `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` | **NEW** | +| `SatelliteProvider.Api/Program.cs` | `.WithValidation()` + removed inline size check | +| `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` | **NEW** | +| `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` | **NEW** | +| `SatelliteProvider.IntegrationTests/Program.cs` | Wired into smoke + full suites | +| `scripts/probe_region_validation.sh` | **NEW** | +| `_docs/02_document/contracts/api/region-request.md` | **NEW** v1.0.0 | +| `_docs/02_document/modules/api_program.md` | RequestRegion handler description | +| `_docs/02_document/system-flows.md` | F2 description | + +### AZ-811 +| Path | Kind | +|------|------| +| `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` | **NEW** (nullable record) | +| `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` | **NEW** | +| `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` | **NEW** (reusable) | +| `SatelliteProvider.Api/Program.cs` | Endpoint filter + .WithValidation + handler signature + .Value deref | +| `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` | lat/lon/zoom descriptions | +| `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` | **NEW** (9 methods) | +| `SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs` | **NEW** (4 methods) | +| `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` | **NEW** (8 methods) | +| `SatelliteProvider.IntegrationTests/TileTests.cs` | URL `?lat=&lon=&zoom=` | +| `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs` | `ProtectedTilesPath` const | +| `SatelliteProvider.IntegrationTests/SecurityTests.cs` | SQLi probe URL | +| `SatelliteProvider.IntegrationTests/Program.cs` | Wired into smoke + full suites | +| `scripts/probe_latlon_validation.sh` | **NEW** | +| `scripts/run-performance-tests.sh` | PT-01 URL update | +| `README.md` | Endpoint example | +| `_docs/02_document/contracts/api/tile-latlon.md` | **NEW** v1.0.0 | +| `_docs/02_document/modules/api_program.md` | Handler + Api/Validators + Api/DTOs | +| `_docs/02_document/modules/common_uuidv5.md` | Example URL | +| `_docs/02_document/system-flows.md` | F1 description | +| `_docs/02_document/tests/blackbox-tests.md` | BT-01/N01/N02/18 triggers | +| `_docs/02_document/tests/security-tests.md` | SEC-01/05 triggers | + +### Shared +| Path | Kind | +|------|------| +| `SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs` | Promoted `AssertErrorsContainsMention` to shared helper (closes batch-1 DRY warning) | +| `SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs` | Use shared helper | +| `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs` | Use shared helper | + +## Tracker + +- AZ-808: To Do → In Progress (batch 2 start) → **In Testing** (post-smoke). +- AZ-811: To Do → In Progress (batch 2 start) → **In Testing** (post-smoke). + +## Next Batch +Batch 3: AZ-809 — route-creation validator (3 DTOs, cross-field constraint: regionSizeMeters covers geofence overlap). Spec calls for a slightly more complex pattern than batch-2 because the validator has to inspect three child DTOs (route metadata + intermediate-points policy + geofence array). diff --git a/_docs/03_implementation/reviews/batch_02_cycle8_review.md b/_docs/03_implementation/reviews/batch_02_cycle8_review.md new file mode 100644 index 0000000..f1131c3 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_02_cycle8_review.md @@ -0,0 +1,151 @@ +# Code Review Report + +**Batch**: 02 (cycle 8) +**Tasks**: AZ-808 (Region POST endpoint strict validation) + AZ-811 (lat/lon GET endpoint strict validation) +**Date**: 2026-05-22 +**Verdict**: PASS_WITH_NOTES + +## Findings + +| # | Severity | Category | File:Line | Title | +|---|----------|----------|-----------|-------| +| 1 | Info | Design rationale | `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs:1-30` | DTO uses `double?` / `int?` on purpose to dodge minimal-API "Required parameter not provided" short-circuit | + +### Finding Details + +**F1: `GetTileByLatLonQuery` uses nullable types on purpose** (Info / Design rationale) +- Location: `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs:17-20` +- Description: The DTO declares `Lat`/`Lon`/`Zoom` as `double?`/`double?`/`int?`. Non-nullable variants would feel simpler but cause the minimal-API parameter binder to throw `BadHttpRequestException` BEFORE endpoint filters run when a query param is missing. That short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` — no `errors{}` envelope, no per-field key — which violates AZ-811 ACs 1 and 4 (every failure mode must surface as `errors.`). +- Initial implementation used non-nullable types. Diagnostic instrumentation captured the failing test response body (`{"title":"Bad Request","status":400,"detail":"Required parameter \"double Lat\" was not provided from query string."}`) which proved the binder was short-circuiting. Fix: switch to nullable + add `NotNull()` rule in the validator with `CascadeMode.Stop` ahead of the range rule. The handler dereferences `.Value` only after the validator filter passes. +- Suggestion: NONE — the rationale is now documented in both the DTO XML/doc comment and `api_program.md::Api/DTOs`. Captured here so a future reader doesn't "simplify" the types back to non-nullable. +- Task: AZ-811 + +## Phase Summary + +| Phase | Outcome | +|-------|---------| +| 1. Context Loading | Read AZ-808 + AZ-811 specs, `_docs/02_document/contracts/api/tile-inventory.md` (validator pattern reference from cycle 7), and the cycle-7 `ValidationEndpointFilter` shared infra. Both tasks share batch 2 because both wire `WithValidation()` and reuse the cycle-7 validation envelope. | +| 2. Spec Compliance | **AZ-808**: AC-1..AC-8 all ✓. New `RegionRequestValidator` covers `id`/`lat`/`lon`/`sizeMeters`/`zoomLevel`. `[JsonRequired]` on `RequestRegionRequest` enforces required-field at the deserializer (no defaulting). New contract `region-request.md` v1.0.0 published. Unit + integration tests cover happy path + each rule + missing-required + type-mismatch. Probe script exercises every failure mode via `curl`. **AZ-811**: AC-1..AC-9 all ✓. New `GetTileByLatLonQueryValidator` covers `lat`/`lon`/`zoom` with explicit `NotNull` for missing + `InclusiveBetween` for range (CascadeMode.Stop). New `RejectUnknownQueryParamsEndpointFilter` rejects any query key outside `{lat, lon, zoom}` with `Results.ValidationProblem`. New contract `tile-latlon.md` v1.0.0 published. Unit tests for both validator (7 methods) and filter (4 methods); integration tests cover happy path + 6 failure modes. Probe script exercises every failure mode. | +| 3. Code Quality | Mechanical patterns followed; new validators and filter are minimal and SRP-clean. One Info finding (F1) on the nullable-DTO design — surfaced rather than left implicit. Cycle-7 `AssertErrorsContainsMention` helper promoted to `ProblemDetailsAssertions.cs` (closes the Low-severity DRY warning from batch-1 review). | +| 4. Security | `RejectUnknownQueryParamsEndpointFilter` rejects fingerprinting probes (`?debug=1&admin=true`, `?Latitude=...&Longitude=...`) with HTTP 400 + named keys — no enumeration vector. `RegionRequestValidator` runs BEFORE any DB work (idempotency lookup, queueing). No SQL injection vectors, no hardcoded secrets, no PII in logs. JWT auth retained on both endpoints. | +| 5. Performance | Validators run synchronously against in-memory record fields — negligible cost vs the Google-Maps round-trip or DB write that follows. Endpoint filter inspects `Query.Keys` (in-memory dictionary scan). No N+1, no blocking I/O. | +| 6. Cross-Task Consistency | Both tasks share `ValidationEndpointFilter` infra from cycle 7 and the new shared `ProblemDetailsAssertions.AssertErrorsContainsMention`. `RegionRequestValidator` and `GetTileByLatLonQueryValidator` follow the same `Cascade(Stop).NotNull().InclusiveBetween()` pattern. Both produce identically-shaped `ValidationProblemDetails` per `error-shape.md` v1.0.0. | +| 7. Architecture Compliance | DTOs in `SatelliteProvider.Common/DTO/` (Region) and `SatelliteProvider.Api/DTOs/` (latlon query) — the query record is API-local because its `[FromQuery]` binding semantics are not reusable outside the API layer. Validators co-located with the API at `SatelliteProvider.Api/Validators/`. No layering violations. No cycles, no public-API bypasses, no ADR breaches. | + +## Files Reviewed + +### AZ-808 (Region POST validator) +- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` — `[JsonRequired]` on every property; removed default values for `ZoomLevel` and `StitchTiles` so callers cannot rely on implicit defaults. +- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` — **NEW** — 5 rules (id non-empty + 4 range rules). +- `SatelliteProvider.Api/Program.cs` (lines around `MapPost("/api/satellite/request", ...)`) — added `.WithValidation()`, `.Accepts<>`, `.Produces<>`, `.ProducesProblem()`. Removed inline `request.SizeMeters` size check (now in validator). +- `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` — **NEW** — Theory + Fact coverage for each rule, positive and negative. +- `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` — **NEW** — Happy + empty body + missing/zero GUID + 4 out-of-range + missing-stitch + type mismatch. +- `SatelliteProvider.IntegrationTests/Program.cs` — wired `RegionRequestValidationTests.RunAll` into smoke + full suites. +- `scripts/probe_region_validation.sh` — **NEW** — curl probes for every failure mode. +- `_docs/02_document/contracts/api/region-request.md` — **NEW** — v1.0.0 contract (no prior version existed). +- `_docs/02_document/modules/api_program.md` — RequestRegion handler description updated; references new contract. +- `_docs/02_document/system-flows.md::F2` — references new contract + validator. + +### AZ-811 (lat/lon GET validator) +- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` — **NEW** — nullable record with `[FromQuery(Name = ...)]` per property. Rationale documented in-file (F1). +- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` — **NEW** — `Cascade(Stop).NotNull().InclusiveBetween` per param. +- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` — **NEW** — reusable `IEndpointFilter` parameterised by allowed-keys set (case-insensitive). Returns `Results.ValidationProblem` with one error per unknown key. +- `SatelliteProvider.Api/Program.cs` (lines around `MapGet("/api/satellite/tiles/latlon", ...)`) — added envelope filter + `.WithValidation()`. Handler signature now `[AsParameters] GetTileByLatLonQuery query`; dereferences `query.Lat!.Value` etc. +- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` — descriptions for `lat`/`lon`/`zoom` (post-rename); removed legacy `Latitude`/`Longitude`/`ZoomLevel` entries. +- `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` — **NEW** — 9 methods incl. null cases. +- `SatelliteProvider.Tests/Validators/RejectUnknownQueryParamsEndpointFilterTests.cs` — **NEW** — 4 methods (delegation, unknown-key block, legacy PascalCase, case-insensitive allowed-set). +- `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` — **NEW** — Happy + 3 out-of-range + 1 missing-required + 2 unknown-key (legacy + hostile) + 1 type-mismatch = 8 methods. +- `SatelliteProvider.IntegrationTests/Program.cs` — wired `GetTileByLatLonValidationTests.RunAll` into smoke + full suites. +- `SatelliteProvider.IntegrationTests/TileTests.cs` — URL `?Latitude=&Longitude=&ZoomLevel=` → `?lat=&lon=&zoom=`. +- `SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs` — `ProtectedTilesPath` const updated. +- `SatelliteProvider.IntegrationTests/SecurityTests.cs` — SQLi probe URL updated. +- `scripts/probe_latlon_validation.sh` — **NEW** — curl probes incl. missing-lat, hostile probes, type mismatch. +- `scripts/run-performance-tests.sh` — PT-01 URL updated. +- `README.md` — endpoint example updated. +- `_docs/02_document/contracts/api/tile-latlon.md` — **NEW** — v1.0.0 contract (no prior version existed). +- `_docs/02_document/modules/api_program.md` — handler + `Api/Validators` + `Api/DTOs` sections updated. +- `_docs/02_document/modules/common_uuidv5.md` — example URL updated. +- `_docs/02_document/system-flows.md::F1` — references new contract + validation layers. +- `_docs/02_document/tests/blackbox-tests.md` — BT-01, BT-N01, BT-N02, BT-18 triggers updated. +- `_docs/02_document/tests/security-tests.md` — SEC-01, SEC-05 triggers updated. + +### Shared (cross-task hygiene) +- `SatelliteProvider.IntegrationTests/ProblemDetailsAssertions.cs` — `AssertErrorsContainsMention` promoted from per-test-file private helper to public static. Closes batch-1 Low-severity DRY warning. +- `SatelliteProvider.IntegrationTests/TileInventoryValidationTests.cs` — uses shared helper. +- `SatelliteProvider.IntegrationTests/RegionFieldRenameTests.cs` — uses shared helper; removed unused `using` directives. + +## Test Evidence + +`./scripts/run-tests.sh --smoke` (`integration-tests` container exit code 0): + +``` +Test: POST /api/satellite/request strict validation (AZ-808) +============================================================ + +AZ-808 AC-2: well-formed body → HTTP 200 + ✓ {id,lat,lon,sizeMeters,zoomLevel,stitchTiles} accepted with HTTP 200 + +AZ-808 rule (id-empty): id=Guid.Empty → HTTP 400 + ✓ id=Guid.Empty rejected with errors["id"] + +AZ-808 rule (id-missing): missing id → HTTP 400 via [JsonRequired] + ✓ Missing id rejected via [JsonRequired] (no defaulting to Guid.Empty) + +AZ-808 rule (lat-out-of-range): lat=91 → HTTP 400 + ✓ lat=91 rejected with errors["lat"] + +AZ-808 rule (lon-out-of-range): lon=181 → HTTP 400 + ✓ lon=181 rejected with errors["lon"] + +AZ-808 rule (sizeMeters-out-of-range): sizeMeters=50 → HTTP 400 + ✓ sizeMeters=50 rejected with errors["sizeMeters"] + +AZ-808 rule (zoomLevel-out-of-range): zoomLevel=30 → HTTP 400 + ✓ zoomLevel=30 rejected with errors["zoomLevel"] + +AZ-808 rule (stitchTiles-missing): missing stitchTiles → HTTP 400 via [JsonRequired] + ✓ Missing stitchTiles rejected via [JsonRequired] + +AZ-808 rule (type-mismatch): lat="bad" → HTTP 400 + ✓ Non-numeric lat rejected with HTTP 400 + +AZ-808 empty body → HTTP 400 + ✓ Empty body rejected with HTTP 400 +✓ POST /api/satellite/request validation tests: PASSED + +Test: GET /api/satellite/tiles/latlon strict validation (AZ-811) +================================================================ + +AZ-811 AC-2: well-formed query → HTTP 200 + ✓ {lat,lon,zoom} accepted with HTTP 200 + +AZ-811 rule 1: lat out of range (-90..90) → HTTP 400 + ✓ lat=91 rejected with errors["lat"] + +AZ-811 rule 2: lon out of range (-180..180) → HTTP 400 + ✓ lon=181 rejected with errors["lon"] + +AZ-811 rule 3: zoom out of range (0..22) → HTTP 400 + ✓ zoom=30 rejected with errors["zoom"] + +AZ-811 rule 1: missing `lat` query param → HTTP 400 with errors.lat + ✓ Missing lat rejected with errors["lat"] = `lat` is required + +AZ-811 rule 4: legacy `?Latitude=&Longitude=&ZoomLevel=` (pre-AZ-811 wire format) → HTTP 400 (envelope filter) + ✓ Legacy ?Latitude=&Longitude=&ZoomLevel= rejected by envelope filter + +AZ-811 rule 4: hostile/typo query keys → HTTP 400 (envelope filter) + ✓ ?debug=1&admin=true rejected; errors map names BOTH unknown keys + +AZ-811 rule 5: lat type mismatch (non-numeric) → HTTP 400 + ✓ lat=fifty rejected with HTTP 400 +✓ GET lat/lon validation tests: PASSED +``` + +`=== All tests passed (mode=smoke) ===` — no regressions in cycle-7 inventory/idempotent/security/route/tile/leaflet/migration suites. + +## Verdict Logic + +- No Critical, no High, no Medium findings. +- 1 Info finding (F1) — design rationale captured in code + doc; not a regression. +- **PASS_WITH_NOTES**. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 972d473..b868180 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -6,9 +6,9 @@ step: 10 name: Implement status: in_progress sub_step: - phase: 0 - name: awaiting-invocation - detail: "" + phase: 7 + name: batch-loop + detail: "batch 2 of 4 complete (AZ-808 + AZ-811 In Testing); next: batch 3 = AZ-809 route validator" retry_count: 0 cycle: 8 tracker: jira diff --git a/scripts/probe_latlon_validation.sh b/scripts/probe_latlon_validation.sh new file mode 100755 index 0000000..df8abca --- /dev/null +++ b/scripts/probe_latlon_validation.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Manual end-to-end probe for GET /api/satellite/tiles/latlon strict validation +# (AZ-811). Each failure call should return HTTP 400 with an +# `application/problem+json` body. The happy path should return HTTP 200. +# +# Two enforcement layers: +# 1. RejectUnknownQueryParamsEndpointFilter — rejects any query key outside +# {lat, lon, zoom}. +# 2. WithValidation — range-checks lat, lon, zoom. +# +# Usage: +# API_URL=https://localhost:8080 JWT="" ./scripts/probe_latlon_validation.sh + +API_URL="${API_URL:-https://localhost:8080}" +JWT="${JWT:-}" +PATH_LATLON="${API_URL%/}/api/satellite/tiles/latlon" + +if [[ -z "${JWT}" ]]; then + echo "ERROR: set JWT env var to a bearer token. Mint one via:" + echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only" + exit 2 +fi + +curl_args=(-sS -k -H "Authorization: Bearer ${JWT}" -X GET) + +probe() { + local label="$1" + local query="$2" + local expected_status="$3" + + echo "----- ${label} (expecting HTTP ${expected_status}) -----" + local response + response=$(curl "${curl_args[@]}" "${PATH_LATLON}?${query}" -w "\nHTTP_STATUS=%{http_code}\n") + echo "${response}" + local actual_status + actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//') + if [[ "${actual_status}" != "${expected_status}" ]]; then + echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}" + return 1 + fi + echo "OK: HTTP ${expected_status}" + echo +} + +probe "happy-path" "lat=47.461747&lon=37.647063&zoom=18" 200 +# Validator rules — NotNull (missing required) + InclusiveBetween (range) +probe "missing-lat" "lon=37.647063&zoom=18" 400 +probe "missing-lon" "lat=47.461747&zoom=18" 400 +probe "missing-zoom" "lat=47.461747&lon=37.647063" 400 +probe "lat-out-of-range" "lat=91&lon=37.647063&zoom=18" 400 +probe "lon-out-of-range" "lat=47.461747&lon=181&zoom=18" 400 +probe "zoom-out-of-range" "lat=47.461747&lon=37.647063&zoom=30" 400 +# Envelope rule: unknown query params (legacy pre-AZ-811 wire names + hostile probes) +probe "legacy-param-names" "Latitude=47.461747&Longitude=37.647063&ZoomLevel=18" 400 +probe "hostile-debug-admin" "lat=47.461747&lon=37.647063&zoom=18&debug=1&admin=true" 400 +probe "typo-zooom" "lat=47.461747&lon=37.647063&zooom=18" 400 +# Type mismatch (model binder) +probe "lat-type-mismatch" "lat=fifty&lon=37.647063&zoom=18" 400 + +echo "All probes passed." diff --git a/scripts/probe_region_validation.sh b/scripts/probe_region_validation.sh new file mode 100755 index 0000000..a4fd745 --- /dev/null +++ b/scripts/probe_region_validation.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Manual end-to-end probe for the region-request endpoint's strict validation +# gate (AZ-808). Each failure call should return HTTP 400 with an +# `application/problem+json` body whose `errors` map names the offending field +# path. The happy path should return HTTP 200. +# +# Usage: +# API_URL=https://localhost:8080 JWT="" ./scripts/probe_region_validation.sh +# (defaults to https://localhost:8080 with a JWT minted via PerfBootstrap --mint-only) + +API_URL="${API_URL:-https://localhost:8080}" +JWT="${JWT:-}" +PATH_REGION="${API_URL%/}/api/satellite/request" + +if [[ -z "${JWT}" ]]; then + echo "ERROR: set JWT env var to a bearer token. Mint one via:" + echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only" + exit 2 +fi + +curl_args=(-sS -k -H "Authorization: Bearer ${JWT}" -H "Content-Type: application/json" -X POST "${PATH_REGION}") + +probe() { + local label="$1" + local body="$2" + local expected_status="$3" + + echo "----- ${label} (expecting HTTP ${expected_status}) -----" + local response + response=$(curl "${curl_args[@]}" -d "${body}" -w "\nHTTP_STATUS=%{http_code}\n") + echo "${response}" + local actual_status + actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//') + if [[ "${actual_status}" != "${expected_status}" ]]; then + echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}" + return 1 + fi + echo "OK: HTTP ${expected_status}" + echo +} + +# Generate a unique guid for the happy path +HAPPY_ID="$(uuidgen)" + +probe "happy-path" "{\"id\":\"${HAPPY_ID}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 200 +# Reproduces the 2026-05-22 probe that surfaced silent-Guid-coercion (pre-AZ-808) +probe "missing-id" '{"lat":49.94,"lon":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}' 400 +probe "zero-guid-id" '{"id":"00000000-0000-0000-0000-000000000000","lat":47.461747,"lon":37.647063,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}' 400 +probe "missing-lat" "{\"id\":\"$(uuidgen)\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +probe "lat-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":91,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +probe "missing-lon" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +probe "lon-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":181,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +probe "missing-sizeMeters" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +probe "sizeMeters-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +probe "missing-zoomLevel" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"stitchTiles\":false}" 400 +probe "zoomLevel-out-of-range" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":30,\"stitchTiles\":false}" 400 +probe "missing-stitchTiles" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18}" 400 +probe "lat-type-mismatch" "{\"id\":\"$(uuidgen)\",\"lat\":\"fifty\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" 400 +# Unknown root field — confirms UnmappedMemberHandling.Disallow stays active +probe "unknown-root-field" "{\"id\":\"$(uuidgen)\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false,\"unknownField\":1}" 400 + +echo "All probes passed." diff --git a/scripts/run-performance-tests.sh b/scripts/run-performance-tests.sh index 9d2df71..40cf152 100755 --- a/scripts/run-performance-tests.sh +++ b/scripts/run-performance-tests.sh @@ -169,7 +169,7 @@ echo "PT-01: Tile Download Latency (cold) (threshold: 30000ms)" PT01_LAT="47.461347" PT01_LON="37.646663" START=$(date +%s%N) -HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?Latitude=$PT01_LAT&Longitude=$PT01_LON&ZoomLevel=18") +HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?lat=$PT01_LAT&lon=$PT01_LON&zoom=18") END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 )) if [[ "$HTTP_CODE" == "200" ]]; then @@ -182,7 +182,7 @@ fi echo "" echo "PT-02: Cached Tile Retrieval Latency (threshold: 500ms)" START=$(date +%s%N) -HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18") +HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18") END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 ))