mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 08:51:13 +00:00
[AZ-808] [AZ-811] Strict validation on region POST + lat/lon GET
AZ-808: FluentValidation for POST /api/satellite/request - RegionRequestValidator: id non-empty, lat/lon/sizeMeters/zoomLevel ranges - RequestRegionRequest: [JsonRequired] on every property, no implicit defaults - Wired via .WithValidation<RequestRegionRequest>() in MapPost chain - Unit + integration tests + curl probe script - New contract: contracts/api/region-request.md v1.0.0 AZ-811: FluentValidation + envelope filter for GET /api/satellite/tiles/latlon - GetTileByLatLonQuery: nullable record (double?/int?) so the minimal-API binder never short-circuits with BadHttpRequestException before filters - GetTileByLatLonQueryValidator: Cascade(Stop) + NotNull + InclusiveBetween per param; missing surfaces as `\`<name>\` is required.` - RejectUnknownQueryParamsEndpointFilter: reusable IEndpointFilter that rejects any query key outside the allowed set with errors[<key>] map; catches legacy `?Latitude=` typos and hostile probes (`?debug=1&admin=1`) - Handler: [AsParameters] GetTileByLatLonQuery + .Value deref post-validator - Unit (validator + filter) + integration tests + curl probe script - New contract: contracts/api/tile-latlon.md v1.0.0 Shared hygiene - Promote AssertErrorsContainsMention from per-test-file private helpers to ProblemDetailsAssertions (closes batch-1 Low-severity DRY warning) - Sync Swagger param descriptions, README, blackbox/security/perf scripts, uuidv5 doc with the new lat/lon/zoom query-param names Docs - system-flows.md F1/F2 reference the new contracts + validation layers - modules/api_program.md adds Api/Validators + Api/DTOs sections - _autodev_state.md: batch 2 of 4 complete; next batch = AZ-809 All smoke tests green (mode=smoke, exit 0). AZ-808 + AZ-811 transitioned to In Testing on Jira. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -73,7 +73,7 @@ The service follows a layered architecture:
|
|||||||
### Download Single Tile
|
### Download Single Tile
|
||||||
|
|
||||||
```http
|
```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.
|
Downloads a single tile at specified coordinates and zoom level.
|
||||||
|
|||||||
@@ -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.<paramName>` 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);
|
||||||
@@ -206,6 +206,10 @@ app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile)
|
|||||||
|
|
||||||
app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
|
app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
|
||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
|
.AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(new[] { "lat", "lon", "zoom" }))
|
||||||
|
.WithValidation<GetTileByLatLonQuery>()
|
||||||
|
.Produces<DownloadTileResponse>(StatusCodes.Status200OK)
|
||||||
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
|
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
|
||||||
|
|
||||||
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
|
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
|
||||||
@@ -239,6 +243,10 @@ app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
|||||||
|
|
||||||
app.MapPost("/api/satellite/request", RequestRegion)
|
app.MapPost("/api/satellite/request", RequestRegion)
|
||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
|
.WithValidation<RequestRegionRequest>()
|
||||||
|
.Accepts<RequestRegionRequest>("application/json")
|
||||||
|
.Produces<RegionStatusResponse>(StatusCodes.Status200OK)
|
||||||
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
.WithOpenApi(op => new(op)
|
.WithOpenApi(op => new(op)
|
||||||
{
|
{
|
||||||
Summary = "Request tiles for a region",
|
Summary = "Request tiles for a region",
|
||||||
@@ -271,9 +279,11 @@ async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITil
|
|||||||
return Results.Bytes(tile.Bytes, tile.ContentType);
|
return Results.Bytes(tile.Bytes, tile.ContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, HttpContext httpContext, ITileService tileService)
|
async Task<IResult> 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
|
var response = new DownloadTileResponse
|
||||||
{
|
{
|
||||||
@@ -341,11 +351,6 @@ async Task<IResult> UploadUavTileBatch(
|
|||||||
|
|
||||||
async Task<IResult> RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService)
|
async Task<IResult> 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(
|
var status = await regionService.RequestRegionAsync(
|
||||||
request.Id,
|
request.Id,
|
||||||
request.Lat,
|
request.Lat,
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ public class ParameterDescriptionFilter : IOperationFilter
|
|||||||
|
|
||||||
var parameterDescriptions = new Dictionary<string, string>
|
var parameterDescriptions = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["lat"] = "Latitude coordinate where image was captured",
|
["lat"] = "Latitude coordinate (WGS84, decimal degrees, [-90, 90])",
|
||||||
["lon"] = "Longitude coordinate where image was captured",
|
["lon"] = "Longitude coordinate (WGS84, decimal degrees, [-180, 180])",
|
||||||
|
["zoom"] = "Slippy-map zoom level [0, 22] (higher = more detail)",
|
||||||
["mgrs"] = "MGRS coordinate string",
|
["mgrs"] = "MGRS coordinate string",
|
||||||
["squareSideMeters"] = "Square side size in meters",
|
["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)"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var parameter in operation.Parameters)
|
foreach (var parameter in operation.Parameters)
|
||||||
|
|||||||
@@ -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<GetTileByLatLonQuery> at endpoint registration
|
||||||
|
// time (.WithValidation<GetTileByLatLonQuery>() 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<GetTileByLatLonQuery>
|
||||||
|
{
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RequestRegionRequest> at endpoint
|
||||||
|
// registration time (.WithValidation<RequestRegionRequest>() 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<RequestRegionRequest>
|
||||||
|
{
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[<paramName>] = "Unknown query parameter ...", matching the
|
||||||
|
// shape produced by ValidationEndpointFilter<T> + GlobalExceptionHandler.
|
||||||
|
//
|
||||||
|
// Apply BEFORE ValidationEndpointFilter<T> so unknown-param errors precede
|
||||||
|
// range checks against the bound default value.
|
||||||
|
public sealed class RejectUnknownQueryParamsEndpointFilter : IEndpointFilter
|
||||||
|
{
|
||||||
|
private readonly HashSet<string> _allowedKeys;
|
||||||
|
|
||||||
|
public RejectUnknownQueryParamsEndpointFilter(IEnumerable<string> allowedKeys)
|
||||||
|
{
|
||||||
|
_allowedKeys = new HashSet<string>(allowedKeys, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<object?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,39 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SatelliteProvider.Common.DTO;
|
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
|
public record RequestRegionRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[JsonRequired]
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[JsonRequired]
|
||||||
[JsonPropertyName("lat")]
|
[JsonPropertyName("lat")]
|
||||||
public double Lat { get; set; }
|
public double Lat { get; set; }
|
||||||
|
|
||||||
[Required]
|
[JsonRequired]
|
||||||
[JsonPropertyName("lon")]
|
[JsonPropertyName("lon")]
|
||||||
public double Lon { get; set; }
|
public double Lon { get; set; }
|
||||||
|
|
||||||
[Required]
|
[JsonRequired]
|
||||||
public double SizeMeters { get; set; }
|
public double SizeMeters { get; set; }
|
||||||
|
|
||||||
[Required]
|
[JsonRequired]
|
||||||
public int ZoomLevel { get; set; } = 18;
|
public int ZoomLevel { get; set; }
|
||||||
|
|
||||||
public bool StitchTiles { get; set; } = false;
|
[JsonRequired]
|
||||||
|
public bool StitchTiles { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<GetTileByLatLonQuery> — 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace SatelliteProvider.IntegrationTests;
|
|||||||
|
|
||||||
public static class JwtIntegrationTests
|
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";
|
private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
public static async Task RunAll(string apiUrl, string secret)
|
public static async Task RunAll(string apiUrl, string secret)
|
||||||
|
|||||||
@@ -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<string> EnumeratePaths(JsonElement errorsEl)
|
private static IEnumerable<string> EnumeratePaths(JsonElement errorsEl)
|
||||||
{
|
{
|
||||||
foreach (var prop in errorsEl.EnumerateObject())
|
foreach (var prop in errorsEl.EnumerateObject())
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ class Program
|
|||||||
await TileInventoryTests.RunAll(httpClient);
|
await TileInventoryTests.RunAll(httpClient);
|
||||||
await TileInventoryValidationTests.RunAll(httpClient);
|
await TileInventoryValidationTests.RunAll(httpClient);
|
||||||
await RegionFieldRenameTests.RunAll(httpClient);
|
await RegionFieldRenameTests.RunAll(httpClient);
|
||||||
|
await RegionRequestValidationTests.RunAll(httpClient);
|
||||||
|
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||||
await MigrationTests.RunAll();
|
await MigrationTests.RunAll();
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,8 @@ class Program
|
|||||||
await TileInventoryTests.RunAll(httpClient);
|
await TileInventoryTests.RunAll(httpClient);
|
||||||
await TileInventoryValidationTests.RunAll(httpClient);
|
await TileInventoryValidationTests.RunAll(httpClient);
|
||||||
await RegionFieldRenameTests.RunAll(httpClient);
|
await RegionFieldRenameTests.RunAll(httpClient);
|
||||||
|
await RegionRequestValidationTests.RunAll(httpClient);
|
||||||
|
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||||
await MigrationTests.RunAll();
|
await MigrationTests.RunAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace SatelliteProvider.IntegrationTests;
|
namespace SatelliteProvider.IntegrationTests;
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ public static class RegionFieldRenameTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-812 legacy field names");
|
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");
|
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");
|
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
return httpClient.PostAsync(RegionPath, content);
|
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}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||||
|
{
|
||||||
|
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
|
return httpClient.PostAsync(RegionPath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public static class SecurityTests
|
|||||||
Console.WriteLine("SEC-01: SQL injection attempt in coordinate query string");
|
Console.WriteLine("SEC-01: SQL injection attempt in coordinate query string");
|
||||||
|
|
||||||
var injection = "' OR 1=1 --";
|
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);
|
var response = await httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (response.StatusCode != HttpStatusCode.BadRequest && response.StatusCode != HttpStatusCode.UnprocessableEntity)
|
if (response.StatusCode != HttpStatusCode.BadRequest && response.StatusCode != HttpStatusCode.UnprocessableEntity)
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ public static class TileInventoryValidationTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 missing z");
|
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");
|
Console.WriteLine(" ✓ Missing `z` rejected with errors map mentioning the field");
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ public static class TileInventoryValidationTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown root field");
|
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");
|
Console.WriteLine(" ✓ Unknown root field rejected; errors map names the field");
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ public static class TileInventoryValidationTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown nested field");
|
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");
|
Console.WriteLine(" ✓ Unknown nested field rejected; errors map names the field");
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ public static class TileInventoryValidationTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-794 legacy field");
|
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)");
|
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");
|
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
return httpClient.PostAsync(InventoryPath, content);
|
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}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public static class TileTests
|
|||||||
|
|
||||||
Console.WriteLine($"Getting tile at coordinates ({latitude}, {longitude}) with zoom level {zoomLevel}");
|
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)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,7 @@ public static class TileTests
|
|||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("Testing tile reuse (getting same tile again)...");
|
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)
|
if (!response2.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, StringValues>
|
||||||
|
{
|
||||||
|
["lat"] = "47.461747",
|
||||||
|
["lon"] = "37.647063",
|
||||||
|
["zoom"] = "18"
|
||||||
|
});
|
||||||
|
var sentinel = new object();
|
||||||
|
EndpointFilterDelegate next = _ => ValueTask.FromResult<object?>(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<string, StringValues>
|
||||||
|
{
|
||||||
|
["lat"] = "47.461747",
|
||||||
|
["lon"] = "37.647063",
|
||||||
|
["zoom"] = "18",
|
||||||
|
["debug"] = "1"
|
||||||
|
});
|
||||||
|
var nextCalled = false;
|
||||||
|
EndpointFilterDelegate next = _ =>
|
||||||
|
{
|
||||||
|
nextCalled = true;
|
||||||
|
return ValueTask.FromResult<object?>(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<ProblemHttpResult>().Subject;
|
||||||
|
problem.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||||
|
problem.ProblemDetails.Should().BeOfType<HttpValidationProblemDetails>();
|
||||||
|
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<string, StringValues>
|
||||||
|
{
|
||||||
|
["Latitude"] = "47.461747",
|
||||||
|
["Longitude"] = "37.647063",
|
||||||
|
["ZoomLevel"] = "18"
|
||||||
|
});
|
||||||
|
EndpointFilterDelegate next = _ => ValueTask.FromResult<object?>(new object());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await filter.InvokeAsync(ctx, next);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var problem = result.Should().BeOfType<ProblemHttpResult>().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<string, StringValues>
|
||||||
|
{
|
||||||
|
["Lat"] = "47.461747",
|
||||||
|
["lon"] = "37.647063",
|
||||||
|
["ZOOM"] = "18"
|
||||||
|
});
|
||||||
|
var sentinel = new object();
|
||||||
|
EndpointFilterDelegate next = _ => ValueTask.FromResult<object?>(sentinel);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await filter.InvokeAsync(ctx, next);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeSameAs(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EndpointFilterInvocationContext BuildContext(IDictionary<string, StringValues> queryParams)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Request.Query = new QueryCollection(queryParams.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||||
|
return new DefaultEndpointFilterInvocationContext(httpContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <JWT>
|
||||||
|
```
|
||||||
|
|
||||||
|
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<RequestRegionRequest>`.
|
||||||
|
|
||||||
|
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:<guid>, 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) |
|
||||||
@@ -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=<float>&lon=<float>&zoom=<int>
|
||||||
|
Authorization: Bearer <JWT>
|
||||||
|
```
|
||||||
|
|
||||||
|
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[<paramName>]: ["\`<paramName>\` 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[<paramName>]: ["Unknown query parameter ..."]`. Catches typos and hostile probes.
|
||||||
|
2. **`GetTileByLatLonQueryValidator`** (FluentValidation, runs second) — range-checks `lat` / `lon` / `zoom` with `errors[<paramName>]: ["... 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[<key>]`. 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) |
|
||||||
@@ -9,7 +9,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
|||||||
| Method | Route | Handler | Description |
|
| 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 | `/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<GetTileByLatLonQuery>()`, 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<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
| POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY` → `z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||||
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
|
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
|
||||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. |
|
| POST | `/api/satellite/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)
|
### Local Records (defined in Program.cs)
|
||||||
- `GetSatelliteTilesResponse`, `SatelliteTile` — MGRS response stubs
|
- `GetSatelliteTilesResponse`, `SatelliteTile` — MGRS response stubs
|
||||||
- `DownloadTileResponse` — tile download response
|
- `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<T>()` so unknown-param errors precede range checks against the bound default value.
|
||||||
|
- `GetTileByLatLonQueryValidator` — `AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` 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)
|
### 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).
|
- `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).
|
5. Authenticated by `.RequireAuthorization()` (401 before validation runs for anonymous requests).
|
||||||
|
|
||||||
### GetTileByLatLon Handler
|
### 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[<paramName>]` entry.
|
||||||
|
2. `WithValidation<GetTileByLatLonQuery>()` runs second — checks `NotNull` (missing param → `errors[<paramName>]: "\`<paramName>\` 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
|
### RequestRegion Handler
|
||||||
Validates size (100–10000m), delegates to `IRegionService.RequestRegionAsync`.
|
Validates size (100–10000m), delegates to `IRegionService.RequestRegionAsync`.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ All members are static on `Uuidv5`:
|
|||||||
| `"18/12345/23456"` | `38b26f49-a966-5121-aaf4-9cc476f57869` |
|
| `"18/12345/23456"` | `38b26f49-a966-5121-aaf4-9cc476f57869` |
|
||||||
| `"18/12345/23456/google_maps/00000000-0000-0000-0000-000000000000"` | `e228d1aa-25d4-556e-a72d-e0484756e165` |
|
| `"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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@
|
|||||||
|
|
||||||
### Description
|
### 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
|
### 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
|
- Google Maps session token configured
|
||||||
|
|
||||||
### Sequence Diagram
|
### Sequence Diagram
|
||||||
@@ -80,11 +80,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
### Description
|
### 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
|
### 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
|
### Sequence Diagram
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## BT-01: Single Tile Download
|
## 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
|
**Precondition**: Tile not in cache
|
||||||
**Expected**: HTTP 200; JSON with zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath matching pattern `tiles/18/*/...`
|
**Expected**: HTTP 200; JSON with zoomLevel=18, tileSizePixels=256, imageType="jpg", filePath matching pattern `tiles/18/*/...`
|
||||||
**Pass criterion**: All fields present and correct values
|
**Pass criterion**: All fields present and correct values
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
|
|
||||||
## BT-N01: Invalid Coordinates (out of range)
|
## 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
|
**Expected**: Error response
|
||||||
**Pass criterion**: HTTP 4xx or error in response body
|
**Pass criterion**: HTTP 4xx or error in response body
|
||||||
|
|
||||||
## BT-N02: Invalid Zoom Level
|
## 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
|
**Expected**: Error response
|
||||||
**Pass criterion**: HTTP 4xx or error indicating invalid zoom
|
**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
|
## 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.
|
**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/*/*`).
|
**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.
|
**Pass criterion**: status == 200 AND BT-01's pass criterion AND no behavioral change vs pre-AZ-487 baseline.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## SEC-01: SQL Injection via Coordinate Parameters
|
## 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
|
**Expected**: Request rejected or treated as invalid parameter
|
||||||
**Pass criterion**: HTTP 400 or parameter parsing error; no database damage; tiles table intact
|
**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
|
## 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.
|
**Precondition**: API running with `JWT_SECRET` configured.
|
||||||
**Expected**: HTTP 401 Unauthorized; `WWW-Authenticate: Bearer` header present; response body does not leak validation internals.
|
**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`.
|
**Pass criterion**: status == 401 AND `WWW-Authenticate` header starts with `Bearer`.
|
||||||
|
|||||||
@@ -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<RequestRegionRequest>()` 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<DownloadTileResponse>(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<RequestRegionRequest>()` + 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).
|
||||||
@@ -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.<paramName>`).
|
||||||
|
- 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<T>` shared infra. Both tasks share batch 2 because both wire `WithValidation<T>()` 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<T>` 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<RequestRegionRequest>()`, `.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<GetTileByLatLonQuery>()`. 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**.
|
||||||
@@ -6,9 +6,9 @@ step: 10
|
|||||||
name: Implement
|
name: Implement
|
||||||
status: in_progress
|
status: in_progress
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 0
|
phase: 7
|
||||||
name: awaiting-invocation
|
name: batch-loop
|
||||||
detail: ""
|
detail: "batch 2 of 4 complete (AZ-808 + AZ-811 In Testing); next: batch 3 = AZ-809 route validator"
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 8
|
cycle: 8
|
||||||
tracker: jira
|
tracker: jira
|
||||||
|
|||||||
Executable
+62
@@ -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<GetTileByLatLonQuery> — range-checks lat, lon, zoom.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# API_URL=https://localhost:8080 JWT="<bearer-token>" ./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."
|
||||||
Executable
+64
@@ -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="<bearer-token>" ./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."
|
||||||
@@ -169,7 +169,7 @@ echo "PT-01: Tile Download Latency (cold) (threshold: 30000ms)"
|
|||||||
PT01_LAT="47.461347"
|
PT01_LAT="47.461347"
|
||||||
PT01_LON="37.646663"
|
PT01_LON="37.646663"
|
||||||
START=$(date +%s%N)
|
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)
|
END=$(date +%s%N)
|
||||||
ELAPSED_MS=$(( (END - START) / 1000000 ))
|
ELAPSED_MS=$(( (END - START) / 1000000 ))
|
||||||
if [[ "$HTTP_CODE" == "200" ]]; then
|
if [[ "$HTTP_CODE" == "200" ]]; then
|
||||||
@@ -182,7 +182,7 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "PT-02: Cached Tile Retrieval Latency (threshold: 500ms)"
|
echo "PT-02: Cached Tile Retrieval Latency (threshold: 500ms)"
|
||||||
START=$(date +%s%N)
|
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)
|
END=$(date +%s%N)
|
||||||
ELAPSED_MS=$(( (END - START) / 1000000 ))
|
ELAPSED_MS=$(( (END - START) / 1000000 ))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user