using System.Text; namespace SatelliteProvider.IntegrationTests; // AZ-809: end-to-end coverage for POST /api/satellite/route strict input // validation. Each test exercises one rule from the AZ-809 validator triplet // (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator) // and asserts the response body conforms to the RFC 7807 // ValidationProblemDetails contract in `_docs/02_document/contracts/api/error-shape.md` // v1.0.0. Required-field detection is enforced at the deserializer layer via // [JsonRequired] + UnmappedMemberHandling.Disallow (AZ-795). // // The route-creation happy path is intentionally `requestMaps=false` here to // keep this suite fast; the existing RouteCreationTests.cs exercises the // `requestMaps=true` flow (with background F5 processing). public static class CreateRouteValidationTests { private const string RoutePath = "/api/satellite/route"; public static async Task RunAll(HttpClient httpClient) { RouteTestHelpers.PrintTestHeader("Test: POST /api/satellite/route strict validation (AZ-809)"); await HappyPath_Returns200(httpClient); // Rule 1: body present await EmptyBody_Returns400(httpClient); // Rule 2: id required, non-zero Guid (probe-confirmed gap) await MissingId_Returns400(httpClient); await ZeroGuidId_Returns400(httpClient); // Rule 3: name required, length [1, 200] await EmptyName_Returns400(httpClient); // Rule 5: regionSizeMeters required, [100, 10000] await RegionSizeOutOfRange_Returns400(httpClient); // Rule 6: zoomLevel required, [0, 22] await ZoomLevelOutOfRange_Returns400(httpClient); // Rule 7: points required, [2, 500] await PointsTooFew_Returns400(httpClient); // Rule 8: per-point lat/lon ranges await PointLatOutOfRange_Returns400(httpClient); await PointLonOutOfRange_Returns400(httpClient); // Rule 9: geofence corners + NW-of-SE invariant await GeofenceNwLatNotGreaterThanSeLat_Returns400(httpClient); // Rule 9b: geofence polygon-count cap (F-AZ809-1 security-audit fix) await GeofencePolygonsTooMany_Returns400(httpClient); // Rule 10/11: requestMaps + createTilesZip required await MissingRequestMaps_Returns400(httpClient); // Rule 12: cross-field createTilesZip implies requestMaps await CreateTilesZipWithoutRequestMaps_Returns400(httpClient); // Rule 13: unknown root field rejected await UnknownRootField_Returns400(httpClient); // Rule 14: type mismatch (per-point lat) await PointLatTypeMismatch_Returns400(httpClient); Console.WriteLine("✓ Create-route validation tests: PASSED"); } private static async Task HappyPath_Returns200(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 AC-2: well-formed body → HTTP 200 (no background processing — requestMaps=false)"); // Arrange var routeId = Guid.NewGuid(); var body = BuildValidBody(routeId, requestMaps: false, createTilesZip: false); // Act var response = await PostJsonAsync(httpClient, body); var status = (int)response.StatusCode; var bodyText = await response.Content.ReadAsStringAsync(); // Assert if (status != 200) { throw new Exception($"AZ-809 happy path: expected HTTP 200, got {status}. Body: {bodyText}"); } Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200"); } private static async Task EmptyBody_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 1: empty body → HTTP 400"); // Act var response = await PostJsonAsync(httpClient, ""); var status = (int)response.StatusCode; // Assert if (status != 400) { throw new Exception($"AZ-809 rule 1: expected HTTP 400, got {status}."); } Console.WriteLine(" ✓ Empty body rejected with HTTP 400"); } private static async Task MissingId_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)"); // Arrange — same exact pattern as the AZ-808 probe finding. var body = """ { "name": "derkachi-flight-1", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 missing id"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 missing id"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-809 missing id"); Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)"); } private static async Task ZeroGuidId_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 2: zero-Guid `id` → HTTP 400"); // Arrange var body = BuildValidBody(Guid.Empty, requestMaps: false, createTilesZip: false); // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 zero-Guid id"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 zero-Guid id", expectedErrorPath: "id"); Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]"); } private static async Task EmptyName_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 3: empty `name` → HTTP 400"); // Arrange var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 empty name"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 empty name", expectedErrorPath: "name"); Console.WriteLine(" ✓ Empty `name` rejected with errors[\"name\"]"); } private static async Task RegionSizeOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 5: `regionSizeMeters` out of range (100..10000) → HTTP 400"); // Arrange — same 1M cap-exceeder as AZ-808. var routeId = Guid.NewGuid(); var body = BuildValidBody(routeId, regionSize: 1_000_000, requestMaps: false, createTilesZip: false); // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 regionSize out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 regionSize out of range", expectedErrorPath: "regionSizeMeters"); Console.WriteLine(" ✓ `regionSizeMeters=1000000` rejected with errors[\"regionSizeMeters\"]"); } private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 6: `zoomLevel` out of range (0..22) → HTTP 400"); // Arrange var routeId = Guid.NewGuid(); var body = BuildValidBody(routeId, zoom: 30, requestMaps: false, createTilesZip: false); // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 zoomLevel out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 zoomLevel out of range", expectedErrorPath: "zoomLevel"); Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]"); } private static async Task PointsTooFew_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 7: `points` count < 2 → HTTP 400"); // Arrange — single point. var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "single-point-route", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 } ], "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 points too few"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 points too few", expectedErrorPath: "points"); Console.WriteLine(" ✓ `points` count=1 rejected with errors[\"points\"]"); } private static async Task PointLatOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 8: per-point `lat` out of range → HTTP 400 (errors[points[i].lat])"); // Arrange var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "out-of-range-lat", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 91.0, "lon": 36.11 } ], "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lat out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lat out of range"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "points[1].lat", label: "AZ-809 point lat out of range"); Console.WriteLine(" ✓ `points[1].lat=91` rejected with errors[\"points[1].lat\"]"); } private static async Task PointLonOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 8: per-point `lon` out of range → HTTP 400 (errors[points[i].lon])"); // Arrange var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "out-of-range-lon", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 181.0 } ], "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lon out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lon out of range"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "points[1].lon", label: "AZ-809 point lon out of range"); Console.WriteLine(" ✓ `points[1].lon=181` rejected with errors[\"points[1].lon\"]"); } private static async Task GeofenceNwLatNotGreaterThanSeLat_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 9: geofence NW.lat <= SE.lat → HTTP 400 (cross-field invariant)"); // Arrange — NW.lat == SE.lat → NW not north-of SE. var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "inverted-geofence", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "geofences": { "polygons": [ { "northWest": { "lat": 50.05, "lon": 36.05 }, "southEast": { "lat": 50.05, "lon": 36.15 } } ] }, "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 NW lat not > SE lat"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 NW lat not > SE lat"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "northWest", label: "AZ-809 NW lat not > SE lat"); Console.WriteLine(" ✓ NW.lat <= SE.lat rejected by cross-field invariant"); } private static async Task GeofencePolygonsTooMany_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 9b (security-audit F-AZ809-1): geofence polygon-count > 50 → HTTP 400"); // Arrange — 51 polygons, each valid bbox. Only the count rule should fire. var routeId = Guid.NewGuid(); var polygonsJson = string.Join( ",\n ", Enumerable .Range(0, 51) .Select(_ => "{ \"northWest\": { \"lat\": 50.15, \"lon\": 36.05 }, \"southEast\": { \"lat\": 50.05, \"lon\": 36.15 } }")); var body = $$""" { "id": "{{routeId}}", "name": "too-many-polygons", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "geofences": { "polygons": [ {{polygonsJson}} ] }, "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 geofence polygons too many"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 geofence polygons too many", expectedErrorPath: "geofences.polygons"); Console.WriteLine(" ✓ 51 polygons rejected with errors[\"geofences.polygons\"] (cap is 50)"); } private static async Task MissingRequestMaps_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 10: missing `requestMaps` → HTTP 400 (no defaulting)"); // Arrange var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "no-requestMaps", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 missing requestMaps"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 missing requestMaps"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "requestMaps", label: "AZ-809 missing requestMaps"); Console.WriteLine(" ✓ Missing `requestMaps` rejected"); } private static async Task CreateTilesZipWithoutRequestMaps_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 12: `createTilesZip=true` AND `requestMaps=false` → HTTP 400 (cross-field invariant)"); // Arrange var routeId = Guid.NewGuid(); var body = BuildValidBody(routeId, requestMaps: false, createTilesZip: true); // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 createTilesZip without requestMaps"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 createTilesZip without requestMaps", expectedErrorPath: "createTilesZip"); Console.WriteLine(" ✓ `createTilesZip=true requestMaps=false` rejected by cross-field invariant"); } private static async Task UnknownRootField_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 13: unknown root field → HTTP 400 (UnmappedMemberHandling.Disallow)"); // Arrange var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "with-unknown-field", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "requestMaps": false, "createTilesZip": false, "debug": "fingerprint-probe" } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 unknown root field"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 unknown root field"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-809 unknown root field"); Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors mention"); } private static async Task PointLatTypeMismatch_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-809 rule 14: nested type mismatch (`points[0].lat` as string) → HTTP 400"); // Arrange var routeId = Guid.NewGuid(); var body = $$""" { "id": "{{routeId}}", "name": "nested-type-mismatch", "regionSizeMeters": 1000, "zoomLevel": 18, "points": [ { "lat": "fifty", "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "requestMaps": false, "createTilesZip": false } """; // Act var response = await PostJsonAsync(httpClient, body); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lat type mismatch"); // Assert — GlobalExceptionHandler converts BadHttpRequestException to // ValidationProblemDetails when the inner JsonException's Path is set. ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lat type mismatch"); Console.WriteLine(" ✓ `points[0].lat:\"fifty\"` rejected with HTTP 400"); } private static string BuildValidBody( Guid routeId, double regionSize = 1000.0, int zoom = 18, bool requestMaps = false, bool createTilesZip = false) { // Lat/lon picked from gps-denied-onboard AZ-777 Phase 2 probe. return $$""" { "id": "{{routeId}}", "name": "az-809-integration-test", "description": "AZ-809 integration test route", "regionSizeMeters": {{regionSize.ToString(System.Globalization.CultureInfo.InvariantCulture)}}, "zoomLevel": {{zoom}}, "points": [ { "lat": 50.10, "lon": 36.10 }, { "lat": 50.11, "lon": 36.11 } ], "requestMaps": {{(requestMaps ? "true" : "false")}}, "createTilesZip": {{(createTilesZip ? "true" : "false")}} } """; } private static Task PostJsonAsync(HttpClient httpClient, string body) { var content = new StringContent(body, Encoding.UTF8, "application/json"); return httpClient.PostAsync(RoutePath, content); } }