mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 20:01:08 +00:00
[AZ-535] [AZ-533] Logout/revocation surface + UAV mission tokens
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>
This commit is contained in:
@@ -193,6 +193,66 @@ public sealed class DbHelper
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 — count active sessions for a user, optionally filtered to a session class.
|
||||
/// </summary>
|
||||
public async Task<int> CountActiveSessionsForUser(string email, string? sessionClass = null, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await OpenAsync(ct);
|
||||
var sql = @"
|
||||
SELECT COUNT(*) FROM public.sessions
|
||||
WHERE user_id = (SELECT id FROM public.users WHERE email = @e)
|
||||
AND revoked_at IS NULL"
|
||||
+ (sessionClass != null ? " AND class = @c" : "");
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("e", email);
|
||||
if (sessionClass != null) cmd.Parameters.AddWithValue("c", sessionClass);
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AZ-533 — count open mission sessions whose <c>aircraft_id</c> matches the given user.
|
||||
/// </summary>
|
||||
public async Task<int> CountOpenMissionsForAircraft(Guid aircraftId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT COUNT(*) FROM public.sessions
|
||||
WHERE aircraft_id = @a AND class = 'mission' AND revoked_at IS NULL", conn);
|
||||
cmd.Parameters.AddWithValue("a", aircraftId);
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 — pluck the row's revocation columns for assertions on who/why/when.
|
||||
/// </summary>
|
||||
public async Task<(DateTime? RevokedAt, string? Reason, Guid? RevokedBy)> GetRevocationInfo(Guid sessionId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"SELECT revoked_at, revoked_reason, revoked_by_user_id FROM public.sessions WHERE id = @s", conn);
|
||||
cmd.Parameters.AddWithValue("s", sessionId);
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await rd.ReadAsync(ct))
|
||||
throw new InvalidOperationException($"Session {sessionId} not found.");
|
||||
return (
|
||||
rd.IsDBNull(0) ? null : DateTime.SpecifyKind(rd.GetDateTime(0), DateTimeKind.Utc),
|
||||
rd.IsDBNull(1) ? null : rd.GetString(1),
|
||||
rd.IsDBNull(2) ? null : rd.GetGuid(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AZ-535 — promote a user to <c>Service</c> role so they can read /sessions/revoked.
|
||||
/// </summary>
|
||||
public async Task PromoteToService(string email, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"UPDATE public.users SET role = 'Service' WHERE email = @e", conn);
|
||||
cmd.Parameters.AddWithValue("e", email);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public static string HashRefreshToken(string opaqueToken)
|
||||
{
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes(opaqueToken);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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-533 — mission-token issuance for offline UAV operations.
|
||||
/// </summary>
|
||||
public class MissionTokenTests : IClassFixture<TestFixture>
|
||||
{
|
||||
private readonly TestFixture _fixture;
|
||||
|
||||
public MissionTokenTests(TestFixture fixture) => _fixture = fixture;
|
||||
|
||||
private async Task<(string Email, string Password)> SeedUser(string suffix, int role)
|
||||
{
|
||||
var email = $"mission-{suffix}-{Guid.NewGuid():N}@e2e.local";
|
||||
var password = "Mission1234ABC";
|
||||
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 async Task<Guid> GetUserId(string email)
|
||||
{
|
||||
// Trick: the access-token sub claim equals the user id; quickest to fetch
|
||||
// without adding a /me endpoint just for tests.
|
||||
var (e, p) = (email, "Mission1234ABC");
|
||||
using var c = _fixture.CreateHttpClient();
|
||||
var api = new ApiClient(c);
|
||||
var login = await api.LoginFullAsync(e, p);
|
||||
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(login.AccessToken);
|
||||
return Guid.Parse(jwt.Claims.First(x => x.Type == "nameid" || x.Type == "sub").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC1_Mission_token_carries_required_claims_and_long_lifetime()
|
||||
{
|
||||
// Arrange
|
||||
var (pilot, pp) = await SeedUser("p1", role: 40); // Admin role to be allowed by RequireAuthorization
|
||||
var (aircraft, ap) = await SeedUser("a1", role: 30); // CompanionPC
|
||||
try
|
||||
{
|
||||
var aircraftId = await GetUserId(aircraft);
|
||||
|
||||
using var client = _fixture.CreateHttpClient();
|
||||
var api = new ApiClient(client);
|
||||
var pilotLogin = await api.LoginFullAsync(pilot, pp);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
missionId = "M-2026-05-14-001",
|
||||
aircraftId = aircraftId,
|
||||
plannedDurationH = 8.0,
|
||||
requestedScope = new[] { "submit_telemetry", "fetch_mission" },
|
||||
};
|
||||
|
||||
// Act
|
||||
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<MissionResponse>();
|
||||
body.Should().NotBeNull();
|
||||
|
||||
// Assert — claims
|
||||
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(body!.AccessToken);
|
||||
jwt.Claims.Should().Contain(c => c.Type == "mission_id" && c.Value == "M-2026-05-14-001");
|
||||
jwt.Claims.Should().Contain(c => c.Type == "aircraft_id" && c.Value == aircraftId.ToString());
|
||||
jwt.Claims.Should().Contain(c => c.Type == "token_class" && c.Value == "mission");
|
||||
jwt.Audiences.Should().Contain("satellite-provider");
|
||||
|
||||
// Lifetime: 8 h planned + 1 h buffer = 9 h ±60 s
|
||||
var driftSeconds = (jwt.ValidTo - DateTime.UtcNow.AddHours(9)).TotalSeconds;
|
||||
Math.Abs(driftSeconds).Should().BeLessThan(60);
|
||||
|
||||
// Sessions row class=mission, aircraft_id set
|
||||
(await _fixture.Db.CountActiveSessionsForUser(pilot, "mission")).Should().Be(1);
|
||||
(await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupUser(pilot);
|
||||
await CleanupUser(aircraft);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC2_Mission_id_must_match_pattern()
|
||||
{
|
||||
var (pilot, pp) = await SeedUser("p2", role: 40);
|
||||
var (aircraft, _) = await SeedUser("a2", role: 30);
|
||||
try
|
||||
{
|
||||
var aircraftId = await GetUserId(aircraft);
|
||||
|
||||
using var client = _fixture.CreateHttpClient();
|
||||
var api = new ApiClient(client);
|
||||
var pilotLogin = await api.LoginFullAsync(pilot, pp);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
missionId = "not-a-mission-id",
|
||||
aircraftId,
|
||||
plannedDurationH = 1.0,
|
||||
requestedScope = (string[])[]
|
||||
};
|
||||
|
||||
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupUser(pilot);
|
||||
await CleanupUser(aircraft);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.05)] // < 6 min
|
||||
[InlineData(13.0)] // > 12 h cap
|
||||
public async Task AC2_Planned_duration_must_be_within_bounds(double hours)
|
||||
{
|
||||
var (pilot, pp) = await SeedUser("p3", role: 40);
|
||||
var (aircraft, _) = await SeedUser("a3", role: 30);
|
||||
try
|
||||
{
|
||||
var aircraftId = await GetUserId(aircraft);
|
||||
|
||||
using var client = _fixture.CreateHttpClient();
|
||||
var api = new ApiClient(client);
|
||||
var pilotLogin = await api.LoginFullAsync(pilot, pp);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
missionId = "M-2026-05-14-002",
|
||||
aircraftId,
|
||||
plannedDurationH = hours,
|
||||
};
|
||||
|
||||
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupUser(pilot);
|
||||
await CleanupUser(aircraft);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC3_Aircraft_must_exist_with_companionpc_role()
|
||||
{
|
||||
var (pilot, pp) = await SeedUser("p4", role: 40);
|
||||
var (notAircraft, _) = await SeedUser("a4", role: 10); // ResourceUploader, not CompanionPC
|
||||
try
|
||||
{
|
||||
var bogusId = await GetUserId(notAircraft);
|
||||
|
||||
using var client = _fixture.CreateHttpClient();
|
||||
var api = new ApiClient(client);
|
||||
var pilotLogin = await api.LoginFullAsync(pilot, pp);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
missionId = "M-2026-05-14-003",
|
||||
aircraftId = bogusId,
|
||||
plannedDurationH = 1.0,
|
||||
};
|
||||
|
||||
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupUser(pilot);
|
||||
await CleanupUser(notAircraft);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC4_Aircraft_login_auto_revokes_open_mission_sessions()
|
||||
{
|
||||
var (pilot, pp) = await SeedUser("p5", role: 40);
|
||||
var (aircraft, ap) = await SeedUser("a5", role: 30);
|
||||
try
|
||||
{
|
||||
var aircraftId = await GetUserId(aircraft);
|
||||
|
||||
// Pilot mints a mission token for the aircraft
|
||||
using var pilotClient = _fixture.CreateHttpClient();
|
||||
var pilotApi = new ApiClient(pilotClient);
|
||||
var pilotLogin = await pilotApi.LoginFullAsync(pilot, pp);
|
||||
pilotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
|
||||
|
||||
using var mintResp = await pilotClient.PostAsJsonAsync("/sessions/mission", new
|
||||
{
|
||||
missionId = "M-2026-05-14-004",
|
||||
aircraftId = aircraftId,
|
||||
plannedDurationH = 6.0,
|
||||
});
|
||||
mintResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
(await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(1);
|
||||
|
||||
// Act — aircraft reconnects: logs in as itself
|
||||
using var aircraftClient = _fixture.CreateHttpClient();
|
||||
var aircraftApi = new ApiClient(aircraftClient);
|
||||
await aircraftApi.LoginFullAsync(aircraft, ap);
|
||||
|
||||
// Assert — mission session for this aircraft is gone (post-flight reconnect)
|
||||
(await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupUser(pilot);
|
||||
await CleanupUser(aircraft);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MissionResponse
|
||||
{
|
||||
public string AccessToken { get; init; } = "";
|
||||
public DateTime AccessExp { get; init; }
|
||||
public string TokenClass { get; init; } = "";
|
||||
public Guid SessionId { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user