mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 07:01:15 +00:00
[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:
@@ -122,6 +122,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
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.AddSwaggerGen(c =>
|
||||
{
|
||||
@@ -231,6 +236,7 @@ app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
|
||||
|
||||
app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
||||
.RequireAuthorization(SatellitePermissions.UavUploadPolicy)
|
||||
.AddEndpointFilter<UavUploadValidationFilter>()
|
||||
.Accepts<UavTileBatchUploadRequest>("multipart/form-data")
|
||||
.Produces<UavTileBatchUploadResponse>(StatusCodes.Status200OK)
|
||||
.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;
|
||||
|
||||
// AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each
|
||||
@@ -9,17 +11,28 @@ namespace SatelliteProvider.Common.DTO;
|
||||
// to per-flight on-disk paths (./tiles/uav/{flight_id}/{z}/{x}/{y}.jpg). When
|
||||
// absent, the row is treated as flight-anonymous and the UPSERT collapses to
|
||||
// the AZ-484 "single row per (cell, source)" semantics via COALESCE-to-zero.
|
||||
//
|
||||
// AZ-810 (cycle 8) added [JsonRequired] to every non-optional axis so the
|
||||
// deserializer rejects partial payloads with HTTP 400 + ValidationProblemDetails
|
||||
// via GlobalExceptionHandler BEFORE the FluentValidation + IUavTileQualityGate
|
||||
// layers run. FlightId stays optional per AZ-503 anonymous-flight semantics.
|
||||
public record UavTileMetadata
|
||||
{
|
||||
[JsonRequired]
|
||||
public double Latitude { get; init; }
|
||||
[JsonRequired]
|
||||
public double Longitude { get; init; }
|
||||
[JsonRequired]
|
||||
public int TileZoom { get; init; }
|
||||
[JsonRequired]
|
||||
public double TileSizeMeters { get; init; }
|
||||
[JsonRequired]
|
||||
public DateTime CapturedAt { get; init; }
|
||||
public Guid? FlightId { get; init; }
|
||||
}
|
||||
|
||||
public record UavTileBatchMetadataPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public List<UavTileMetadata> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ class Program
|
||||
|
||||
await JwtIntegrationTests.RunAll(apiUrl, jwtSecret);
|
||||
await UavUploadTests.RunAll(apiUrl, jwtSecret);
|
||||
await UavUploadValidationTests.RunAll(apiUrl, jwtSecret);
|
||||
await Http2MultiplexingTests.RunAll(apiUrl, jwtSecret);
|
||||
|
||||
if (TestRunMode.Smoke)
|
||||
|
||||
@@ -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`
|
||||
**Extended by**: AZ-503 — `_docs/02_tasks/done/AZ-503_tile_identity_uuidv5_bulk_list.md` (added optional `flightId` per-item field; per-flight on-disk path; deterministic `tileId`)
|
||||
**Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client
|
||||
**Version**: 1.1.0
|
||||
**Version**: 1.2.0
|
||||
**Status**: frozen
|
||||
**Last Updated**: 2026-05-12
|
||||
**Last Updated**: 2026-05-23
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -50,6 +50,41 @@ Field names are camelCase. Property-name matching is case-insensitive on read.
|
||||
- `metadata.items.length` MUST NOT exceed `UavQualityConfig.MaxBatchSize` (default `100`). Oversize → HTTP 400.
|
||||
- The total request body size is capped at `MaxBatchSize × MaxBytes` by Kestrel's `MaxRequestBodySize` and the form `MultipartBodyLengthLimit`. Requests beyond this cap are rejected at the framework layer (HTTP 413/400).
|
||||
|
||||
## Metadata validation (14 rules, v1.2.0)
|
||||
|
||||
Before any file bytes are inspected by the Quality Gate below, the `metadata` envelope is run through a strict validator chain. This is the **metadata layer**; the **file layer** (see Quality Gate) is unchanged.
|
||||
|
||||
The validator is split into three composing layers and runs inside a custom multipart endpoint filter (`UavUploadValidationFilter`):
|
||||
|
||||
1. **Deserializer layer** — `JsonSerializerOptions.UnmappedMemberHandling.Disallow` + `[JsonRequired]` on every non-optional axis of `UavTileBatchMetadataPayload` / `UavTileMetadata`. Catches missing-required, unknown fields (root and nested), JSON type mismatches, and malformed UUIDs. Errors surface under `errors["metadata"]`.
|
||||
2. **FluentValidation layer** — `UavTileBatchMetadataPayloadValidator` (envelope rules) and `UavTileMetadataValidator` (per-item rules). Errors surface under `errors["metadata.items"]` / `errors["metadata.items[i].<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)
|
||||
|
||||
Each item is evaluated in this fixed order. The **first** failing rule produces the reported reason; subsequent rules are not evaluated for that item.
|
||||
@@ -106,14 +141,31 @@ Adding a new code is a **minor** contract version bump per the Versioning Rules
|
||||
|
||||
### HTTP 400 — envelope error (RFC 7807 `application/problem+json`)
|
||||
|
||||
Returned when the request itself is malformed:
|
||||
Returned when the request itself is malformed. As of v1.2.0 every 400 body conforms to the shared `ValidationProblemDetails` shape in `error-shape.md` v1.0.0, with the `errors` map keys listed in the "Metadata validation" rule table above. Triggers include:
|
||||
|
||||
- `metadata` field absent, empty, or not valid JSON
|
||||
- `metadata.items` empty or null
|
||||
- `metadata.items.length` ≠ `files.length`
|
||||
- `metadata.items.length` > `MaxBatchSize`
|
||||
- Per-item `latitude`/`longitude`/`tileZoom`/`tileSizeMeters` out of declared range
|
||||
- Per-item `capturedAt` outside the freshness window
|
||||
- Unknown root or nested fields
|
||||
- Type mismatches and malformed UUIDs
|
||||
|
||||
The 5-rule per-item quality gate never produces a 400; per-item failures always surface in the HTTP 200 response array.
|
||||
Sample body:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"metadata.items[0].latitude": ["`latitude` must be between -90 and 90."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The 5-rule per-item quality gate never produces a 400; per-item file rejections always surface in the HTTP 200 response array.
|
||||
|
||||
### HTTP 401 — missing or invalid JWT (from AZ-487)
|
||||
|
||||
@@ -185,3 +237,4 @@ Each version bump requires updating the Change Log and notifying every consumer
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-11 | Initial contract — batch UAV upload endpoint, 5-rule quality gate, per-source UPSERT, closed reject-reason enum, GPS-permission requirement. Produced by AZ-488. | autodev (cycle 2 step 10) |
|
||||
| 1.1.0 | 2026-05-12 | Minor bump for AZ-503: added optional `flightId` per-item metadata field (backward-compatible default `null`); `tileId` in the response is now a deterministic UUIDv5 derived from `(z, x, y, source, flightId)` instead of a random Guid; on-disk path adds a `{flightId or 'none'}` segment for per-flight evidence isolation. No reject-reason changes, no envelope changes, no permission changes. v1.0.0 clients omitting `flightId` keep working unchanged. | autodev (cycle 5 step 13) |
|
||||
| 1.2.0 | 2026-05-23 | Minor bump for AZ-810: added the **metadata validation** layer (14 rules) wired through a new `UavUploadValidationFilter` that runs BEFORE the per-item Quality Gate. Marks every non-optional metadata axis with `[JsonRequired]`; uses `UnmappedMemberHandling.Disallow` so unknown root/nested fields are rejected. HTTP 400 envelope-error body now matches the shared `ValidationProblemDetails` shape per `error-shape.md` v1.0.0. **Behavior change**: callers previously sending malformed metadata that silently coerced (e.g. `latitude: 0` for a missing field) now receive HTTP 400 instead of HTTP 200 + per-item rejection. No wire-format renames, no reject-reason changes, no permission changes. The Quality Gate (5 rules) is unchanged and continues as defence-in-depth. | autodev (cycle 8 batch 4) |
|
||||
|
||||
@@ -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. |
|
||||
| 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) |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. |
|
||||
| POST | `/api/satellite/upload` | `UploadUavTileBatch` | UAV tile batch upload (AZ-488) — multipart envelope, 5-rule quality gate, per-source UPSERT with `source='uav'`. Requires the `RequiresGpsPermission` policy. AZ-810 (cycle 8) added a strict **metadata-layer** validator that runs BEFORE the quality gate via the custom `UavUploadValidationFilter`: 14 rules covering required fields (`[JsonRequired]`), per-item ranges (`latitude`/`longitude`/`tileZoom`/`tileSizeMeters`/`capturedAt`), envelope alignment (`items.Count == files.Count`), and unknown-field / type-mismatch rejection via `UnmappedMemberHandling.Disallow`. Errors surface as RFC 7807 `ValidationProblemDetails` matching `error-shape.md` v1.0.0 with `errors["metadata.…"]` keys. Contract: `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 + `_docs/02_document/contracts/api/error-shape.md` v1.0.0. |
|
||||
| POST | `/api/satellite/request` | `RequestRegion` | Queue region for async tile processing |
|
||||
| GET | `/api/satellite/region/{id}` | `GetRegionStatus` | Get region processing status |
|
||||
| POST | `/api/satellite/route` | `CreateRoute` | Create route with intermediate points. AZ-809 (cycle 8) added strict pre-handler validation via `WithValidation<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).
|
||||
- `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")`.
|
||||
- `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)
|
||||
- `GetTileByLatLonQuery` — `record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the `GetTileByLatLon` handler. **Nullable on purpose**: minimal-API binding throws `BadHttpRequestException` for missing non-nullable query params BEFORE endpoint filters run; that short-circuit produces a plain `ProblemDetails` via `GlobalExceptionHandler` with no `errors{}` envelope. Nullable types let binding always succeed so the envelope filter + validator handle the failure surface uniformly per `error-shape.md` v1.0.0. The handler dereferences `.Value` only after the validator filter passes.
|
||||
@@ -39,7 +41,7 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
- `UavTileBatchUploadRequest` — multipart envelope with `metadata` (JSON string) and `files` (`IFormFileCollection`)
|
||||
|
||||
### Common/DTO (AZ-488)
|
||||
- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape
|
||||
- `UavTileMetadata`, `UavTileBatchMetadataPayload` — per-item metadata + envelope shape. AZ-810 cycle 8 added `[JsonRequired]` to every non-optional axis (`latitude`, `longitude`, `tileZoom`, `tileSizeMeters`, `capturedAt` on the per-item record; `items` on the envelope) so the deserializer rejects partial payloads with HTTP 400 before the FluentValidation + `IUavTileQualityGate` layers run. `flightId` stays optional per AZ-503 anonymous-flight semantics.
|
||||
- `UavTileBatchUploadResponse`, `UavTileUploadResultItem` — per-item response shape
|
||||
- `UavTileUploadStatus`, `UavTileRejectReasons` — string-constant enumerations exposed in the v1.0.0 contract
|
||||
|
||||
@@ -75,7 +77,8 @@ Application entry point. Configures DI container, sets up middleware, defines mi
|
||||
11. **Kestrel HTTP/2 (AZ-505)**: `builder.WebHost.ConfigureKestrel(opts => opts.ConfigureEndpointDefaults(lo => lo.Protocols = HttpProtocols.Http1AndHttp2))`. The dev listener is now `https://+:8080` with a self-signed cert (`./certs/api.pfx`, generated idempotently by `scripts/run-tests.sh` and bound via `ASPNETCORE_Kestrel__Certificates__Default__Path` / `__Password` in `docker-compose.yml`). Kestrel needs TLS for HTTP/2 protocol negotiation; ALPN advertises both `h2` and `http/1.1` so HTTP/2-capable clients (browser Leaflet, `HttpClient` with `Version20` + `RequestVersionExact`, httpx `http2=True`) multiplex tile reads on a single TLS connection, and legacy clients fall back to HTTP/1.1. The integration-test container trusts the dev cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. AZ-505 AC-5 verifies the multiplex semantics here; production termination is expected at the ingress (Envoy / nginx / ALB) — Kestrel can then drop to HTTP/2 cleartext behind it without changing this code.
|
||||
12. **ProblemDetails + global exception handler (AZ-795, cycle 7)**: `AddProblemDetails()` + `AddExceptionHandler<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.
|
||||
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
|
||||
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.
|
||||
Executable
+153
@@ -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."
|
||||
Reference in New Issue
Block a user