mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 10:31:14 +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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user