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-533 — mission-token issuance for offline UAV operations. /// public class MissionTokenTests : IClassFixture { 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 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(); 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; } } }