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