using System.Net; using System.Net.Http.Headers; using SatelliteProvider.TestSupport; namespace SatelliteProvider.IntegrationTests; public static class JwtIntegrationTests { private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18"; private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000"; public static async Task RunAll(string apiUrl, string secret) { RouteTestHelpers.PrintTestHeader("Test: JWT auth baseline (AZ-487 + AZ-494)"); await AnonymousRequest_To_AnyEndpoint_Returns401(apiUrl); await ExpiredToken_Returns401(apiUrl, secret); await InvalidSignature_Returns401(apiUrl, secret); await ValidToken_Returns200_OnHealthyEndpoint(apiUrl, secret); await WrongIssuer_Returns401(apiUrl, secret); await WrongAudience_Returns401(apiUrl, secret); await SwaggerDocument_AdvertisesBearerSecurityScheme(apiUrl); Console.WriteLine("✓ JWT integration tests: PASSED"); } private static async Task AnonymousRequest_To_AnyEndpoint_Returns401(string apiUrl) { Console.WriteLine(); Console.WriteLine("AZ-487 AC-1: Anonymous request to a protected endpoint returns 401"); using var anon = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var response = await anon.GetAsync(ProtectedTilesPath); var status = (int)response.StatusCode; if (status != 401) { throw new Exception($"Expected 401 for anonymous request, got {status}"); } Console.WriteLine($" ✓ Anonymous request rejected with HTTP {status}"); } private static async Task ExpiredToken_Returns401(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-487 AC-2: Expired token returns 401"); using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var expired = JwtTestHelpers.MintExpired(secret); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expired); var response = await client.GetAsync(ProtectedTilesPath); var status = (int)response.StatusCode; if (status != 401) { throw new Exception($"Expected 401 for expired token, got {status}"); } Console.WriteLine($" ✓ Expired token rejected with HTTP {status}"); } private static async Task InvalidSignature_Returns401(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-487 AC-3: Tampered signature returns 401"); using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var valid = JwtTestHelpers.MintAuthenticated(secret); var tampered = JwtTokenFactory.TamperSignature(valid); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tampered); var response = await client.GetAsync(ProtectedRegionPath); var status = (int)response.StatusCode; if (status != 401) { throw new Exception($"Expected 401 for tampered signature, got {status}"); } Console.WriteLine($" ✓ Tampered signature rejected with HTTP {status}"); } private static async Task ValidToken_Returns200_OnHealthyEndpoint(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-487 AC-4 / AZ-494 AC-3: Valid token with matching iss/aud reaches handler unchanged"); using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(2) }; var valid = JwtTestHelpers.MintAuthenticated(secret); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", valid); var response = await client.GetAsync(ProtectedTilesPath); var status = (int)response.StatusCode; // The endpoint may legitimately return 200 (tile available) or a tile-download-related // error; what we care about for AZ-487 is that the request reached the handler at all // (i.e. NOT 401 / 403). Treat 200 as confirmation; treat anything other than 401/403 as // "passed auth" — the handler decided the outcome. if (status == 401 || status == 403) { var body = await response.Content.ReadAsStringAsync(); throw new Exception($"Expected valid-token request to bypass auth, got {status}. Body: {body}"); } Console.WriteLine($" ✓ Valid-token request reached handler (HTTP {status})"); } private static async Task WrongIssuer_Returns401(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-494 AC-1: Token with wrong iss returns 401"); using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var wrongIssuer = JwtTestHelpers.MintAuthenticated(secret, overrideIssuer: "https://wrong-issuer.invalid/"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongIssuer); var response = await client.GetAsync(ProtectedTilesPath); var status = (int)response.StatusCode; if (status != 401) { throw new Exception($"Expected 401 for wrong-issuer token, got {status}"); } Console.WriteLine($" ✓ Wrong-issuer token rejected with HTTP {status}"); } private static async Task WrongAudience_Returns401(string apiUrl, string secret) { Console.WriteLine(); Console.WriteLine("AZ-494 AC-2: Token with wrong aud returns 401"); using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var wrongAudience = JwtTestHelpers.MintAuthenticated(secret, overrideAudience: "wrong-audience-not-satellite"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongAudience); var response = await client.GetAsync(ProtectedTilesPath); var status = (int)response.StatusCode; if (status != 401) { throw new Exception($"Expected 401 for wrong-audience token, got {status}"); } Console.WriteLine($" ✓ Wrong-audience token rejected with HTTP {status}"); } private static async Task SwaggerDocument_AdvertisesBearerSecurityScheme(string apiUrl) { // AC-7: Swagger UI accepts a Bearer token via the "Authorize" button. The button is // rendered only when the OpenAPI document declares a `Bearer` security scheme — so the // existence of that scheme in the document is the automatable signal for AC-7. Console.WriteLine(); Console.WriteLine("AZ-487 AC-7: Swagger document advertises Bearer security scheme"); using var anon = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) }; var response = await anon.GetAsync("/swagger/v1/swagger.json"); if (!response.IsSuccessStatusCode) { throw new Exception($"Expected Swagger document to be reachable, got HTTP {(int)response.StatusCode}"); } var body = await response.Content.ReadAsStringAsync(); using var doc = System.Text.Json.JsonDocument.Parse(body); var root = doc.RootElement; if (!root.TryGetProperty("components", out var components) || !components.TryGetProperty("securitySchemes", out var schemes) || !schemes.TryGetProperty("Bearer", out var bearer)) { throw new Exception("Swagger document is missing `components.securitySchemes.Bearer`."); } var type = bearer.GetProperty("type").GetString(); var scheme = bearer.GetProperty("scheme").GetString(); var format = bearer.TryGetProperty("bearerFormat", out var bf) ? bf.GetString() : null; if (!string.Equals(type, "http", StringComparison.OrdinalIgnoreCase) || !string.Equals(scheme, "bearer", StringComparison.OrdinalIgnoreCase) || !string.Equals(format, "JWT", StringComparison.OrdinalIgnoreCase)) { throw new Exception($"Bearer scheme has wrong shape: type={type}, scheme={scheme}, bearerFormat={format}"); } Console.WriteLine($" ✓ Swagger document declares Bearer (http, bearer, JWT)"); } }