mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 20:51:09 +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>
244 lines
9.1 KiB
C#
244 lines
9.1 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-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; }
|
|
}
|
|
}
|