mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 06:51:13 +00:00
b763da3f24
The AZ-810 metadata validator rejects lat outside [-90, 90] and lon
outside [-180, 180]. Two NextTestCoordinate() helpers seeded their
counter from `(Ticks/TicksPerSecond) % 1_000_000` and returned
`60 + n*0.0005`, producing lat well above 90° for almost any seed
(e.g. n=200000 -> lat=160). Pre-AZ-810 there was no validator and no
DB constraint, so the out-of-range values were silently accepted; the
new validator (correctly) rejected them at HTTP 400.
Clamp both helpers to non-overlapping OSM-valid ranges:
- UavUploadTests.cs: lat in [50, 70), lon in [10, 40)
- UavUploadValidationTests.cs: lat in [-70, -50), lon in [-40, -10)
Non-overlap (not the prior +5_000_000 counter offset) is what now
guarantees AZ-488 and AZ-810 suites don't collide on the per-source
UNIQUE index when both run against the same DB.
No production code change; AZ-810 validator behaviour is unchanged.
Also:
- Correct AC-9 in batch_04_cycle8_report.md: the original claim
("verified by tracing source") was a false-PASS; the autodev
Step 11 test run surfaced the gap. Now confirmed by full-suite
green (scripts/run-tests.sh --full).
- Add ring-buffer lesson on AC-verification standards for input-
validation changes: tracing fixture variables to their generators
is insufficient; only a green integration-test run is sound
evidence for a "no-regression" AC.
Co-authored-by: Cursor <cursoragent@cursor.com>
666 lines
30 KiB
C#
666 lines
30 KiB
C#
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();
|
|
}
|
|
|
|
// Use a southern-hemisphere range that does NOT overlap UavUploadTests'
|
|
// northern range ([50,70) x [10,40)). Non-overlap (not counter offset) is
|
|
// what guarantees the AZ-488 and AZ-810 suites don't collide on the
|
|
// per-source UNIQUE index when both run against the same DB. Wrap on
|
|
// 40_000-cell axes so the result always stays strictly inside the
|
|
// OSM-valid ranges enforced by UavTileMetadataValidator:
|
|
// lat in [-70.0, -50.0), lon in [-40.0, -10.0).
|
|
private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000);
|
|
|
|
private static (double Latitude, double Longitude) NextTestCoordinate()
|
|
{
|
|
var n = Interlocked.Increment(ref _coordinateCounter);
|
|
var lat = -50.0 - ((uint)n % 40_000u) * 0.0005;
|
|
var lon = -10.0 - ((uint)n % 60_000u) * 0.0005;
|
|
return (lat, lon);
|
|
}
|
|
}
|