namespace SatelliteProvider.IntegrationTests; // AZ-811: end-to-end coverage for GET /api/satellite/tiles/latlon strict input // validation. Two enforcement layers: // 1. RejectUnknownQueryParamsEndpointFilter — rejects any query key outside // {lat, lon, zoom}, catching typos like `?latitude=` that pre-AZ-811 // silently bound to 0. // 2. WithValidation — range-checks lat, lon, zoom. // Both surface RFC 7807 ValidationProblemDetails per error-shape.md v1.0.0. public static class GetTileByLatLonValidationTests { private const string LatLonPath = "/api/satellite/tiles/latlon"; public static async Task RunAll(HttpClient httpClient) { RouteTestHelpers.PrintTestHeader("Test: GET /api/satellite/tiles/latlon strict validation (AZ-811)"); await HappyPath_Returns200(httpClient); // Validator rules (range) await LatOutOfRange_Returns400(httpClient); await LonOutOfRange_Returns400(httpClient); await ZoomOutOfRange_Returns400(httpClient); // Validator rules (missing required) await MissingLat_Returns400(httpClient); // Envelope rule: unknown query params await UnknownQueryParam_LegacyLatitude_Returns400(httpClient); await UnknownQueryParam_Hostile_Returns400(httpClient); // Type mismatch (delegates to GlobalExceptionHandler via model-binding) await LatTypeMismatch_Returns400(httpClient); Console.WriteLine("✓ GET lat/lon validation tests: PASSED"); } private static async Task HappyPath_Returns200(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 AC-2: well-formed query → HTTP 200"); // Act var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18"); var status = (int)response.StatusCode; var bodyText = await response.Content.ReadAsStringAsync(); // Assert if (status != 200) { throw new Exception($"AZ-811 happy path: expected HTTP 200, got {status}. Body: {bodyText}"); } Console.WriteLine(" ✓ {lat,lon,zoom} accepted with HTTP 200"); } private static async Task LatOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 1: lat out of range (-90..90) → HTTP 400"); // Act var response = await httpClient.GetAsync($"{LatLonPath}?lat=91&lon=37.647063&zoom=18"); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lat out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lat out of range", expectedErrorPath: "lat"); Console.WriteLine(" ✓ lat=91 rejected with errors[\"lat\"]"); } private static async Task LonOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 2: lon out of range (-180..180) → HTTP 400"); // Act var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=181&zoom=18"); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lon out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lon out of range", expectedErrorPath: "lon"); Console.WriteLine(" ✓ lon=181 rejected with errors[\"lon\"]"); } private static async Task ZoomOutOfRange_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 3: zoom out of range (0..22) → HTTP 400"); // Act var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=30"); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 zoom out of range"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 zoom out of range", expectedErrorPath: "zoom"); Console.WriteLine(" ✓ zoom=30 rejected with errors[\"zoom\"]"); } private static async Task MissingLat_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 1: missing `lat` query param → HTTP 400 with errors.lat"); // Act — only lon + zoom supplied; the validator's NotNull rule on Lat must // fire (binder produces Lat=null because the DTO is nullable; see // GetTileByLatLonQuery for why). var response = await httpClient.GetAsync($"{LatLonPath}?lon=37.647063&zoom=18"); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 missing lat"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 missing lat", expectedErrorPath: "lat"); Console.WriteLine(" ✓ Missing lat rejected with errors[\"lat\"] = `lat` is required"); } private static async Task UnknownQueryParam_LegacyLatitude_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 4: legacy `?Latitude=&Longitude=&ZoomLevel=` (pre-AZ-811 wire format) → HTTP 400 (envelope filter)"); // Act — exact pre-AZ-811 wire format; must now fail explicitly instead // of silently binding to lat=0/lon=0/zoom=0 (typo class). var response = await httpClient.GetAsync($"{LatLonPath}?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18"); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 legacy param names"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 legacy param names"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "Latitude", label: "AZ-811 legacy param names"); Console.WriteLine(" ✓ Legacy ?Latitude=&Longitude=&ZoomLevel= rejected by envelope filter"); } private static async Task UnknownQueryParam_Hostile_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 4: hostile/typo query keys → HTTP 400 (envelope filter)"); // Act var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18&debug=1&admin=true"); var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 hostile params"); // Assert ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 hostile params"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-811 hostile params"); ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "admin", label: "AZ-811 hostile params"); Console.WriteLine(" ✓ ?debug=1&admin=true rejected; errors map names BOTH unknown keys"); } private static async Task LatTypeMismatch_Returns400(HttpClient httpClient) { Console.WriteLine(); Console.WriteLine("AZ-811 rule 5: lat type mismatch (non-numeric) → HTTP 400"); // Act var response = await httpClient.GetAsync($"{LatLonPath}?lat=fifty&lon=37.647063&zoom=18"); var status = (int)response.StatusCode; // Assert — ASP.NET query-param binding produces 400 for type mismatch via // BadHttpRequestException; the exact ProblemDetails shape varies depending // on whether the GlobalExceptionHandler intercepts. Either way the wire // contract is HTTP 400, no body leak. if (status != 400) { throw new Exception($"AZ-811 type mismatch: expected HTTP 400, got {status}."); } Console.WriteLine(" ✓ lat=fifty rejected with HTTP 400"); } }