[AZ-810] Strict validation for POST /api/satellite/upload metadata

Adds the per-endpoint child of AZ-795 ("Strict Input Validation Epic")
for the UAV upload multipart endpoint. Three new validators land under
SatelliteProvider.Api/Validators/:

- UavTileBatchMetadataPayloadValidator: items NotNull + NotEmpty +
  count <= MaxBatchSize + RuleForEach dispatching to the per-item
  validator.
- UavTileMetadataValidator: lat / lon / tileZoom range, tileSizeMeters
  > 0, capturedAt within [now - MaxAgeDays, now + future-skew]; uses an
  injectable TimeProvider so unit tests can drive a fixed clock.
- UavUploadValidationFilter: IEndpointFilter that reads the multipart
  `metadata` form field, deserializes it with the strict global
  JsonSerializerOptions (so UnmappedMemberHandling.Disallow +
  [JsonRequired] from AZ-795 are honored), runs the FluentValidation
  chain, and enforces the cross-field `items.Count == files.Count`
  envelope rule. FluentValidation errors are prefixed with `metadata.`
  so wire keys look like `errors["metadata.items[0].latitude"]`.

[JsonRequired] is added to every non-optional axis on
UavTileMetadata and UavTileBatchMetadataPayload; FlightId stays
nullable per AZ-503 anonymous-flight semantics.

Coverage: 13 unit tests + 16 integration tests + 1 curl probe script
exercise the happy path and every failure mode. All 9 ACs covered;
no regression in AZ-488 UavUploadTests payloads (traced against the
new rules).

Documentation: uav-tile-upload.md bumped v1.1.0 -> v1.2.0 with the
new validation rules section + 400-shape examples + changelog entry.
api_program.md updated to describe the three new validators + filter
+ the AddTransient<UavUploadValidationFilter>() DI registration.

Reports: batch_04_cycle8_report.md + reviews/batch_04_cycle8_review.md
record the PASS_WITH_WARNINGS verdict (2 Low DRY-in-tests findings:
FixedTimeProvider duplication crossed the cycle-2 "promote to shared"
threshold; PostBatch helper duplicated between two integration
suites). Both deferred to follow-up PBIs.

