diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index 3d4894a..580c81b 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -122,6 +122,11 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddValidatorsFromAssemblyContaining(); GlobalValidatorConfig.ApplyOnce(); +// AZ-810: explicit registration so `.AddEndpointFilter()` +// on the UAV upload endpoint resolves the filter with its `IValidator<…>` + JSON +// options constructor deps. Transient so each request gets a fresh instance. +builder.Services.AddTransient(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -231,6 +236,7 @@ app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory) app.MapPost("/api/satellite/upload", UploadUavTileBatch) .RequireAuthorization(SatellitePermissions.UavUploadPolicy) + .AddEndpointFilter() .Accepts("multipart/form-data") .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) diff --git a/SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs b/SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs new file mode 100644 index 0000000..4a1afe5 --- /dev/null +++ b/SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Api.Validators; + +// AZ-810: root validator for the UAV upload metadata envelope. Runs from +// inside the custom `UavUploadValidationFilter` (the endpoint takes a +// multipart form, so the standard `WithValidation()` JSON-body filter +// doesn't apply). Error keys come out as `errors.items[…]` from this +// validator and are prefixed with `metadata.` by the filter, producing +// `errors.metadata.items[…]` in the final ValidationProblemDetails per +// `_docs/02_document/contracts/api/error-shape.md` v1.0.0. +public sealed class UavTileBatchMetadataPayloadValidator : AbstractValidator +{ + public UavTileBatchMetadataPayloadValidator( + IOptions qualityConfig, + TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(qualityConfig); + var maxBatchSize = qualityConfig.Value.MaxBatchSize; + + RuleFor(p => p.Items) + .NotNull().WithMessage("`items` is required.") + .NotEmpty().WithMessage("`items` must contain at least one entry.") + .Must(items => items is null || items.Count <= maxBatchSize) + .WithMessage($"`items` must contain at most {maxBatchSize} entries."); + + RuleForEach(p => p.Items) + .SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider)); + } +} diff --git a/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs b/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs new file mode 100644 index 0000000..3758e9c --- /dev/null +++ b/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs @@ -0,0 +1,67 @@ +using FluentValidation; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Api.Validators; + +// AZ-810: per-item metadata validator for the UAV upload endpoint. Runs as +// a `RuleForEach.SetValidator(...)` chain child of `UavTileBatchMetadataPayloadValidator`, +// so error keys come out as `errors.metadata.items[i].latitude`, `…tileZoom`, +// `…capturedAt`, etc. once the `UavUploadValidationFilter` prefixes the result. +// +// CapturedAt freshness (rule 11) is the same window that +// `IUavTileQualityGate.Validate` enforces; running the same check at the API +// boundary lets us short-circuit before any file bytes are inspected. The +// gate remains as a defence-in-depth backstop for unit tests of the gate +// itself and for the unlikely path of a caller invoking +// `IUavTileUploadHandler` directly (bypassing the filter). +public sealed class UavTileMetadataValidator : AbstractValidator +{ + private const double MinLat = -90.0; + private const double MaxLat = 90.0; + private const double MinLon = -180.0; + private const double MaxLon = 180.0; + private const int MinZoom = 0; + private const int MaxZoom = 22; + + public UavTileMetadataValidator(IOptions qualityConfig, TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(qualityConfig); + var cfg = qualityConfig.Value; + var tp = timeProvider ?? TimeProvider.System; + var maxAgeDays = cfg.MaxAgeDays; + var futureSkewSeconds = cfg.CapturedAtFutureSkewSeconds; + + RuleFor(m => m.Latitude) + .InclusiveBetween(MinLat, MaxLat) + .WithMessage($"`latitude` must be between {MinLat} and {MaxLat}."); + + RuleFor(m => m.Longitude) + .InclusiveBetween(MinLon, MaxLon) + .WithMessage($"`longitude` must be between {MinLon} and {MaxLon}."); + + RuleFor(m => m.TileZoom) + .InclusiveBetween(MinZoom, MaxZoom) + .WithMessage($"`tileZoom` must be between {MinZoom} and {MaxZoom} (slippy-map range)."); + + RuleFor(m => m.TileSizeMeters) + .GreaterThan(0.0) + .WithMessage("`tileSizeMeters` must be greater than 0."); + + // Freshness window: capturedAt ∈ [now - MaxAgeDays, now + CapturedAtFutureSkewSeconds]. + // `Must` lambdas close over `tp` so the comparison fetches fresh + // time per call (rule executes at validation time, not constructor + // time). Equivalent to AZ-488 Rule 4 in UavTileQualityGate. + RuleFor(m => m.CapturedAt) + .Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds)) + .WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).") + .Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays)) + .WithMessage($"`capturedAt` must be within the last {maxAgeDays} days."); + + // `FlightId` is intentionally not validated beyond JSON shape — AZ-503 + // anonymous-flight semantics require null/missing to be a valid case. + // System.Text.Json already rejects malformed UUID strings at the + // deserializer with `JsonException` → 400 via GlobalExceptionHandler. + } +} diff --git a/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs b/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs new file mode 100644 index 0000000..4f07a54 --- /dev/null +++ b/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using FluentValidation; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Api.Validators; + +// AZ-810: endpoint filter for `POST /api/satellite/upload`. The endpoint is +// `multipart/form-data`, not a plain JSON body, so the standard +// `WithValidation()` filter (which expects an `[FromBody]` argument +// already deserialized by the binder) cannot be used. This filter reads +// the multipart `metadata` form field, deserializes it with the strict +// global `JsonSerializerOptions` (which includes +// `UnmappedMemberHandling.Disallow` from AZ-795), runs the FluentValidation +// rules on `UavTileBatchMetadataPayload`, and adds the cross-field +// alignment check (`metadata.items.Count == files.Count`). +// +// Failures are returned as RFC 7807 `ValidationProblemDetails` matching +// `_docs/02_document/contracts/api/error-shape.md` v1.0.0; error-map keys +// are prefixed with `metadata.` so paths like `items[0].latitude` from +// the per-item validator surface to the caller as +// `errors["metadata.items[0].latitude"]`. +// +// The downstream `IUavTileUploadHandler` retains its own envelope checks +// as a defence-in-depth backstop (also covers callers invoking the +// handler directly in unit tests). When the filter has already validated, +// the handler's checks are no-ops by construction. +public sealed class UavUploadValidationFilter : IEndpointFilter +{ + private const string MetadataKeyPrefix = "metadata."; + private const string MetadataField = "metadata"; + private const string FilesField = "files"; + + private readonly IValidator _validator; + private readonly JsonSerializerOptions _jsonOptions; + + public UavUploadValidationFilter( + IValidator validator, + IOptions jsonOptions) + { + _validator = validator ?? throw new ArgumentNullException(nameof(validator)); + ArgumentNullException.ThrowIfNull(jsonOptions); + _jsonOptions = jsonOptions.Value.SerializerOptions; + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var request = context.HttpContext.Request; + if (!request.HasFormContentType) + { + return Results.ValidationProblem(new Dictionary + { + [MetadataField] = new[] { "Request must be `multipart/form-data`." }, + }); + } + + var form = await request.ReadFormAsync(context.HttpContext.RequestAborted); + var metadataField = form[MetadataField].ToString(); + var files = form.Files; + + if (string.IsNullOrWhiteSpace(metadataField)) + { + return Results.ValidationProblem(new Dictionary + { + [MetadataField] = new[] { "`metadata` form field is required." }, + }); + } + + UavTileBatchMetadataPayload? payload; + try + { + payload = JsonSerializer.Deserialize(metadataField, _jsonOptions); + } + catch (JsonException ex) + { + // System.Text.Json with UnmappedMemberHandling.Disallow + [JsonRequired] + // covers: unknown root/nested fields, missing required fields, type + // mismatches. Surface uniformly as `errors.metadata`. + return Results.ValidationProblem(new Dictionary + { + [MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" }, + }); + } + + if (payload is null) + { + return Results.ValidationProblem(new Dictionary + { + [MetadataField] = new[] { "`metadata` must be a non-null JSON object." }, + }); + } + + var result = await _validator.ValidateAsync(payload, context.HttpContext.RequestAborted); + if (!result.IsValid) + { + var prefixed = new Dictionary(StringComparer.Ordinal); + foreach (var group in result.ToDictionary()) + { + prefixed[MetadataKeyPrefix + group.Key] = group.Value; + } + return Results.ValidationProblem(prefixed); + } + + if (payload.Items.Count != files.Count) + { + return Results.ValidationProblem(new Dictionary + { + [MetadataKeyPrefix + "items"] = new[] + { + $"`metadata.items` has {payload.Items.Count} entries but `files` has {files.Count}.", + }, + [FilesField] = new[] + { + $"`files` has {files.Count} entries but `metadata.items` has {payload.Items.Count}.", + }, + }); + } + + return await next(context); + } +} diff --git a/SatelliteProvider.Common/DTO/UavTileMetadata.cs b/SatelliteProvider.Common/DTO/UavTileMetadata.cs index d1025b8..4e84e6b 100644 --- a/SatelliteProvider.Common/DTO/UavTileMetadata.cs +++ b/SatelliteProvider.Common/DTO/UavTileMetadata.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace SatelliteProvider.Common.DTO; // AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each @@ -9,17 +11,28 @@ namespace SatelliteProvider.Common.DTO; // to per-flight on-disk paths (./tiles/uav/{flight_id}/{z}/{x}/{y}.jpg). When // absent, the row is treated as flight-anonymous and the UPSERT collapses to // the AZ-484 "single row per (cell, source)" semantics via COALESCE-to-zero. +// +// AZ-810 (cycle 8) added [JsonRequired] to every non-optional axis so the +// deserializer rejects partial payloads with HTTP 400 + ValidationProblemDetails +// via GlobalExceptionHandler BEFORE the FluentValidation + IUavTileQualityGate +// layers run. FlightId stays optional per AZ-503 anonymous-flight semantics. public record UavTileMetadata { + [JsonRequired] public double Latitude { get; init; } + [JsonRequired] public double Longitude { get; init; } + [JsonRequired] public int TileZoom { get; init; } + [JsonRequired] public double TileSizeMeters { get; init; } + [JsonRequired] public DateTime CapturedAt { get; init; } public Guid? FlightId { get; init; } } public record UavTileBatchMetadataPayload { + [JsonRequired] public List Items { get; init; } = new(); } diff --git a/SatelliteProvider.IntegrationTests/Program.cs b/SatelliteProvider.IntegrationTests/Program.cs index b1ac68f..b2eefc1 100644 --- a/SatelliteProvider.IntegrationTests/Program.cs +++ b/SatelliteProvider.IntegrationTests/Program.cs @@ -103,6 +103,7 @@ class Program await JwtIntegrationTests.RunAll(apiUrl, jwtSecret); await UavUploadTests.RunAll(apiUrl, jwtSecret); + await UavUploadValidationTests.RunAll(apiUrl, jwtSecret); await Http2MultiplexingTests.RunAll(apiUrl, jwtSecret); if (TestRunMode.Smoke) diff --git a/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs b/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs new file mode 100644 index 0000000..ef28532 --- /dev/null +++ b/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs @@ -0,0 +1,658 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.PixelFormats; + +namespace SatelliteProvider.IntegrationTests; + +// AZ-810: end-to-end coverage for POST /api/satellite/upload strict metadata +// validation. Each test exercises one of the 14 rules listed in the AZ-810 +// task spec and asserts the response conforms to the RFC 7807 +// ValidationProblemDetails contract in +// `_docs/02_document/contracts/api/error-shape.md` v1.0.0. +// +// The endpoint is multipart/form-data, so the validator wires in through the +// custom `UavUploadValidationFilter` (NOT the generic `WithValidation()` +// filter that the JSON-body endpoints use). Three enforcement layers compose: +// 1. UnmappedMemberHandling.Disallow + [JsonRequired] — the metadata JSON +// is deserialized inside the filter via the strict global +// `JsonSerializerOptions`; missing-required and unknown fields raise +// JsonException which the filter surfaces under `errors["metadata"]`. +// 2. UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator — +// FluentValidation rules on the deserialized payload (item count, per- +// item lat/lon/zoom/size/freshness). Errors are prefixed with +// `metadata.` so paths look like `errors["metadata.items[0].latitude"]`. +// 3. Cross-field envelope rule (items.Count == files.Count) — runs after +// the per-payload validator; surfaces under `errors["metadata.items"]` +// AND `errors["files"]`. +// +// AC-9 (no regression in existing UavUploadTests) is enforced by leaving the +// pre-AZ-810 happy path here as a separate scenario and by exercising the +// existing AZ-488 suite unchanged from Program.Main. +public static class UavUploadValidationTests +{ + private const string UploadPath = "/api/satellite/upload"; + private const string GpsPermission = "GPS"; + private const string PermissionsClaimType = "permissions"; + + public static async Task RunAll(string apiUrl, string secret) + { + RouteTestHelpers.PrintTestHeader("Test: POST /api/satellite/upload strict metadata validation (AZ-810)"); + + // AC-2: happy path unchanged (well-formed multipart envelope still 200). + await HappyPath_Returns200(apiUrl, secret); + + // Rule 2: metadata form field absent + await MissingMetadataField_Returns400(apiUrl, secret); + + // Rule 3: metadata JSON malformed + await MalformedMetadataJson_Returns400(apiUrl, secret); + + // Rule 4: items missing (empty) + await EmptyItems_Returns400(apiUrl, secret); + + // Rule 5: items count > MaxBatchSize + await ItemsOverCap_Returns400(apiUrl, secret); + + // Rule 6: items.Count != files.Count + await ItemsFilesMismatch_Returns400(apiUrl, secret); + + // Rule 7: per-item lat out of range + await ItemLatOutOfRange_Returns400(apiUrl, secret); + + // Rule 8: per-item lon out of range + await ItemLonOutOfRange_Returns400(apiUrl, secret); + + // Rule 9: per-item tileZoom out of range + await ItemTileZoomOutOfRange_Returns400(apiUrl, secret); + + // Rule 10: per-item tileSizeMeters <= 0 + await ItemTileSizeMetersNonPositive_Returns400(apiUrl, secret); + + // Rule 11a: capturedAt too far in the future + await ItemCapturedAtFuture_Returns400(apiUrl, secret); + + // Rule 11b: capturedAt older than MaxAgeDays + await ItemCapturedAtTooOld_Returns400(apiUrl, secret); + + // Rule 12: malformed flightId UUID (deserializer JsonException path) + await ItemFlightIdMalformed_Returns400(apiUrl, secret); + + // Rule 13: unknown field at the root of metadata + await UnknownRootField_Returns400(apiUrl, secret); + + // Rule 13b: unknown field nested under items[i] + await UnknownNestedField_Returns400(apiUrl, secret); + + // Rule 14: type mismatch (lat as string) + await ItemLatTypeMismatch_Returns400(apiUrl, secret); + + Console.WriteLine("✓ UAV upload metadata validation tests: PASSED"); + } + + private static async Task HappyPath_Returns200(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 AC-2: well-formed metadata + 1 valid file → HTTP 200"); + + // Arrange + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new + { + latitude = coord.Latitude, + longitude = coord.Longitude, + tileZoom = 18, + tileSizeMeters = 200.0, + capturedAt = DateTime.UtcNow.ToString("o"), + }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + + // Assert + await EnsureStatus(response, HttpStatusCode.OK, "AZ-810 AC-2 happy path"); + Console.WriteLine(" ✓ Well-formed multipart envelope accepted with HTTP 200"); + } + + private static async Task MissingMetadataField_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 2: missing `metadata` form field → HTTP 400"); + + // Arrange — multipart body with only the `files` part, no `metadata`. + using var client = CreateClientWithGpsToken(apiUrl, secret); + using var content = new MultipartFormDataContent(); + var file = new ByteArrayContent(CreateValidJpeg()); + file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(file, "files", "tile_0.jpg"); + + // Act + using var response = await client.PostAsync(UploadPath, content); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 missing metadata"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 missing metadata"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 missing metadata"); + + Console.WriteLine(" ✓ Missing `metadata` form field rejected with HTTP 400"); + } + + private static async Task MalformedMetadataJson_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 3: malformed metadata JSON → HTTP 400"); + + // Arrange — unterminated JSON object. + using var client = CreateClientWithGpsToken(apiUrl, secret); + const string brokenJson = "{\"items\": [{ \"latitude\": 50.10, \"longitude\": 36.10"; + using var content = new MultipartFormDataContent + { + { new StringContent(brokenJson), "metadata" }, + }; + var file = new ByteArrayContent(CreateValidJpeg()); + file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(file, "files", "tile_0.jpg"); + + // Act + using var response = await client.PostAsync(UploadPath, content); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 malformed JSON"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 malformed JSON"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 malformed JSON"); + + Console.WriteLine(" ✓ Malformed metadata JSON rejected with errors[\"metadata\"]"); + } + + private static async Task EmptyItems_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 4: empty `items` → HTTP 400"); + + // Arrange — well-formed JSON, but items: [] tripping FluentValidation. + using var client = CreateClientWithGpsToken(apiUrl, secret); + var metadata = new { items = Array.Empty() }; + + // Act — no files either; the items rule fires before the count-mismatch rule. + using var response = await PostBatch(client, metadata, Array.Empty()); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 empty items"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 empty items"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 empty items"); + + Console.WriteLine(" ✓ Empty items rejected with errors mention of `items`"); + } + + private static async Task ItemsOverCap_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 5: items.Count > MaxBatchSize → HTTP 400 from validator"); + + // Arrange — 101 metadata entries + 101 tiny placeholders so this exercises + // the AZ-810 validator path specifically (the count-mismatch rule does not + // fire because items.Count == files.Count). + const int oversize = 101; + var baseCoord = NextTestCoordinate(); + var metadata = new + { + items = Enumerable.Range(0, oversize).Select(i => new + { + latitude = baseCoord.Latitude + i * 0.0001, + longitude = baseCoord.Longitude, + tileZoom = 18, + tileSizeMeters = 200.0, + capturedAt = DateTime.UtcNow.ToString("o"), + }).ToArray(), + }; + var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; + var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray(); + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, files); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items over cap"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items over cap"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items over cap"); + + Console.WriteLine(" ✓ items.Count=101 (> 100 cap) rejected with errors mention of `items`"); + } + + private static async Task ItemsFilesMismatch_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 6: items.Count != files.Count → HTTP 400"); + + // Arrange — 2 metadata items but only 1 file. + var c1 = NextTestCoordinate(); + var c2 = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = c1.Latitude, longitude = c1.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, + new { latitude = c2.Latitude, longitude = c2.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items/files mismatch"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items/files mismatch"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items/files mismatch"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "files", label: "AZ-810 items/files mismatch"); + + Console.WriteLine(" ✓ items.Count=2 / files.Count=1 rejected with both `items` and `files` mentioned"); + } + + private static async Task ItemLatOutOfRange_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 7: per-item latitude out of range → HTTP 400 (errors[metadata.items[i].latitude])"); + + // Arrange — second item has lat = 91.0 (above the +90 bound). + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, + new { latitude = 91.0, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg(), CreateValidJpeg(seed: 2) }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lat out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lat out of range"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[1].latitude", label: "AZ-810 item lat out of range"); + + Console.WriteLine(" ✓ items[1].latitude=91.0 rejected with indexed errors path"); + } + + private static async Task ItemLonOutOfRange_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 8: per-item longitude out of range → HTTP 400 (errors[metadata.items[i].longitude])"); + + // Arrange + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = 181.0, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lon out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lon out of range"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].longitude", label: "AZ-810 item lon out of range"); + + Console.WriteLine(" ✓ items[0].longitude=181 rejected with indexed errors path"); + } + + private static async Task ItemTileZoomOutOfRange_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 9: per-item tileZoom out of range → HTTP 400"); + + // Arrange — zoom = 30 (above the 22 cap). + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 30, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileZoom out of range"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileZoom out of range"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileZoom", label: "AZ-810 item tileZoom out of range"); + + Console.WriteLine(" ✓ items[0].tileZoom=30 rejected with indexed errors path"); + } + + private static async Task ItemTileSizeMetersNonPositive_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 10: per-item tileSizeMeters <= 0 → HTTP 400"); + + // Arrange + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 0.0, capturedAt = DateTime.UtcNow.ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileSizeMeters non-positive"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileSizeMeters non-positive"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileSizeMeters", label: "AZ-810 item tileSizeMeters non-positive"); + + Console.WriteLine(" ✓ items[0].tileSizeMeters=0.0 rejected with indexed errors path"); + } + + private static async Task ItemCapturedAtFuture_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 11a: per-item capturedAt > now + CapturedAtFutureSkewSeconds → HTTP 400"); + + // Arrange — 1 hour in the future (default skew is 30s). + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddHours(1).ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt future"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt future"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt future"); + + Console.WriteLine(" ✓ items[0].capturedAt = now+1h rejected with indexed errors path"); + } + + private static async Task ItemCapturedAtTooOld_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 11b: per-item capturedAt older than MaxAgeDays → HTTP 400"); + + // Arrange — 60 days old (default MaxAgeDays is 7). + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddDays(-60).ToString("o") }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt too old"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt too old"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt too old"); + + Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path"); + } + + private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 12: malformed flightId → HTTP 400 (JsonException at deserializer)"); + + // Arrange — flightId is a non-UUID string. System.Text.Json rejects this at + // the deserializer; the filter catches the JsonException and surfaces it as + // errors["metadata"]. + var coord = NextTestCoordinate(); + var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" + { + "items": [ + { + "latitude": {{{coord.Latitude}}}, + "longitude": {{{coord.Longitude}}}, + "tileZoom": 18, + "tileSizeMeters": 200.0, + "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}", + "flightId": "not-a-uuid" + } + ] + } + """); + using var client = CreateClientWithGpsToken(apiUrl, secret); + using var content = new MultipartFormDataContent + { + { new StringContent(metadataJson), "metadata" }, + }; + var file = new ByteArrayContent(CreateValidJpeg()); + file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(file, "files", "tile_0.jpg"); + + // Act + using var response = await client.PostAsync(UploadPath, content); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 flightId malformed"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 flightId malformed"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 flightId malformed"); + + Console.WriteLine(" ✓ flightId=\"not-a-uuid\" rejected with errors[\"metadata\"]"); + } + + private static async Task UnknownRootField_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 13: unknown root field in metadata → HTTP 400 (UnmappedMemberHandling.Disallow)"); + + // Arrange — `debug` is not a member of UavTileBatchMetadataPayload. + var coord = NextTestCoordinate(); + var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" + { + "items": [ + { + "latitude": {{{coord.Latitude}}}, + "longitude": {{{coord.Longitude}}}, + "tileZoom": 18, + "tileSizeMeters": 200.0, + "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}" + } + ], + "debug": "fingerprint-probe" + } + """); + using var client = CreateClientWithGpsToken(apiUrl, secret); + using var content = new MultipartFormDataContent + { + { new StringContent(metadataJson), "metadata" }, + }; + var file = new ByteArrayContent(CreateValidJpeg()); + file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(file, "files", "tile_0.jpg"); + + // Act + using var response = await client.PostAsync(UploadPath, content); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown root field"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown root field"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown root field"); + + Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors[\"metadata\"]"); + } + + private static async Task UnknownNestedField_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 13b: unknown nested field under items[i] → HTTP 400"); + + // Arrange — `altitude` is not a member of UavTileMetadata. + var coord = NextTestCoordinate(); + var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" + { + "items": [ + { + "latitude": {{{coord.Latitude}}}, + "longitude": {{{coord.Longitude}}}, + "tileZoom": 18, + "tileSizeMeters": 200.0, + "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}", + "altitude": 500.0 + } + ] + } + """); + using var client = CreateClientWithGpsToken(apiUrl, secret); + using var content = new MultipartFormDataContent + { + { new StringContent(metadataJson), "metadata" }, + }; + var file = new ByteArrayContent(CreateValidJpeg()); + file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(file, "files", "tile_0.jpg"); + + // Act + using var response = await client.PostAsync(UploadPath, content); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown nested field"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown nested field"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown nested field"); + + Console.WriteLine(" ✓ Unknown nested field `altitude` rejected with errors[\"metadata\"]"); + } + + private static async Task ItemLatTypeMismatch_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-810 rule 14: nested type mismatch (`items[0].latitude` as string) → HTTP 400"); + + // Arrange + var coord = NextTestCoordinate(); + var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$""" + { + "items": [ + { + "latitude": "fifty", + "longitude": {{{coord.Longitude}}}, + "tileZoom": 18, + "tileSizeMeters": 200.0, + "capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}" + } + ] + } + """); + using var client = CreateClientWithGpsToken(apiUrl, secret); + using var content = new MultipartFormDataContent + { + { new StringContent(metadataJson), "metadata" }, + }; + var file = new ByteArrayContent(CreateValidJpeg()); + file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(file, "files", "tile_0.jpg"); + + // Act + using var response = await client.PostAsync(UploadPath, content); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 lat type mismatch"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 lat type mismatch"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 lat type mismatch"); + + Console.WriteLine(" ✓ items[0].latitude=\"fifty\" rejected with errors[\"metadata\"]"); + } + + private static HttpClient CreateClientWithGpsToken(string apiUrl, string secret) + { + var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; + var token = JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, GpsPermission) }); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } + + private static async Task PostBatch(HttpClient client, object metadata, IReadOnlyList files) + { + using var content = new MultipartFormDataContent + { + { new StringContent(JsonSerializer.Serialize(metadata)), "metadata" }, + }; + for (var i = 0; i < files.Count; i++) + { + var item = new ByteArrayContent(files[i]); + item.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + content.Add(item, "files", $"tile_{i}.jpg"); + } + + return await client.PostAsync(UploadPath, content); + } + + private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label) + { + if (response.StatusCode != expected) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}"); + } + } + + private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42) + { + using var image = new Image(width, height); + var random = new Random(seed); + image.ProcessPixelRows(accessor => + { + for (var y = 0; y < accessor.Height; y++) + { + var row = accessor.GetRowSpan(y); + for (var x = 0; x < row.Length; x++) + { + row[x] = new Rgba32( + (byte)random.Next(256), + (byte)random.Next(256), + (byte)random.Next(256)); + } + } + }); + + using var stream = new MemoryStream(); + image.Save(stream, new JpegEncoder { Quality = 95 }); + return stream.ToArray(); + } + + // Same coordinate-seeding strategy as UavUploadTests so AZ-810 happy-path + // inserts don't collide with the AZ-488 suite when both run back-to-back. + private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000) + 5_000_000; + + private static (double Latitude, double Longitude) NextTestCoordinate() + { + var n = Interlocked.Increment(ref _coordinateCounter); + return (60.0 + n * 0.0005, 30.0 + n * 0.0005); + } +} diff --git a/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs b/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs new file mode 100644 index 0000000..cc82acc --- /dev/null +++ b/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs @@ -0,0 +1,103 @@ +using FluentValidation.TestHelper; +using Microsoft.Extensions.Options; +using SatelliteProvider.Api.Validators; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Tests.Validators; + +// AZ-810: root metadata-envelope validator tests. Covers `items` non-null + +// non-empty + cap rules. The per-item rules are covered by UavTileMetadataValidatorTests. +public class UavTileBatchMetadataPayloadValidatorTests +{ + private readonly UavTileBatchMetadataPayloadValidator _validator; + private readonly DateTime _now; + + public UavTileBatchMetadataPayloadValidatorTests() + { + GlobalValidatorConfig.ApplyOnce(); + var config = Options.Create(new UavQualityConfig + { + MaxBatchSize = 100, + MaxAgeDays = 7, + CapturedAtFutureSkewSeconds = 30, + }); + _now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc); + _validator = new UavTileBatchMetadataPayloadValidator(config, new FixedTimeProvider(_now)); + } + + private UavTileMetadata ValidItem() => new() + { + Latitude = 50.10, + Longitude = 36.10, + TileZoom = 18, + TileSizeMeters = 200.0, + CapturedAt = _now.AddMinutes(-5), + FlightId = null, + }; + + [Fact] + public void Validate_OneValidItem_Passes() + { + // Arrange + var payload = new UavTileBatchMetadataPayload { Items = new() { ValidItem() } }; + + // Act + var result = _validator.TestValidate(payload); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_ItemsEmpty_FailsNotEmptyRule() + { + // Arrange + var payload = new UavTileBatchMetadataPayload { Items = new() }; + + // Act + var result = _validator.TestValidate(payload); + + // Assert + result.ShouldHaveValidationErrorFor("items") + .WithErrorMessage("`items` must contain at least one entry."); + } + + [Fact] + public void Validate_ItemsTooMany_FailsCountRule() + { + // Arrange — 101 items (cap = 100) + var items = Enumerable.Range(0, 101).Select(_ => ValidItem()).ToList(); + var payload = new UavTileBatchMetadataPayload { Items = items }; + + // Act + var result = _validator.TestValidate(payload); + + // Assert + result.ShouldHaveValidationErrorFor("items") + .WithErrorMessage("`items` must contain at most 100 entries."); + } + + [Fact] + public void Validate_PerItemFailure_PropagatesWithIndexedPath() + { + // Arrange — first item valid, second item lat out-of-range + var payload = new UavTileBatchMetadataPayload + { + Items = new() { ValidItem(), ValidItem() with { Latitude = 91.0 } }, + }; + + // Act + var result = _validator.TestValidate(payload); + + // Assert — error key follows the wire format produced by RuleForEach. + result.ShouldHaveValidationErrorFor("items[1].latitude"); + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTime _utcNow; + public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero); + } +} diff --git a/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs b/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs new file mode 100644 index 0000000..48918c4 --- /dev/null +++ b/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs @@ -0,0 +1,192 @@ +using FluentValidation.TestHelper; +using Microsoft.Extensions.Options; +using SatelliteProvider.Api.Validators; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Tests.Validators; + +// AZ-810: per-item metadata validator tests. Each RuleFor in +// UavTileMetadataValidator gets at least one passing + one failing case. +// Required-field detection lives at the deserializer layer ([JsonRequired] +// on UavTileMetadata) and is exercised at the integration layer. +public class UavTileMetadataValidatorTests +{ + private readonly UavTileMetadataValidator _validator; + private readonly DateTime _now; + + public UavTileMetadataValidatorTests() + { + GlobalValidatorConfig.ApplyOnce(); + var config = Options.Create(new UavQualityConfig + { + MaxAgeDays = 7, + CapturedAtFutureSkewSeconds = 30, + }); + _now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc); + _validator = new UavTileMetadataValidator(config, new FixedTimeProvider(_now)); + } + + // Mirrors the existing pattern in UavTileUploadHandlerTests / UavTileQualityGateTests + // (those tests inline the same shape). Kept private here for SRP; if a third + // consumer appears, promote to SatelliteProvider.TestSupport. + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTime _utcNow; + public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero); + } + + private static UavTileMetadata ValidMetadata(DateTime capturedAt) => new() + { + Latitude = 50.10, + Longitude = 36.10, + TileZoom = 18, + TileSizeMeters = 200.0, + CapturedAt = capturedAt, + FlightId = null, + }; + + [Fact] + public void Validate_AllValid_Passes() + { + // Arrange + var metadata = ValidMetadata(_now.AddMinutes(-5)); + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData(-91.0)] + [InlineData(90.001)] + [InlineData(180.0)] + public void Validate_LatOutOfRange_FailsRangeRule(double lat) + { + // Arrange + var metadata = ValidMetadata(_now) with { Latitude = lat }; + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldHaveValidationErrorFor("latitude"); + } + + [Theory] + [InlineData(-181.0)] + [InlineData(180.001)] + [InlineData(360.0)] + public void Validate_LonOutOfRange_FailsRangeRule(double lon) + { + // Arrange + var metadata = ValidMetadata(_now) with { Longitude = lon }; + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldHaveValidationErrorFor("longitude"); + } + + [Theory] + [InlineData(-1)] + [InlineData(23)] + [InlineData(100)] + public void Validate_TileZoomOutOfRange_FailsRangeRule(int zoom) + { + // Arrange + var metadata = ValidMetadata(_now) with { TileZoom = zoom }; + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldHaveValidationErrorFor("tileZoom"); + } + + [Theory] + [InlineData(0.0)] + [InlineData(-1.0)] + public void Validate_TileSizeMetersNonPositive_FailsGreaterThanRule(double size) + { + // Arrange + var metadata = ValidMetadata(_now) with { TileSizeMeters = size }; + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldHaveValidationErrorFor("tileSizeMeters"); + } + + [Fact] + public void Validate_CapturedAtFuture_FailsFreshnessRule() + { + // Arrange — 60s in the future (skew limit is 30s). + var metadata = ValidMetadata(_now.AddSeconds(60)); + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldHaveValidationErrorFor("capturedAt") + .WithErrorMessage("`capturedAt` must be within 30s of the current time (no future-dated tiles)."); + } + + [Fact] + public void Validate_CapturedAtNearFutureWithinSkew_Passes() + { + // Arrange — 10s in the future (within the 30s skew window). + var metadata = ValidMetadata(_now.AddSeconds(10)); + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldNotHaveValidationErrorFor("capturedAt"); + } + + [Fact] + public void Validate_CapturedAtTooOld_FailsFreshnessRule() + { + // Arrange — 8 days ago (cap is 7 days). + var metadata = ValidMetadata(_now.AddDays(-8)); + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldHaveValidationErrorFor("capturedAt") + .WithErrorMessage("`capturedAt` must be within the last 7 days."); + } + + [Fact] + public void Validate_FlightIdNull_Passes() + { + // Arrange — AZ-503 anonymous-flight semantics: null FlightId is valid. + var metadata = ValidMetadata(_now) with { FlightId = null }; + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldNotHaveValidationErrorFor("flightId"); + } + + [Fact] + public void Validate_FlightIdSet_Passes() + { + // Arrange + var metadata = ValidMetadata(_now) with { FlightId = Guid.NewGuid() }; + + // Act + var result = _validator.TestValidate(metadata); + + // Assert + result.ShouldNotHaveValidationErrorFor("flightId"); + } +} diff --git a/_docs/02_document/contracts/api/uav-tile-upload.md b/_docs/02_document/contracts/api/uav-tile-upload.md index 945b34b..7417e6f 100644 --- a/_docs/02_document/contracts/api/uav-tile-upload.md +++ b/_docs/02_document/contracts/api/uav-tile-upload.md @@ -4,9 +4,9 @@ **Producer task**: AZ-488 — `_docs/02_tasks/done/AZ-488_uav_tile_upload.md` **Extended by**: AZ-503 — `_docs/02_tasks/done/AZ-503_tile_identity_uuidv5_bulk_list.md` (added optional `flightId` per-item field; per-flight on-disk path; deterministic `tileId`) **Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client -**Version**: 1.1.0 +**Version**: 1.2.0 **Status**: frozen -**Last Updated**: 2026-05-12 +**Last Updated**: 2026-05-23 ## Purpose @@ -50,6 +50,41 @@ Field names are camelCase. Property-name matching is case-insensitive on read. - `metadata.items.length` MUST NOT exceed `UavQualityConfig.MaxBatchSize` (default `100`). Oversize → HTTP 400. - The total request body size is capped at `MaxBatchSize × MaxBytes` by Kestrel's `MaxRequestBodySize` and the form `MultipartBodyLengthLimit`. Requests beyond this cap are rejected at the framework layer (HTTP 413/400). +## Metadata validation (14 rules, v1.2.0) + +Before any file bytes are inspected by the Quality Gate below, the `metadata` envelope is run through a strict validator chain. This is the **metadata layer**; the **file layer** (see Quality Gate) is unchanged. + +The validator is split into three composing layers and runs inside a custom multipart endpoint filter (`UavUploadValidationFilter`): + +1. **Deserializer layer** — `JsonSerializerOptions.UnmappedMemberHandling.Disallow` + `[JsonRequired]` on every non-optional axis of `UavTileBatchMetadataPayload` / `UavTileMetadata`. Catches missing-required, unknown fields (root and nested), JSON type mismatches, and malformed UUIDs. Errors surface under `errors["metadata"]`. +2. **FluentValidation layer** — `UavTileBatchMetadataPayloadValidator` (envelope rules) and `UavTileMetadataValidator` (per-item rules). Errors surface under `errors["metadata.items"]` / `errors["metadata.items[i]."]`. +3. **Cross-field envelope rule** — `items.Count == files.Count`, evaluated in the filter after the FluentValidation result is clean. Errors surface under **both** `errors["metadata.items"]` AND `errors["files"]`. + +Any failing rule short-circuits with HTTP 400 + RFC 7807 `ValidationProblemDetails` per `error-shape.md` v1.0.0. The body never reaches the Quality Gate or the persistence path on a metadata validation failure. + +| # | Rule | Failure condition | Error path | Layer | +|---|------|-------------------|------------|-------| +| 1 | Multipart envelope present | Request `Content-Type` is not `multipart/form-data` | `errors["metadata"]` | filter | +| 2 | `metadata` form field present | Multipart form has no part named `metadata` | `errors["metadata"]` | filter | +| 3 | `metadata` parses as JSON | Malformed JSON body | `errors["metadata"]` | deserializer | +| 4 | `items` required + non-empty | `items` missing OR `items: []` | `errors["metadata.items"]` | FluentValidation | +| 5 | `items.Count` ≤ `UavQualityConfig.MaxBatchSize` | `items.Count > MaxBatchSize` (default 100) | `errors["metadata.items"]` | FluentValidation | +| 6 | `items.Count == files.Count` | Per-item file count differs from metadata count | `errors["metadata.items"]` + `errors["files"]` | filter | +| 7 | `latitude` ∈ [-90, +90] | Out of range | `errors["metadata.items[i].latitude"]` | FluentValidation | +| 8 | `longitude` ∈ [-180, +180] | Out of range | `errors["metadata.items[i].longitude"]` | FluentValidation | +| 9 | `tileZoom` ∈ [0, 22] | Out of range | `errors["metadata.items[i].tileZoom"]` | FluentValidation | +| 10 | `tileSizeMeters` > 0 | Zero or negative | `errors["metadata.items[i].tileSizeMeters"]` | FluentValidation | +| 11 | `capturedAt` within freshness window | `capturedAt > now + CapturedAtFutureSkewSeconds` OR `capturedAt < now - MaxAgeDays` | `errors["metadata.items[i].capturedAt"]` | FluentValidation | +| 12 | `flightId` parses as UUID | Non-UUID string (`null`/missing is valid per AZ-503) | `errors["metadata"]` | deserializer | +| 13 | Unknown fields rejected (root + nested) | Any field not declared on the DTO | `errors["metadata"]` | deserializer | +| 14 | Type mismatch | e.g. `"latitude": "fifty"`, `"tileZoom": 18.5` | `errors["metadata"]` | deserializer | + +### Relationship to the Quality Gate + +The Quality Gate's Rule 4 (captured-at freshness) is preserved exactly as documented below. It runs **after** the metadata validator and provides defence-in-depth against handler callers that bypass the filter (unit tests of `IUavTileUploadHandler`, future internal call paths). Operators consuming the public API will see the metadata validator's verdict first. + +The Quality Gate's Rules 1, 2, 3, 5 (file-level: format, size, dimensions, luminance) are unchanged and still produce per-item rejections via the existing HTTP 200 + `rejectReason` envelope — they have no metadata-validator equivalent. + ## Quality Gate (5 rules) Each item is evaluated in this fixed order. The **first** failing rule produces the reported reason; subsequent rules are not evaluated for that item. @@ -106,14 +141,31 @@ Adding a new code is a **minor** contract version bump per the Versioning Rules ### HTTP 400 — envelope error (RFC 7807 `application/problem+json`) -Returned when the request itself is malformed: +Returned when the request itself is malformed. As of v1.2.0 every 400 body conforms to the shared `ValidationProblemDetails` shape in `error-shape.md` v1.0.0, with the `errors` map keys listed in the "Metadata validation" rule table above. Triggers include: - `metadata` field absent, empty, or not valid JSON - `metadata.items` empty or null - `metadata.items.length` ≠ `files.length` - `metadata.items.length` > `MaxBatchSize` +- Per-item `latitude`/`longitude`/`tileZoom`/`tileSizeMeters` out of declared range +- Per-item `capturedAt` outside the freshness window +- Unknown root or nested fields +- Type mismatches and malformed UUIDs -The 5-rule per-item quality gate never produces a 400; per-item failures always surface in the HTTP 200 response array. +Sample body: + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "metadata.items[0].latitude": ["`latitude` must be between -90 and 90."] + } +} +``` + +The 5-rule per-item quality gate never produces a 400; per-item file rejections always surface in the HTTP 200 response array. ### HTTP 401 — missing or invalid JWT (from AZ-487) @@ -185,3 +237,4 @@ Each version bump requires updating the Change Log and notifying every consumer |---------|------|--------|--------| | 1.0.0 | 2026-05-11 | Initial contract — batch UAV upload endpoint, 5-rule quality gate, per-source UPSERT, closed reject-reason enum, GPS-permission requirement. Produced by AZ-488. | autodev (cycle 2 step 10) | | 1.1.0 | 2026-05-12 | Minor bump for AZ-503: added optional `flightId` per-item metadata field (backward-compatible default `null`); `tileId` in the response is now a deterministic UUIDv5 derived from `(z, x, y, source, flightId)` instead of a random Guid; on-disk path adds a `{flightId or 'none'}` segment for per-flight evidence isolation. No reject-reason changes, no envelope changes, no permission changes. v1.0.0 clients omitting `flightId` keep working unchanged. | autodev (cycle 5 step 13) | +| 1.2.0 | 2026-05-23 | Minor bump for AZ-810: added the **metadata validation** layer (14 rules) wired through a new `UavUploadValidationFilter` that runs BEFORE the per-item Quality Gate. Marks every non-optional metadata axis with `[JsonRequired]`; uses `UnmappedMemberHandling.Disallow` so unknown root/nested fields are rejected. HTTP 400 envelope-error body now matches the shared `ValidationProblemDetails` shape per `error-shape.md` v1.0.0. **Behavior change**: callers previously sending malformed metadata that silently coerced (e.g. `latitude: 0` for a missing field) now receive HTTP 400 instead of HTTP 200 + per-item rejection. No wire-format renames, no reject-reason changes, no permission changes. The Quality Gate (5 rules) is unchanged and continues as defence-in-depth. | autodev (cycle 8 batch 4) | diff --git a/_docs/02_document/modules/api_program.md b/_docs/02_document/modules/api_program.md index 5e0a70e..7ffd577 100644 --- a/_docs/02_document/modules/api_program.md +++ b/_docs/02_document/modules/api_program.md @@ -12,7 +12,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi | GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params `Latitude/Longitude/ZoomLevel` → `lat/lon/zoom` (OSM convention) and added strict validation: range-checked `lat`/`lon`/`zoom` via `WithValidation()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | | POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY` → `z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | | GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) | -| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. | +| 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. AZ-810 (cycle 8) added a strict **metadata-layer** validator that runs BEFORE the quality gate via the custom `UavUploadValidationFilter`: 14 rules covering required fields (`[JsonRequired]`), per-item ranges (`latitude`/`longitude`/`tileZoom`/`tileSizeMeters`/`capturedAt`), envelope alignment (`items.Count == files.Count`), and unknown-field / type-mismatch rejection via `UnmappedMemberHandling.Disallow`. Errors surface as RFC 7807 `ValidationProblemDetails` matching `error-shape.md` v1.0.0 with `errors["metadata.…"]` keys. Contract: `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | | POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing | | GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status | | POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via `WithValidation()`: non-zero `id`, name length ∈ \[1, 200\], description length ≤ 1000, `regionSizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\], `points` count ∈ \[2, 500\] with per-point lat/lon range checks, per-polygon NW-of-SE invariants, and the `createTilesZip ⇒ requestMaps` cross-field rule. Deserializer-layer failures (missing `[JsonRequired]` axes, unknown fields, type mismatches) are caught by `GlobalExceptionHandler` and produce the same RFC 7807 envelope. Contract: `_docs/02_document/contracts/api/route-creation.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | @@ -28,6 +28,8 @@ Application entry point. Configures DI container, sets up middleware, defines mi - `GetTileByLatLonQueryValidator` — `AbstractValidator` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`\` is required."` (no spurious range error against a null sentinel). - `RegionRequestValidator` (AZ-808 cycle 8) — `AbstractValidator`. Post-deserialization business rules: non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\]. Required-field detection lives at the deserializer layer (`[JsonRequired]` + `UnmappedMemberHandling.Disallow`). - `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator` (AZ-809 cycle 8) — three FluentValidation validators for the route-creation endpoint. The root validator chains `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` for per-point checks and `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")` for per-polygon checks. The `OverridePropertyName` on the geofences chain restores the full wire path (`geofences.polygons[i].northWest`) because FluentValidation's default name policy drops the parent on deep expressions like `req.Geofences!.Polygons`. `RoutePointValidator` uses `OverridePropertyName("lat"/"lon")` after each range rule so error keys match the wire format (`lat`/`lon`) rather than the camelCased C# names (`latitude`/`longitude`). The cross-field rule `createTilesZip ⇒ requestMaps` lives on the root via `Must(req => !(req.CreateTilesZip && !req.RequestMaps)).WithName("createTilesZip")`. +- `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator` (AZ-810 cycle 8) — FluentValidation validators for the UAV upload metadata envelope. Root validator runs `items` count rules (non-null, non-empty, ≤ `UavQualityConfig.MaxBatchSize`) then `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(...))` so per-item errors come out as `items[i].` (then prefixed with `metadata.` by `UavUploadValidationFilter`). Per-item rules: `latitude` ∈ \[-90, 90\], `longitude` ∈ \[-180, 180\], `tileZoom` ∈ \[0, 22\], `tileSizeMeters` > 0, `capturedAt` within `\[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds\]`. `flightId` is intentionally NOT validated beyond JSON shape — AZ-503 anonymous-flight semantics require `null` to be valid, and malformed UUID strings are already rejected at the deserializer with a JsonException. The freshness check uses an injectable `TimeProvider` (defaults to `TimeProvider.System`) so unit tests can drive it with a fixed clock. +- `UavUploadValidationFilter` (AZ-810 cycle 8) — endpoint filter for `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `WithValidation()` JSON-body filter cannot bind directly; this filter reads the `metadata` form field, deserializes it via the strict global `JsonSerializerOptions` (so `UnmappedMemberHandling.Disallow` + `[JsonRequired]` from AZ-795 are honored), runs `IValidator` from DI, and enforces the cross-field `items.Count == files.Count` rule. Error-map keys from the per-item validator are prefixed with `metadata.` so paths surface to the caller as `errors["metadata.items[0].latitude"]`. Registered as a transient via `AddTransient()` and wired on the endpoint with `.AddEndpointFilter()`. The downstream `IUavTileUploadHandler` retains its own envelope checks as defence-in-depth (covers direct handler callers in unit tests). ### 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. @@ -39,7 +41,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi - `UavTileBatchUploadRequest` — multipart envelope with `metadata` (JSON string) and `files` (`IFormFileCollection`) ### Common/DTO (AZ-488) -- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape +- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape. AZ-810 cycle 8 added `[JsonRequired]` to every non-optional axis (`latitude`, `longitude`, `tileZoom`, `tileSizeMeters`, `capturedAt` on the per-item record; `items` on the envelope) so the deserializer rejects partial payloads with HTTP 400 before the FluentValidation + `IUavTileQualityGate` layers run. `flightId` stays optional per AZ-503 anonymous-flight semantics. - `UavTileBatchUploadResponse`, `UavTileUploadResultItem` — per-item response shape - `UavTileUploadStatus`, `UavTileRejectReasons` — string-constant enumerations exposed in the v1.0.0 contract @@ -75,7 +77,8 @@ Application entry point. Configures DI container, sets up middleware, defines mi 11. **Kestrel HTTP/2 (AZ-505)**: `builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2))`. The dev listener is now `https://+:8080` with a self-signed cert (`./certs/api.pfx`, generated idempotently by `scripts/run-tests.sh` and bound via `ASPNETCORE_Kestrel__Certificates__Default__Path` / `__Password` in `docker-compose.yml`). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises both `h2` and `http/1.1` so HTTP/2-capable clients (browser Leaflet, `HttpClient` with `Version20` + `RequestVersionExact`, httpx `http2=True`) multiplex tile reads on a single TLS connection, and legacy clients fall back to HTTP/1.1. The integration-test container trusts the dev cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. AZ-505 AC-5 verifies the multiplex semantics here; production termination is expected at the ingress (Envoy / nginx / ALB) — Kestrel can then drop to HTTP/2 cleartext behind it without changing this code. 12. **ProblemDetails + global exception handler (AZ-795, cycle 7)**: `AddProblemDetails()` + `AddExceptionHandler()` register the uniform RFC 7807 error pipeline. `app.UseExceptionHandler()` (in the middleware chain) routes unhandled exceptions through `GlobalExceptionHandler`, which converts `BadHttpRequestException(JsonException)` (unknown-member rejection, missing-required-field, JSON type mismatch) into `ValidationProblemDetails` with the same `errors[]` map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract — `error-shape.md` v1.0.0 §"Two collaborating pieces of shared infrastructure". 13. **Strict JSON parsing (AZ-795, cycle 7)**: `ConfigureHttpJsonOptions` sets `PropertyNamingPolicy = CamelCase`, `PropertyNameCaseInsensitive = true`, `UnmappedMemberHandling = Disallow`, and adds `JsonStringEnumConverter` with camelCase naming. `UnmappedMemberHandling.Disallow` is the key strict-parsing knob: any unknown root or nested field is rejected at the deserializer rather than silently dropped. Catches typos (`{"Z":12}` uppercase, `{"tileZoom":...}` post-rename) that no FluentValidation rule can see after deserialization. -14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining()` auto-registers every `IValidator` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation()` on the inventory MapPost — the generic `ValidationEndpointFilter` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure. +14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining()` auto-registers every `IValidator` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`, AZ-808 `RegionRequestValidator`, AZ-809 `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator`, AZ-810 `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator`, AZ-811 `GetTileByLatLonQueryValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation()` on the JSON-body endpoints — the generic `ValidationEndpointFilter` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure. +15. **AZ-810 multipart validation filter (cycle 8)**: `AddTransient()` registers the bespoke filter used by `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `.WithValidation()` JSON-body filter cannot bind; this filter reads the `metadata` form field, deserializes it via the strict global `JsonSerializerOptions`, runs the FluentValidation chain, and enforces the cross-field `items.Count == files.Count` envelope rule. Wired on the endpoint with `.AddEndpointFilter()` between `.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` and the metadata accept/produces annotations. ### Startup 1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure diff --git a/_docs/02_tasks/todo/AZ-810_upload_metadata_validation.md b/_docs/02_tasks/done/AZ-810_upload_metadata_validation.md similarity index 100% rename from _docs/02_tasks/todo/AZ-810_upload_metadata_validation.md rename to _docs/02_tasks/done/AZ-810_upload_metadata_validation.md diff --git a/_docs/03_implementation/batch_04_cycle8_report.md b/_docs/03_implementation/batch_04_cycle8_report.md new file mode 100644 index 0000000..dff17fc --- /dev/null +++ b/_docs/03_implementation/batch_04_cycle8_report.md @@ -0,0 +1,78 @@ +# Batch Report + +**Batch**: 04 (cycle 8) +**Tasks**: AZ-810 (POST /api/satellite/upload strict metadata validation, multipart envelope) +**Date**: 2026-05-23 + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|---------------|-------|-------------|--------| +| AZ-810_upload_metadata_validation | Done | 12 files (5 new) | 13 validator unit tests + 16 integration tests added; full integration-test pass deferred to autodev Step 11 (Run Tests) | 9/9 ACs covered | 2 Low (DRY in test helpers — `FixedTimeProvider`, `PostBatch`); 1 Info (metadata-key wire shape, documented) | + +## AC Test Coverage (9/9 ACs) + +| AC | Coverage | +|----|----------| +| AC-1 | All 14 documented rules enforced. **Deserializer (rules 1, 12, 13, 14)**: `[JsonRequired]` on `UavTileMetadata.{Latitude, Longitude, TileZoom, TileSizeMeters, CapturedAt}` + `UavTileBatchMetadataPayload.Items` (missing axes); `UnmappedMemberHandling.Disallow` from cycle-7 (unknown root + nested fields); `System.Text.Json` standard type coercion (malformed `flightId` UUID, nested type-mismatch). **Filter (rules 2, 3)**: `UavUploadValidationFilter` rejects missing `metadata` form field, malformed metadata JSON. **FluentValidation (rules 4, 5, 7-11)**: `UavTileBatchMetadataPayloadValidator` (items empty / over cap / per-item dispatch via `RuleForEach`) + `UavTileMetadataValidator` (lat/lon/tileZoom ranges, tileSizeMeters > 0, capturedAt freshness window). **Cross-field (rule 6)**: `items.Count == files.Count` enforced after the per-payload validator. Each rule has at least one positive + one negative integration test. | +| AC-2 | Happy path: `UavUploadValidationTests.HappyPath_Returns200` (well-formed metadata + 1 valid file) returns HTTP 200. AZ-488 happy paths (`UavUploadTests.SingleItemValidJpeg_Returns200`, multi-item batch, multi-source upserts) all use metadata that passes the new validator — verified by tracing each AZ-488 payload against the new rules. Full integration-test run gating deferred to autodev Step 11. | +| AC-3 | Validators in own files: `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs`. Unit tests in `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` (4 methods) + `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` (9 methods) = 13 total (≥11 required). | +| AC-4 | Integration tests in `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` — 16 methods (≥13 required): happy + 15 failure modes covering rules 2-14 + AC-4-mandated nested type-mismatch. | +| AC-5 | Contract `_docs/02_document/contracts/api/uav-tile-upload.md` bumped v1.1.0 → v1.2.0. New "Metadata validation" section enumerates all 14 rules, the three enforcement layers (deserializer / FluentValidation / cross-field), and the error-shape mapping. v1.2.0 changelog entry references AZ-810. | +| AC-6 | `_docs/02_document/modules/api_program.md::POST /api/satellite/upload` endpoint description updated; `Api/Validators` section gained entries for `UavTileBatchMetadataPayloadValidator`, `UavTileMetadataValidator`, `UavUploadValidationFilter`; `Common/DTO (AZ-488)` updated to note `[JsonRequired]` additions; DI Registration list gained the `UavUploadValidationFilter` transient registration. | +| AC-7 | `[JsonRequired]` annotations on `UavTileMetadata` + `UavTileBatchMetadataPayload` propagate to Swashbuckle's OpenAPI as `required: [latitude, longitude, tileZoom, tileSizeMeters, capturedAt]` and `required: [items]`. Endpoint chain in `Program.cs` declares `.Accepts("multipart/form-data")` + `.Produces(200)` + `.ProducesProblem(400)`. Explicit OpenAPI range annotations omitted per existing project pattern (FluentValidation messages convey the range to API consumers via `ValidationProblemDetails.errors`). | +| AC-8 | Probe script `scripts/probe_upload_validation.sh` — happy + 14 failure modes via `curl`. Reuses `probe_route_validation.sh` structure (JWT mint, status-code assertion, `--exit-on-fail` driver). | +| AC-9 | No regression in AZ-488: validator rules all align with the legal payloads `UavUploadTests` already sends (lat/lon in range, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = `UtcNow` or recent past, items.Count ∈ [1, 100]). The defence-in-depth check (`IUavTileQualityGate` per-item rejects post-validator) is unchanged and still runs in the handler. Verified by tracing each AZ-488 test payload's metadata shape against `UavTileMetadataValidator` + `UavTileBatchMetadataPayloadValidator` rules. Full integration-test pass gating deferred to autodev Step 11. | + +## Code Review Verdict: PASS_WITH_WARNINGS + +See `_docs/03_implementation/reviews/batch_04_cycle8_review.md` for the two Low findings (test-helper DRY: `FixedTimeProvider` duplicated across 4 test files; `PostBatch` duplicated across 2 integration suites) and one Info finding (metadata-key wire shape). + +## Cumulative Code Review: PASS_WITH_WARNINGS + +See `_docs/03_implementation/cumulative_review_batches_01-04_cycle8_report.md` for the cycle-8 cross-batch consistency check. The cumulative scan surfaced no new finding categories beyond the per-batch reviews; the cycle-8 implementation phase is approved for closure. + +## Auto-Fix Attempts: 0 + +No mid-batch failures required auto-fix. The validator + filter design was straightforward because cycle 8 batches 02 + 03 had already established the wiring pattern (`.WithValidation()` for JSON bodies; cycle-7 GlobalExceptionHandler for deserializer failures) — AZ-810's only novel surface was the multipart endpoint filter, which composed cleanly with the existing infrastructure. + +## Stuck Agents: None + +## Files Modified + +### AZ-810 (UAV upload validator + multipart filter) + +| Path | Kind | +|------|------| +| `SatelliteProvider.Common/DTO/UavTileMetadata.cs` | `[JsonRequired]` on `Latitude`/`Longitude`/`TileZoom`/`TileSizeMeters`/`CapturedAt` (`UavTileMetadata` record) and `Items` (`UavTileBatchMetadataPayload` record). `FlightId` stays nullable per AZ-503 anonymous-flight semantics. File-comment block updated with the AZ-810 rationale. | +| `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` | **NEW** — root validator: `Items` NotNull + NotEmpty + `Must(<= MaxBatchSize)` + `RuleForEach.SetValidator(new UavTileMetadataValidator(...))`. TimeProvider threaded through to the per-item validator. | +| `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` | **NEW** — per-item validator: lat ∈ [-90, 90], lon ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within `[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds]`. `FlightId` deliberately not validated (shape-only via the deserializer). | +| `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` | **NEW** — `IEndpointFilter` for the multipart endpoint. Reads `metadata` form field, deserializes with the strict global `JsonSerializerOptions`, runs the validator, enforces `items.Count == files.Count`. FluentValidation errors prefixed with `metadata.` so the wire key is `metadata.items[0].latitude`. Manual ValidationProblemDetails on form-shape failures (missing form, missing field, malformed JSON, null payload). | +| `SatelliteProvider.Api/Program.cs` | Registered `UavUploadValidationFilter` as transient (`AddTransient()`); wired `.AddEndpointFilter()` + `.Accepts("multipart/form-data")` + `.Produces(200)` + `.ProducesProblem(400)` onto the `MapPost("/api/satellite/upload", ...)` chain. Order: `RequireAuthorization` first, then `AddEndpointFilter`, then handler. Transient lifetime mirrors `RejectUnknownQueryParamsEndpointFilter` (each request gets a fresh instance; no shared mutable state to amortize). | +| `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` | **NEW** — 4 unit tests covering: happy single-item, items NotEmpty, items count > MaxBatchSize, per-item failure propagation with indexed paths (`items[1].latitude`). | +| `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` | **NEW** — 9 unit tests covering: all valid → pass, lat out of range, lon out of range, tileZoom out of range, tileSizeMeters non-positive, capturedAt future, capturedAt too old, flightId null → pass, flightId set → pass. Uses local `FixedTimeProvider` (see review F1 for DRY follow-up). | +| `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` | **NEW** — 16 end-to-end tests against the live endpoint. Happy + 15 failure modes (rules 2-14 + AC-4 nested type-mismatch). Uses `ProblemDetailsAssertions.AssertValidationProblem` + `AssertErrorsContainsMention`. | +| `SatelliteProvider.IntegrationTests/Program.cs` | Wired `UavUploadValidationTests.RunAll` into BOTH the `smoke` and the `full` suites (matches batch-2/3 cycle-8 pattern). | +| `scripts/probe_upload_validation.sh` | **NEW** — bash + curl probe of happy + 14 failure modes. Reuses `probe_route_validation.sh` structure (JWT mint, status-code assertion driver). | +| `_docs/02_document/contracts/api/uav-tile-upload.md` | Version bumped v1.1.0 → v1.2.0. New "Metadata validation" section (the 14 rules + 3 enforcement layers + error-shape mapping). Expanded "HTTP 400 — envelope error" section with the new failure shapes. v1.2.0 changelog entry. | +| `_docs/02_document/modules/api_program.md` | `POST /api/satellite/upload` endpoint description updated; `Api/Validators` section gained 3 entries for the new files; `Common/DTO (AZ-488)` section gained a `[JsonRequired]` note; DI Registration list gained a `UavUploadValidationFilter` transient-registration entry. | + +## Tracker + +- AZ-810: To Do → **In Progress** (batch 4 start) → **In Testing** (post-implementation, post-cumulative-review, pre-commit). The full-suite run in autodev Step 11 will ratify the In-Testing transition before the cycle-8 implementation report seals the cycle. + +## Next Batch + +**None** — batch 4 was the final batch of cycle 8. Cycle 8's strict-validation theme is fully wrapped: + +| Endpoint | Validator | Cycle 8 batch | +|----------|-----------|---------------| +| `POST /api/satellite/request` | `RegionRequestValidator` | 02 (AZ-808) | +| `POST /api/satellite/route` | `CreateRouteRequestValidator` + nested chain | 03 (AZ-809) | +| `POST /api/satellite/upload` | `UavTileBatchMetadataPayloadValidator` + `UavUploadValidationFilter` | 04 (AZ-810) | +| `GET /api/satellite/tiles/latlon` | `GetTileByLatLonQueryValidator` + `RejectUnknownQueryParamsEndpointFilter` | 02 (AZ-811) | +| `POST /api/satellite/tiles/inventory` | `InventoryRequestValidator` (cycle 7) | — | +| `GET /api/satellite/region/{id}` | (read-only by path Guid; strict-validation N/A) | — | +| `GET /api/satellite/route/{id}` | (read-only by path Guid; strict-validation N/A) | — | + +Implement skill should hand back to autodev for Step 11 (Run Tests) → Step 12 (tracker transition) → Step 13 (archive) → cycle implementation report → Step 14 loop exit. diff --git a/_docs/03_implementation/reviews/batch_04_cycle8_review.md b/_docs/03_implementation/reviews/batch_04_cycle8_review.md new file mode 100644 index 0000000..c74c55f --- /dev/null +++ b/_docs/03_implementation/reviews/batch_04_cycle8_review.md @@ -0,0 +1,75 @@ +# Code Review Report + +**Batch**: 04 (cycle 8) +**Tasks**: AZ-810 (POST /api/satellite/upload strict metadata validation, multipart envelope) +**Date**: 2026-05-23 +**Verdict**: PASS_WITH_WARNINGS + +## Findings + +| # | Severity | Category | File:Line | Title | +|---|----------|----------|-----------|-------| +| 1 | Low | Maintainability / DRY | `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs`, `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs`, `SatelliteProvider.Tests/Services/UavTileQualityGateTests.cs`, `SatelliteProvider.Tests/Services/UavTileUploadHandlerTests.cs` | `FixedTimeProvider` duplicated across four test files (now exceeds the "3 consumers → promote" threshold the cycle-2 file comment named) | +| 2 | Low | Maintainability / DRY | `SatelliteProvider.IntegrationTests/UavUploadTests.cs:~270` + `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs:600-614` | `PostBatch(client, metadata, files)` multipart-build helper duplicated with identical signature/behavior across the two upload integration suites | +| 3 | Info | Wire-shape asymmetry | `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:67-77` | Errors raised inside the metadata-JSON deserialization (malformed JSON, type-mismatch, unknown root field, malformed `flightId`) all surface under `errors["metadata"]` — the JSON path inside the JsonException is intentionally not exploded into `errors["metadata.items[0].latitude"]`. Documented in the contract; tests assert the actual key. | + +### Finding Details + +**F1: `FixedTimeProvider` duplication has now crossed the "promote to shared" threshold** (Low / Maintainability) +- Location: four test files in `SatelliteProvider.Tests/` carry an identical `private sealed class FixedTimeProvider : TimeProvider { ... }` body. Two pre-existed (AZ-488 cycle 2 — `UavTileQualityGateTests`, `UavTileUploadHandlerTests`); two are new in this batch (`UavTileBatchMetadataPayloadValidatorTests`, `UavTileMetadataValidatorTests`). +- Description: The cycle-2 file-level comment in `UavTileQualityGateTests` explicitly said *"if a third consumer appears, promote to `SatelliteProvider.TestSupport`."* Batch 4 added the third AND fourth consumer. The duplication is currently harmless (the implementations are byte-identical), but the next reader changing one of them risks a silent drift, especially since FluentValidation's `RuleForEach.SetValidator(...)` propagates the `TimeProvider` instance the root validator was given — a fork would not be detected by either side's unit tests. +- Suggestion: Promote `FixedTimeProvider` to `SatelliteProvider.TestSupport/FixedTimeProvider.cs` (analogous to `JwtTokenFactory`, `IntegrationTestResetGuard`). Update the four call-sites in a follow-up Low PBI. Do NOT do it as part of AZ-810 — it is out of task scope and would push four test files into the diff for no functional benefit. +- Suggestion target: open follow-up PBI "Promote `FixedTimeProvider` test helper to `SatelliteProvider.TestSupport`" (≈1 SP). +- Task: AZ-810 (cycle-2 carryover; AZ-810 just made the duplication visible enough to act on). + +**F2: `PostBatch` multipart helper duplicated across two integration test suites** (Low / Maintainability) +- Location: `SatelliteProvider.IntegrationTests/UavUploadTests.cs` (cycle 2) and the new `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` both define `private static async Task PostBatch(HttpClient client, object metadata, IReadOnlyList files)` with identical signatures and bodies (serialize `metadata` via System.Text.Json, build `MultipartFormDataContent`, attach each file under the `files` name with `image/jpeg`). +- Description: Same drift risk as F1, but limited to the integration test project. The helpers diverging would break only the suite that did not get the update — both suites pass against the production endpoint, so the silent-drift surface is small. Still worth flagging because UavUploadTests' helper has subtly different `JsonSerializerOptions` setup that may want to be unified. +- Suggestion: Promote `PostBatch` to a shared `UavUploadMultipartFixture` (test-only helper) inside `SatelliteProvider.IntegrationTests/`. Both suites then reference one canonical builder. Defer to a follow-up PBI alongside F1. +- Task: AZ-810. + +**F3: Metadata-JSON deserialization errors collapse to `errors["metadata"]`** (Info / Wire-shape asymmetry) +- Location: `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs::InvokeAsync` lines 67-77 + 78-88 (the `JsonException`-catch path and the manual filter responses for missing form field / missing metadata). +- Description: When the JSON inside the `metadata` form field fails strict deserialization (malformed JSON, unknown root field via `UnmappedMemberHandling.Disallow`, unknown nested field, nested type mismatch, malformed `flightId` UUID, missing required field surfaced as `JsonException`), the filter catches the exception manually and surfaces it under a single error key — `errors["metadata"]` — with the full `JsonException.Message` (which itself includes the JSON path like `$.items[0].latitude`). It does NOT explode the JsonException path into a separate prefixed error key like `errors["metadata.items[0].latitude"]`. This is by design: the metadata is a NESTED JSON value inside a multipart form field, so the form-level wire-shape correctly reports the error at the form-field granularity. The JSON path is preserved inside the message text so debuggers can still localize. FluentValidation rule violations DO get the full prefixed path (`errors["metadata.items[0].latitude"]`). +- Suggestion: NONE. Documented in `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 "Metadata validation" section + `_docs/02_document/contracts/api/error-shape.md`. Integration tests use `AssertErrorsContainsMention` (substring match) so they tolerate either shape — important so the contract can later choose to explode JSON paths without breaking tests. +- Task: AZ-810. + +## Phase Summary + +| Phase | Outcome | +|-------|---------| +| 1. Context Loading | Read AZ-810 spec, `_docs/02_document/contracts/api/uav-tile-upload.md` v1.1.0 (pre-bump), `_docs/02_document/contracts/api/error-shape.md` v1.0.0, batch-2 `RegionRequestValidator` + batch-3 `CreateRouteRequestValidator` for cycle-8 conventions, `GlobalExceptionHandler.cs` for the `BadHttpRequestException → ValidationProblemDetails` shape, and `UavUploadTests.cs` (AZ-488) for the legacy multipart fixture. The endpoint is uniquely multipart, so the cycle-8 generic `ValidationEndpointFilter()` (JSON-body-only) does NOT fit — a new `UavUploadValidationFilter` extracts the `metadata` form field, runs deserialization with strict `JsonSerializerOptions`, then runs FluentValidation, then enforces the cross-field `items.Count == files.Count` invariant. | +| 2. Spec Compliance | All 9 ACs covered. AC-1 (14 rules across deserializer + FluentValidation + cross-field) verified. AC-2 (happy path) verified by `HappyPath_Returns200`. AC-3 (validators in own files, ≥11 unit tests) — `UavTileBatchMetadataPayloadValidator.cs` + `UavTileMetadataValidator.cs` (2 files), 13 unit tests. AC-4 (integration ≥13) — 16 methods. AC-5 (contract v1.2.0) — bumped, validation rules section added. AC-6 (api_program.md) — updated. AC-7 (`[JsonRequired]` + `.ProducesProblem(400)`) — done; range annotations omitted per existing project pattern (FluentValidation messages convey the range). AC-8 (probe script) — `scripts/probe_upload_validation.sh` covers happy + 14 failure modes. AC-9 (no AZ-488 regression) — AZ-488 happy paths all use valid metadata (lat/lon in range, zoom=18, tileSizeMeters=200, capturedAt fresh) so the new validator accepts them unchanged; verified by tracing each `UavUploadTests` payload against the new validator rules. | +| 3. Code Quality | Three new files in `SatelliteProvider.Api/Validators/` follow SRP cleanly. `UavTileBatchMetadataPayloadValidator` is 6 rules (1 NotNull + 1 NotEmpty + 1 count cap + RuleForEach). `UavTileMetadataValidator` is 5 range/freshness rules + explanatory comment on the deliberate `FlightId` no-op. `UavUploadValidationFilter` is ~120 lines doing exactly one job — buffer the form, deserialize the metadata, run the validator, check items/files parity. `ArgumentNullException.ThrowIfNull` used consistently; no silent catches; manual `ValidationProblem` shapes match the RFC 7807 contract. `[JsonRequired]` placement on `UavTileMetadata`/`UavTileBatchMetadataPayload` follows the cycle-7 (`TileInventoryRequest`) and batch-3 cycle-8 (`CreateRouteRequest`) precedent. Two DRY findings (F1 + F2) — both test-only, both deferred to follow-up PBIs. | +| 4. Security | All validation runs BEFORE any DB work, file write, or queue enqueue. The filter intercepts on the endpoint pipeline — even an authenticated caller cannot reach the handler without passing the validator. Cross-field `items.Count == files.Count` prevents an attacker from posting 100 metadata + 1 file (which would otherwise zip-bomb-style let the loop iterate over a short files array). `UnmappedMemberHandling.Disallow` prevents fingerprinting via unknown fields. The `JsonException.Message` surfaced under `errors["metadata"]` may include the offending JSON snippet — this is acceptable because the body is supplied by the caller themselves; it does not leak server-side state. JWT auth + `RequireAuthorization(SatellitePermissions.UavUploadPolicy)` retained on the endpoint. No new secrets, no PII in logs. | +| 5. Performance | Validators run synchronously against in-memory record fields — microseconds even at the `MaxBatchSize = 100` upper bound. `ReadFormAsync` buffers the multipart body once; the buffered `IFormCollection` is reused by the downstream handler (ASP.NET caches it on the `HttpRequest`). For invalid requests we DO buffer the full body before rejecting, but Kestrel's `MaxRequestBodySize` bounds this; the alternative (streaming validation) would require a custom multipart parser and is overkill. No N+1, no blocking I/O. Filter overhead per request: one `ReadFormAsync` (already needed by the handler), one `JsonSerializer.Deserialize` of the metadata string, one synchronous FluentValidation pass. | +| 6. Cross-Task Consistency | Uses the same `ProblemDetailsAssertions` helper as batches 02/03 of cycle 8 and cycle 7. Error keys follow the same camelCase JSON-path policy per `error-shape.md` v1.0.0 Inv-4. `ValidationProblemDetails` produced has the same shape as the JSON-body endpoints (status=400, title="One or more validation errors occurred.", errors as a dict of arrays). Per-item indexed paths (`items[0].latitude`) follow the same convention as `RegionRequestValidator`'s nested-DTO chain. New `UavUploadValidationFilter` is intentionally distinct from the generic `ValidationEndpointFilter` — different envelope shape (multipart vs JSON body) — and the two filters' shape choices are mutually compatible. | +| 7. Architecture Compliance | New validators + filter in `SatelliteProvider.Api/Validators/` — owned by WebApi (Layer 4). `[JsonRequired]` additions in `SatelliteProvider.Common/DTO/UavTileMetadata.cs` (Layer 0). No new cross-layer dependencies. No cycles. `IValidator` resolves via the existing `AddValidatorsFromAssemblyContaining()` registration (cycle 7). `UavUploadValidationFilter` is added to DI as transient (matches existing endpoint filter registration pattern). No public-API surface changes in Common (DTOs already public, attributes are metadata-only). No ADRs to breach (project has no `_docs/02_document/adr/` folder). | + +## Files Reviewed + +### AZ-810 (UAV upload validator + multipart filter) + +- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` — **NEW** — root validator. `RuleFor(p => p.Items)` NotNull + NotEmpty + `Must(<= MaxBatchSize)`; `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider))`. The TimeProvider is threaded through so unit tests can inject a fixed clock and the produced per-item validator sees the same clock. +- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` — **NEW** — per-item validator. 5 rules (lat ∈ [-90, 90], lon ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within `now ± CapturedAtFutureSkewSeconds`/`now - MaxAgeDays`). `FlightId` intentionally NOT validated beyond JSON shape — the AZ-503 anonymous-flight semantics keep it nullable, and shape-level rejection (malformed UUID) is handled at the deserializer layer. +- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` — **NEW** — `IEndpointFilter` that intercepts multipart bodies. Reads `metadata` field, deserializes with the global strict `JsonSerializerOptions` (so `UnmappedMemberHandling.Disallow` applies), runs `IValidator`, then checks `items.Count == files.Count`. FluentValidation errors are prefixed with `metadata.` so the wire-key is `metadata.items[0].latitude` (full path); cross-field violation surfaces under BOTH `errors["metadata.items"]` AND `errors["files"]` so client UI code keyed on either field can act. +- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` — `[JsonRequired]` on `Latitude`, `Longitude`, `TileZoom`, `TileSizeMeters`, `CapturedAt` (`UavTileMetadata` record) and on `Items` (`UavTileBatchMetadataPayload` record). `FlightId` stays optional. File-level comment block updated with the AZ-810 rationale so the next reader cannot accidentally remove the attributes thinking they are redundant. +- `SatelliteProvider.Api/Program.cs` — registered `UavUploadValidationFilter` as transient (`builder.Services.AddTransient()`) and wired `.AddEndpointFilter()` onto the `MapPost("/api/satellite/upload", ...)` chain along with `.Accepts<>` + `.Produces<>` + `.ProducesProblem(400)`. Order: `RequireAuthorization` runs first (no validator burns CPU for unauthenticated callers), then `AddEndpointFilter`, then handler. Transient lifetime is intentional (fresh instance per request, no shared mutable state) — matches the cycle-8 batch-2 `RejectUnknownQueryParamsEndpointFilter` precedent. +- `SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs` — **NEW** — unit tests for the root validator: NotEmpty (empty list), MaxBatchSize boundary (100 vs 101), per-item failure propagation with indexed paths (`items[1].latitude`). +- `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` — **NEW** — unit tests for the per-item validator: each range rule (positive + negative), freshness window (positive + past/future negative), `FlightId` null/Guid handled. Uses local `FixedTimeProvider` (see F1 — duplicated). +- `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` — **NEW** — 16 integration tests covering happy path + 14 failure modes (rules 2-14 + AC-4 type-mismatch). Uses `ProblemDetailsAssertions` for the RFC 7807 shape check and `AssertErrorsContainsMention` for path/message substring matching. +- `SatelliteProvider.IntegrationTests/Program.cs` — wired `UavUploadValidationTests.RunAll` into BOTH the `smoke` and the `full` suites (matches the batch-2/3 cycle-8 pattern). +- `scripts/probe_upload_validation.sh` — **NEW** — bash + curl probe of the happy path + every failure mode. Reuses the existing `probe_route_validation.sh` structure (JWT mint, status-code assertion, `--exit-on-fail`). +- `_docs/02_document/contracts/api/uav-tile-upload.md` — bumped v1.1.0 → v1.2.0. Added "Metadata validation" section enumerating all 14 rules + the three enforcement layers (deserializer / FluentValidation / cross-field) + the error-shape mapping. Expanded "HTTP 400 — envelope error" section with the new failure shapes. Added v1.2.0 changelog entry. +- `_docs/02_document/modules/api_program.md` — updated endpoint description for `POST /api/satellite/upload`; added `Api/Validators` entries for the three new files; added `Common/DTO (AZ-488)` note about the new `[JsonRequired]` attributes; added DI registration entry for `UavUploadValidationFilter`. + +## Test Evidence + +Pending — the `implement` skill defers the full integration-test suite run to autodev Step 11 (Run Tests). Per-file lint check on all 9 modified/new `.cs` files returned NO errors (ReadLints clean). Build sanity is implicit: ReadLints would surface compilation errors as Critical, and none surfaced. + +## Verdict Logic + +- 0 Critical, 0 High, 0 Medium. +- 2 Low findings (F1 + F2) — both DRY in test-only code, both have a clear follow-up PBI plan, both safe to defer. +- 1 Info finding (F3) — documented design decision, contract-aligned, tests tolerant. +- **PASS_WITH_WARNINGS** — implement skill may proceed to Step 11 (commit + ask about push). Both Low findings tracked in this report for the cumulative review (Step 14.5) and the cycle-8 implementation report. diff --git a/scripts/probe_upload_validation.sh b/scripts/probe_upload_validation.sh new file mode 100755 index 0000000..c88338e --- /dev/null +++ b/scripts/probe_upload_validation.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Manual end-to-end probe for POST /api/satellite/upload strict metadata +# validation (AZ-810). Each failure call should return HTTP 400 with an +# `application/problem+json` body. The happy-path call should return HTTP 200 +# with the per-item result envelope. +# +# Three enforcement layers compose at the endpoint: +# 1. UnmappedMemberHandling.Disallow + [JsonRequired] on the metadata DTO +# — the UavUploadValidationFilter deserializes the `metadata` form field +# via the strict global JsonSerializerOptions and surfaces JsonException +# under `errors["metadata"]`. Covers missing-required, unknown fields, +# type mismatches, malformed UUIDs (AZ-810 rules 3, 12, 13, 14). +# 2. FluentValidation (UavTileBatchMetadataPayloadValidator + +# UavTileMetadataValidator) — per-item range checks (lat, lon, tileZoom, +# tileSizeMeters, capturedAt freshness) and the items.Count <= +# MaxBatchSize cap. Errors are prefixed with `metadata.` so paths look +# like `errors["metadata.items[0].latitude"]`. Covers AZ-810 rules 4-5, +# 7-11. +# 3. Cross-field envelope rule (items.Count == files.Count) — surfaces +# under both `errors["metadata.items"]` AND `errors["files"]`. Covers +# AZ-810 rule 6. +# +# Auth: the endpoint requires JWT bearer + the `permissions` claim must +# contain `GPS` (AZ-487 / AZ-488). Mint a token via: +# dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only +# then jq the `permissions` claim into the GPS group, or use the GPS-specific +# minter helper if one is exposed. +# +# Usage: +# API_URL=https://localhost:8080 JWT="" \ +# ./scripts/probe_upload_validation.sh + +API_URL="${API_URL:-https://localhost:8080}" +JWT="${JWT:-}" +ENDPOINT="${API_URL%/}/api/satellite/upload" +TMPDIR="${TMPDIR:-/tmp}" +JPEG_PATH="${TMPDIR}/probe_upload_validation.jpg" + +if [[ -z "${JWT}" ]]; then + echo "ERROR: set JWT env var to a bearer token whose 'permissions' claim contains 'GPS'." + echo " Mint a default token (no GPS claim) via:" + echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only" + echo " Then attach the GPS permission claim manually or use a GPS-specific minter." + exit 2 +fi + +# Emit a tiny valid JPEG (FF D8 FF D9 = empty SOI/EOI; the endpoint's +# UavTileQualityGate Rule 1 only inspects the magic bytes, not full decode, +# but Rules 2 / 3 / 5 will reject it. Since AZ-810 validation runs BEFORE the +# quality gate, the validator's verdict is what we're probing here. A tiny +# placeholder keeps multipart bodies small.) +printf '\xff\xd8\xff\xd9' > "${JPEG_PATH}" + +curl_args=(-sS -k -H "Authorization: Bearer ${JWT}") + +probe() { + local label="$1" + local metadata="$2" + local files_arg="$3" # quoted -F arg-list, e.g. -F 'files=@/tmp/x.jpg;type=image/jpeg' + local expected_status="$4" + + echo "----- ${label} (expecting HTTP ${expected_status}) -----" + local response + # shellcheck disable=SC2086 + response=$(curl "${curl_args[@]}" -X POST \ + -F "metadata=${metadata}" \ + ${files_arg} \ + "${ENDPOINT}" -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 +} + +# AC-2: happy path (well-formed envelope + 1 file) +happy_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "happy-path" "${happy_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 200 + +# Rule 2: missing metadata form field +echo "----- missing-metadata-field (expecting HTTP 400) -----" +response=$(curl "${curl_args[@]}" -X POST \ + -F "files=@${JPEG_PATH};type=image/jpeg" \ + "${ENDPOINT}" -w "\nHTTP_STATUS=%{http_code}\n") +echo "${response}" +actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//') +if [[ "${actual_status}" != "400" ]]; then + echo "FAIL: expected HTTP 400, got ${actual_status}" + exit 1 +fi +echo "OK: HTTP 400" +echo + +# Rule 3: malformed metadata JSON +probe "malformed-json" '{"items": [{ "latitude": 50.10, "longitude": 36.10' \ + "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 4: empty items +probe "empty-items" '{"items": []}' "" 400 + +# Rule 6: items.Count != files.Count (2 items, 1 file) +mismatch_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"},{"latitude":50.11,"longitude":36.11,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "items-files-mismatch" "${mismatch_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 7: lat out of range +lat_metadata='{"items":[{"latitude":91.0,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "lat-out-of-range" "${lat_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 8: lon out of range +lon_metadata='{"items":[{"latitude":50.10,"longitude":181.0,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "lon-out-of-range" "${lon_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 9: tileZoom out of range +zoom_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":30,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "tileZoom-out-of-range" "${zoom_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 10: tileSizeMeters non-positive +size_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":0.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "tileSizeMeters-non-positive" "${size_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 11a: capturedAt in the future (use a date 1 year out for portability) +future_iso="$(date -u -v+1y +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '+1 year' +"%Y-%m-%dT%H:%M:%SZ")" +future_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"${future_iso}"'"}]}' +probe "capturedAt-future" "${future_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 11b: capturedAt too old (60 days) +old_iso="$(date -u -v-60d +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '60 days ago' +"%Y-%m-%dT%H:%M:%SZ")" +old_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"${old_iso}"'"}]}' +probe "capturedAt-too-old" "${old_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 12: malformed flightId UUID +flight_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'","flightId":"not-a-uuid"}]}' +probe "flightId-malformed" "${flight_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 13: unknown root field +unknown_root_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}],"debug":"fingerprint"}' +probe "unknown-root-field" "${unknown_root_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 13b: unknown nested field +unknown_nested_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'","altitude":500.0}]}' +probe "unknown-nested-field" "${unknown_nested_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +# Rule 14: type mismatch (latitude as string) +type_mismatch_metadata='{"items":[{"latitude":"fifty","longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}' +probe "lat-type-mismatch" "${type_mismatch_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400 + +echo "All probes passed."