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"); } }