Task spec archived: _docs/02_tasks/todo/AZ-810... -> done/.
Jira: AZ-810 transitioned In Progress -> In Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-23 13:32:19 +03:00
parent 5e056b2334
commit 490902c80a
15 changed files with 1564 additions and 7 deletions
+6
View File
@@ -122,6 +122,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
GlobalValidatorConfig.ApplyOnce(); GlobalValidatorConfig.ApplyOnce();
// AZ-810: explicit registration so `.AddEndpointFilter<UavUploadValidationFilter>()`
// 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<UavUploadValidationFilter>();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => builder.Services.AddSwaggerGen(c =>
{ {
@@ -231,6 +236,7 @@ app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
app.MapPost("/api/satellite/upload", UploadUavTileBatch) app.MapPost("/api/satellite/upload", UploadUavTileBatch)
.RequireAuthorization(SatellitePermissions.UavUploadPolicy) .RequireAuthorization(SatellitePermissions.UavUploadPolicy)
.AddEndpointFilter<UavUploadValidationFilter>()
.Accepts<UavTileBatchUploadRequest>("multipart/form-data") .Accepts<UavTileBatchUploadRequest>("multipart/form-data")
.Produces<UavTileBatchUploadResponse>(StatusCodes.Status200OK) .Produces<UavTileBatchUploadResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest)
@@ -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<T>()` 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<UavTileBatchMetadataPayload>
{
public UavTileBatchMetadataPayloadValidator(
IOptions<UavQualityConfig> 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));
}
}
@@ -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<UavTileMetadata>
{
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<UavQualityConfig> 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.
}
}
@@ -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<T>()` 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<UavTileBatchMetadataPayload> _validator;
private readonly JsonSerializerOptions _jsonOptions;
public UavUploadValidationFilter(
IValidator<UavTileBatchMetadataPayload> validator,
IOptions<JsonOptions> jsonOptions)
{
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
ArgumentNullException.ThrowIfNull(jsonOptions);
_jsonOptions = jsonOptions.Value.SerializerOptions;
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var request = context.HttpContext.Request;
if (!request.HasFormContentType)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[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<string, string[]>
{
[MetadataField] = new[] { "`metadata` form field is required." },
});
}
UavTileBatchMetadataPayload? payload;
try
{
payload = JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(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<string, string[]>
{
[MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" },
});
}
if (payload is null)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[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<string, string[]>(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<string, string[]>
{
[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);
}
}
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SatelliteProvider.Common.DTO; namespace SatelliteProvider.Common.DTO;
// AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each // 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 // 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 // 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. // 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 public record UavTileMetadata
{ {
[JsonRequired]
public double Latitude { get; init; } public double Latitude { get; init; }
[JsonRequired]
public double Longitude { get; init; } public double Longitude { get; init; }
[JsonRequired]
public int TileZoom { get; init; } public int TileZoom { get; init; }
[JsonRequired]
public double TileSizeMeters { get; init; } public double TileSizeMeters { get; init; }
[JsonRequired]
public DateTime CapturedAt { get; init; } public DateTime CapturedAt { get; init; }
public Guid? FlightId { get; init; } public Guid? FlightId { get; init; }
} }
public record UavTileBatchMetadataPayload public record UavTileBatchMetadataPayload
{ {
[JsonRequired]
public List<UavTileMetadata> Items { get; init; } = new(); public List<UavTileMetadata> Items { get; init; } = new();
} }
@@ -103,6 +103,7 @@ class Program
await JwtIntegrationTests.RunAll(apiUrl, jwtSecret); await JwtIntegrationTests.RunAll(apiUrl, jwtSecret);
await UavUploadTests.RunAll(apiUrl, jwtSecret); await UavUploadTests.RunAll(apiUrl, jwtSecret);
await UavUploadValidationTests.RunAll(apiUrl, jwtSecret);
await Http2MultiplexingTests.RunAll(apiUrl, jwtSecret); await Http2MultiplexingTests.RunAll(apiUrl, jwtSecret);
if (TestRunMode.Smoke) if (TestRunMode.Smoke)
@@ -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<T>()`
// 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<object>() };
// Act — no files either; the items rule fires before the count-mismatch rule.
using var response = await PostBatch(client, metadata, Array.Empty<byte[]>());
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<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> 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<Rgba32>(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);
}
}
@@ -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);
}
}
@@ -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");
}
}
@@ -4,9 +4,9 @@
**Producer task**: AZ-488 — `_docs/02_tasks/done/AZ-488_uav_tile_upload.md` **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`) **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 **Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client
**Version**: 1.1.0 **Version**: 1.2.0
**Status**: frozen **Status**: frozen
**Last Updated**: 2026-05-12 **Last Updated**: 2026-05-23
## Purpose ## 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. - `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). - 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].<field>"]`.
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) ## 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. 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`) ### 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` field absent, empty, or not valid JSON
- `metadata.items` empty or null - `metadata.items` empty or null
- `metadata.items.length``files.length` - `metadata.items.length``files.length`
- `metadata.items.length` > `MaxBatchSize` - `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) ### 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.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.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) |
+6 -3
View File
@@ -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<GetTileByLatLonQuery>()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | | GET | `/api/satellite/tiles/latlon` | `GetTileByLatLon` | Download single tile by lat/lon/zoom. AZ-811 (cycle 8) renamed the query params `Latitude/Longitude/ZoomLevel``lat/lon/zoom` (OSM convention) and added strict validation: range-checked `lat`/`lon`/`zoom` via `WithValidation<GetTileByLatLonQuery>()`, plus a `RejectUnknownQueryParamsEndpointFilter` that rejects any extra query keys (catches typos like `?latitude=` that pre-AZ-811 silently bound to 0). Contract: `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
| POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY``z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. | | POST | `/api/satellite/tiles/inventory` | `GetTilesInventory` | Bulk tile-existence/metadata lookup (AZ-505) — body is XOR of `tiles[{z,x,y}]` (Form A) and `locationHashes[uuid]` (Form B), each capped at 5000 entries. Response is one entry per request entry, in input order. AZ-794 (cycle 7) renamed the coord triple from `tileZoom/tileX/tileY``z/x/y` (OSM convention); AZ-796 (cycle 7) added strict input validation via `WithValidation<TileInventoryRequest>()` so malformed payloads return RFC 7807 `ValidationProblemDetails` instead of silently coercing to zero. Contracts: `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
| GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) | | GET | `/api/satellite/tiles/mgrs` | `GetSatelliteTilesByMgrs` | MGRS stub (returns empty) |
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. | | POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. 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 | | POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing |
| GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status | | 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<CreateRouteRequest>()`: 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. | | POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via `WithValidation<CreateRouteRequest>()`: 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<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` is required."` (no spurious range error against a null sentinel). - `GetTileByLatLonQueryValidator``AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules. Each rule chains `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so a missing param surfaces ONLY as `"\`<paramName>\` is required."` (no spurious range error against a null sentinel).
- `RegionRequestValidator` (AZ-808 cycle 8) — `AbstractValidator<RequestRegionRequest>`. 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`). - `RegionRequestValidator` (AZ-808 cycle 8) — `AbstractValidator<RequestRegionRequest>`. 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")`. - `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].<field>` (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<T>()` 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<UavTileBatchMetadataPayload>` 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<UavUploadValidationFilter>()` and wired on the endpoint with `.AddEndpointFilter<UavUploadValidationFilter>()`. 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) ### 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. - `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`) - `UavTileBatchUploadRequest` — multipart envelope with `metadata` (JSON string) and `files` (`IFormFileCollection`)
### Common/DTO (AZ-488) ### 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 - `UavTileBatchUploadResponse`, `UavTileUploadResultItem` — per-item response shape
- `UavTileUploadStatus`, `UavTileRejectReasons` — string-constant enumerations exposed in the v1.0.0 contract - `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. 11. **Kestrel HTTP/2 (AZ-505)**: `builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2))`. The dev listener is now `https://+:8080` with a self-signed cert (`./certs/api.pfx`, generated idempotently by `scripts/run-tests.sh` and bound via `ASPNETCORE_Kestrel__Certificates__Default__Path` / `__Password` in `docker-compose.yml`). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises both `h2` and `http/1.1` so HTTP/2-capable clients (browser Leaflet, `HttpClient` with `Version20` + `RequestVersionExact`, httpx `http2=True`) multiplex tile reads on a single TLS connection, and legacy clients fall back to HTTP/1.1. The integration-test container trusts the dev cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. AZ-505 AC-5 verifies the multiplex semantics here; production termination is expected at the ingress (Envoy / nginx / ALB) — Kestrel can then drop to HTTP/2 cleartext behind it without changing this code.
12. **ProblemDetails + global exception handler (AZ-795, cycle 7)**: `AddProblemDetails()` + `AddExceptionHandler<GlobalExceptionHandler>()` register the uniform RFC 7807 error pipeline. `app.UseExceptionHandler()` (in the middleware chain) routes unhandled exceptions through `GlobalExceptionHandler`, which converts `BadHttpRequestException(JsonException)` (unknown-member rejection, missing-required-field, JSON type mismatch) into `ValidationProblemDetails` with the same `errors[]` map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract — `error-shape.md` v1.0.0 §"Two collaborating pieces of shared infrastructure". 12. **ProblemDetails + global exception handler (AZ-795, cycle 7)**: `AddProblemDetails()` + `AddExceptionHandler<GlobalExceptionHandler>()` register the uniform RFC 7807 error pipeline. `app.UseExceptionHandler()` (in the middleware chain) routes unhandled exceptions through `GlobalExceptionHandler`, which converts `BadHttpRequestException(JsonException)` (unknown-member rejection, missing-required-field, JSON type mismatch) into `ValidationProblemDetails` with the same `errors[]` map shape that FluentValidation produces. This is the deserializer-layer half of the strict-validation contract — `error-shape.md` v1.0.0 §"Two collaborating pieces of shared infrastructure".
13. **Strict JSON parsing (AZ-795, cycle 7)**: `ConfigureHttpJsonOptions` sets `PropertyNamingPolicy = CamelCase`, `PropertyNameCaseInsensitive = true`, `UnmappedMemberHandling = Disallow`, and adds `JsonStringEnumConverter` with camelCase naming. `UnmappedMemberHandling.Disallow` is the key strict-parsing knob: any unknown root or nested field is rejected at the deserializer rather than silently dropped. Catches typos (`{"Z":12}` uppercase, `{"tileZoom":...}` post-rename) that no FluentValidation rule can see after deserialization. 13. **Strict JSON parsing (AZ-795, cycle 7)**: `ConfigureHttpJsonOptions` sets `PropertyNamingPolicy = CamelCase`, `PropertyNameCaseInsensitive = true`, `UnmappedMemberHandling = Disallow`, and adds `JsonStringEnumConverter` with camelCase naming. `UnmappedMemberHandling.Disallow` is the key strict-parsing knob: any unknown root or nested field is rejected at the deserializer rather than silently dropped. Catches typos (`{"Z":12}` uppercase, `{"tileZoom":...}` post-rename) that no FluentValidation rule can see after deserialization.
14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining<Program>()` auto-registers every `IValidator<T>` in the API assembly (currently `InventoryRequestValidator` + `TileCoordValidator`). `GlobalValidatorConfig.ApplyOnce()` runs the idempotent process-wide config — sets `ValidatorOptions.Global.PropertyNameResolver` so `errors` map keys are camelCase (matches the request body's casing per `error-shape.md` Inv-4). Per-endpoint opt-in via `.WithValidation<TileInventoryRequest>()` on the inventory MapPost — the generic `ValidationEndpointFilter<T>` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure. 14. **FluentValidation registration (AZ-795 + AZ-796, cycle 7)**: `AddValidatorsFromAssemblyContaining<Program>()` auto-registers every `IValidator<T>` 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<T>()` on the JSON-body endpoints — the generic `ValidationEndpointFilter<T>` resolves the validator from DI at request time and returns `Results.ValidationProblem` on failure.
15. **AZ-810 multipart validation filter (cycle 8)**: `AddTransient<UavUploadValidationFilter>()` registers the bespoke filter used by `POST /api/satellite/upload`. The endpoint is `multipart/form-data` so the generic `.WithValidation<T>()` 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<UavUploadValidationFilter>()` between `.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` and the metadata accept/produces annotations.
### Startup ### Startup
1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure 1. Database migration via `DatabaseMigrator.RunMigrations()` — throws on failure
@@ -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<UavTileBatchUploadRequest>("multipart/form-data")` + `.Produces<UavTileBatchUploadResponse>(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<T>()` 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<UavUploadValidationFilter>()`); wired `.AddEndpointFilter<UavUploadValidationFilter>()` + `.Accepts<UavTileBatchUploadRequest>("multipart/form-data")` + `.Produces<UavTileBatchUploadResponse>(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.
@@ -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<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> 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<T>()` (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<T>` — 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<UavTileBatchMetadataPayload>` resolves via the existing `AddValidatorsFromAssemblyContaining<Program>()` 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<UavTileBatchMetadataPayload>`, 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<UavUploadValidationFilter>()`) and wired `.AddEndpointFilter<UavUploadValidationFilter>()` 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.
+153
View File
@@ -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="<bearer-token-with-GPS-permission>" \
# ./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."