Files
satellite-provider/SatelliteProvider.IntegrationTests/JwtIntegrationTests.cs
T
Oleksandr Bezdieniezhnykh c396740644
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-491] Cycle 3 batch 2: consolidate JWT test-mint helpers into TestSupport
AZ-491 (3 SP): eliminate the cycle-2 duplicate of JWT-minting logic
that existed in both SatelliteProvider.Tests/TestUtilities/
JwtTokenFactory.cs (unit-side) and SatelliteProvider.IntegrationTests/
JwtTestHelpers.cs (integration-side), where the same Expires <
NotBefore bug needed parallel fixes in commits f64d0d7 + 11b7074.

Option A chosen: new SatelliteProvider.TestSupport class library
(no test framework) holds the canonical JwtTokenFactory.Create /
CreateExpired / TamperSignature. Both Tests and IntegrationTests
consume it via ProjectReference; production projects (Api, Common,
DataAccess, Services.*) cannot depend on it. The notBefore-shift
workaround is preserved with an inline regression-prevention comment
back-referencing the cycle-2 fix commits.

SatelliteProvider.IntegrationTests/JwtTestHelpers.cs is stripped to
runner-only concerns: ResolveSecretOrThrow, AttachDefaultAuthorization,
and the DefaultSubject = "integration-tests" constant. Call sites in
Program.cs, JwtIntegrationTests.cs, and UavUploadTests.cs (10 sites)
switched to JwtTokenFactory.* with JwtTestHelpers.DefaultSubject
explicitly passed for the runner subject - behavior parity preserved.

Dockerfile for IntegrationTests gets the new TestSupport csproj
in its pre-restore COPY layer. Api Dockerfile unchanged (TestSupport
is NOT a production dependency).

A new code-review SKILL.md Phase 6 checklist row flags near-identical
helper logic across test projects as a Medium / Maintainability
finding with explicit cycle-2 retro back-reference, so this whole
pattern stops at one occurrence.

module-layout.md adds a TestSupport Shared/Cross-Cutting entry
documenting the production-isolation invariant. tests_unit.md +
tests_integration.md updated to describe the consolidated layout.
sln updated.

Test-suite gate (AC-2 + AC-3) deferred to Step 16 Final Test Run
per implement-skill convention. Per-batch review verdict:
PASS_WITH_WARNINGS with 1 Low (pre-existing 7.0.3 version pin
preserved verbatim from cycle-2 IntegrationTests csproj for parity;
not blocking; deferred bump).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 01:32:24 +03:00

148 lines
6.6 KiB
C#

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?Latitude=47.461747&Longitude=37.647063&ZoomLevel=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)");
await AnonymousRequest_To_AnyEndpoint_Returns401(apiUrl);
await ExpiredToken_Returns401(apiUrl, secret);
await InvalidSignature_Returns401(apiUrl, secret);
await ValidToken_Returns200_OnHealthyEndpoint(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 = JwtTokenFactory.CreateExpired(secret, JwtTestHelpers.DefaultSubject);
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 = JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject);
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: Valid token reaches handler unchanged");
using var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(2) };
var valid = JwtTokenFactory.Create(secret, JwtTestHelpers.DefaultSubject);
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 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)");
}
}