using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Xunit; namespace Azaion.E2E.Tests; /// /// AZ-535 — logout endpoint + revocation surface for verifiers. /// public class LogoutRevocationTests : IClassFixture { private readonly TestFixture _fixture; public LogoutRevocationTests(TestFixture fixture) => _fixture = fixture; private async Task<(string Email, string Password)> SeedUser(string suffix, int role = 10) { var email = $"logout-{suffix}-{Guid.NewGuid():N}@e2e.local"; var password = "Logout1234ABC"; using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); using var resp = await admin.PostAsync("/users", new { email, password, role }); resp.StatusCode.Should().Be(HttpStatusCode.OK); return (email, password); } private async Task CleanupUser(string email) { await _fixture.Db.DeleteSessionsFor(email); await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } private static Guid SidFromAccessToken(string accessToken) { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken); var sid = jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Sid).Value; return Guid.Parse(sid); } [Fact] public async Task AC1_Logout_revokes_caller_session_and_blocks_refresh() { // Arrange var (email, password) = await SeedUser("ac1"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var login = await api.LoginFullAsync(email, password); var sid = SidFromAccessToken(login.AccessToken); // Act client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken); using var resp = await client.PostAsJsonAsync("/logout", new { }); resp.StatusCode.Should().Be(HttpStatusCode.OK); // Assert — DB row revoked with reason=logged_out, by the caller themselves var (revokedAt, reason, by) = await _fixture.Db.GetRevocationInfo(sid); revokedAt.Should().NotBeNull(); reason.Should().Be("logged_out"); by.Should().NotBeNull("the caller's user id is recorded as the actor"); // Refresh token is now useless because its session row is revoked using var refreshResp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = login.RefreshToken }); refreshResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } finally { await CleanupUser(email); } } [Fact] public async Task AC1_Logout_is_idempotent() { // Arrange var (email, password) = await SeedUser("idem"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var login = await api.LoginFullAsync(email, password); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken); using var first = await client.PostAsJsonAsync("/logout", new { }); using var second = await client.PostAsJsonAsync("/logout", new { }); // Act + Assert — both calls succeed first.StatusCode.Should().Be(HttpStatusCode.OK); second.StatusCode.Should().Be(HttpStatusCode.OK); } finally { await CleanupUser(email); } } [Fact] public async Task AC2_Logout_all_revokes_every_session_for_the_user() { // Arrange — same user, two parallel sessions var (email, password) = await SeedUser("ac2"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var session1 = await api.LoginFullAsync(email, password); var session2 = await api.LoginFullAsync(email, password); (await _fixture.Db.CountActiveSessionsForUser(email)).Should().Be(2); // Act — logout/all using session1's access token client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session1.AccessToken); using var resp = await client.PostAsJsonAsync("/logout/all", new { }); resp.StatusCode.Should().Be(HttpStatusCode.OK); // Assert — both sessions dead; both refresh tokens rejected (await _fixture.Db.CountActiveSessionsForUser(email)).Should().Be(0); using var r1 = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = session1.RefreshToken }); using var r2 = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = session2.RefreshToken }); r1.StatusCode.Should().Be(HttpStatusCode.Unauthorized); r2.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } finally { await CleanupUser(email); } } [Fact] public async Task AC3_Admin_can_revoke_any_session_by_sid() { // Arrange var (email, password) = await SeedUser("ac3"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var victim = await api.LoginFullAsync(email, password); var sid = SidFromAccessToken(victim.AccessToken); // Act — admin (the fixture's AdminToken) revokes the victim's session using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); using var resp = await admin.PostAsync($"/sessions/{sid}/revoke", new { }); resp.StatusCode.Should().Be(HttpStatusCode.OK); // Assert — reason recorded as admin_revoked var (revokedAt, reason, _) = await _fixture.Db.GetRevocationInfo(sid); revokedAt.Should().NotBeNull(); reason.Should().Be("admin_revoked"); // Victim refresh now fails using var refreshResp = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = victim.RefreshToken }); refreshResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } finally { await CleanupUser(email); } } [Fact] public async Task AC3_Non_admin_cannot_revoke_other_sessions() { // Arrange — two non-admin users var (a, pa) = await SeedUser("ac3a"); var (b, pb) = await SeedUser("ac3b"); try { using var clientA = _fixture.CreateHttpClient(); var apiA = new ApiClient(clientA); var loginA = await apiA.LoginFullAsync(a, pa); using var clientB = _fixture.CreateHttpClient(); var apiB = new ApiClient(clientB); var loginB = await apiB.LoginFullAsync(b, pb); var sidB = SidFromAccessToken(loginB.AccessToken); // Act — A tries to revoke B clientA.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginA.AccessToken); using var resp = await clientA.PostAsJsonAsync($"/sessions/{sidB}/revoke", new { }); // Assert — Forbidden, B still active resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); var (rev, _, _) = await _fixture.Db.GetRevocationInfo(sidB); rev.Should().BeNull(); } finally { await CleanupUser(a); await CleanupUser(b); } } [Fact] public async Task AC4_Verifier_polls_revoked_snapshot_with_service_role() { // Arrange — service-role user + a victim whose session we revoke var (verifier, vp) = await SeedUser("svc"); var (victim, pp) = await SeedUser("victim"); try { await _fixture.Db.PromoteToService(verifier); using var victimClient = _fixture.CreateHttpClient(); var victimApi = new ApiClient(victimClient); var victimLogin = await victimApi.LoginFullAsync(victim, pp); var victimSid = SidFromAccessToken(victimLogin.AccessToken); // Revoke the victim's session via the admin path (irrelevant which path mints // the row — we're testing the snapshot endpoint). using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); using var revResp = await adminClient.PostAsync($"/sessions/{victimSid}/revoke", new { }); revResp.StatusCode.Should().Be(HttpStatusCode.OK); // Service user logs in (happens AFTER promotion so the new role is on the JWT). using var verifierClient = _fixture.CreateHttpClient(); var verifierApi = new ApiClient(verifierClient); var verifierLogin = await verifierApi.LoginFullAsync(verifier, vp); // Act — fetch the snapshot since 1 hour ago verifierClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", verifierLogin.AccessToken); var since = DateTime.UtcNow.AddHours(-1).ToString("o"); using var snapResp = await verifierClient.GetAsync($"/sessions/revoked?since={Uri.EscapeDataString(since)}"); // Assert snapResp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await snapResp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var hits = doc.RootElement.EnumerateArray() .Where(e => Guid.Parse(e.GetProperty("sid").GetString()!) == victimSid) .ToList(); hits.Should().HaveCount(1, "victim session must appear exactly once"); } finally { await CleanupUser(verifier); await CleanupUser(victim); } } [Fact] public async Task AC4_NonService_user_cannot_read_revoked_snapshot() { // Arrange — ResourceUploader (role=10) is not Service or ApiAdmin var (email, password) = await SeedUser("nosvc"); try { using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); var login = await api.LoginFullAsync(email, password); // Act client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken); using var resp = await client.GetAsync("/sessions/revoked"); // Assert resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); } finally { await CleanupUser(email); } } }