using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Resilience;
///
/// NFT-RES-07 — operational counterpart of NFT-SEC-11. Verifies that a JWKS
/// rotation propagates through the SUT WITHOUT a process restart. The
/// security-shaped variant lives in Tests/Security/JwksRotationTests.cs;
/// here the assertion focuses on
/// docker inspect --format '{{.State.StartedAt}}' missions-sut
/// returning the SAME ISO-8601 timestamp before and after the rotation flow.
/// Traces: AC-5.7.
///
[Collection("JwksRotation")]
[Trait("Category", "Res")]
[Trait("db_access", "seed-or-assert-only")]
public sealed class JwksRotationNoRestartTests : TestBase, IClassFixture
{
[SkippableFact(Timeout = 200_000)]
[Trait("Traces", "AC-5.7")]
[Trait("max_ms", "180000")]
public async Task NFT_RES_07_jwks_rotation_propagates_without_missions_restart()
{
Skip.IfNot(MissionsContainerHelper.Enabled,
"Requires docker CLI access (COMPOSE_RESTART_ENABLED=1) to read StartedAt.");
// Arrange — capture StartedAt before any rotation activity so the
// post-flow comparison is anchored to "before this test started".
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
var startedAtBefore = MissionsContainerHelper.GetStartedAt("missions-sut");
var t1 = await Tokens.MintDefaultAsync();
var kidV1 = t1.Kid;
using (var resp = await CallVehiclesAsync(t1.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
// Act 1 — rotate; mint a token with the new kid; assert pre-refresh 401.
var kidV2 = await RotateMockAsync();
Assert.NotEqual(kidV1, kidV2);
var t2 = await Tokens.MintDefaultAsync();
Assert.Equal(kidV2, t2.Kid);
using (var resp = await CallVehiclesAsync(t2.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.Unauthorized);
// Act 2 — force JWKS refresh via the test-only hook (the library's
// 5-minute floor on AutomaticRefreshInterval forbids the proactive
// path and our custom IssuerSigningKeyResolver bypasses the JwtBearer
// signature-failure refresh path; see Helpers/JwksRefreshHelper.cs).
await JwksRefreshHelper.ForceRefreshAsync(Missions);
using (var resp = await CallVehiclesAsync(t2.Jwt))
await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK);
// Assert — service did NOT restart.
var startedAtAfter = MissionsContainerHelper.GetStartedAt("missions-sut");
Assert.Equal(startedAtBefore, startedAtAfter);
}
private async Task CallVehiclesAsync(string jwt)
{
var req = new HttpRequestMessage(HttpMethod.Get, "/vehicles");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
return await Missions.SendAsync(req);
}
private static async Task RotateMockAsync()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var rotateUrl = new Uri(new Uri(TestEnvironment.JwksMockBaseUrl), "/rotate-key");
using var resp = await http.PostAsync(rotateUrl, content: null);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync();
return body.GetProperty("kid").GetString()
?? throw new InvalidOperationException("mock /rotate-key returned no kid");
}
}