mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 19:01:10 +00:00
8e7c602f51
AZ-535: POST /logout (caller's session), /logout/all (all sessions for user),
admin POST /sessions/{sid}/revoke, and verifier-only GET /sessions/revoked
snapshot. New Service role gates the snapshot. Idempotent revoke; reason +
revoked_by_user_id audited per row.
AZ-533: POST /sessions/mission mints a long-lived no-refresh ES256 token bound
to one aircraft + one mission. Audience narrowed to satellite-provider, hard
12 h cap, persisted as class='mission' so the existing logout/revoke surface
covers it. Successful CompanionPC /login or /token/refresh auto-revokes that
aircraft's open mission session (post-flight reconnect).
Schema: 09_sessions_logout_and_mission.sql adds revoked_by_user_id, class,
aircraft_id; drops NOT NULL on refresh_hash for mission rows; adds two partial
indexes for the auto-revoke and snapshot hot paths.
Tests: 13 new e2e tests, all green; full suite 75/76 (1 pre-existing flake in
PasswordHashingTests AC5 timing assertion, unrelated to this batch).
Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
11 KiB
C#
256 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// AZ-535 — logout endpoint + revocation surface for verifiers.
|
|
/// </summary>
|
|
public class LogoutRevocationTests : IClassFixture<TestFixture>
|
|
{
|
|
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); }
|
|
}
|
|
}
|