using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Xunit; namespace Azaion.E2E.Tests; [Collection("E2E")] public sealed class AuthTests { private static readonly JsonSerializerOptions ResponseJsonOptions = new() { PropertyNameCaseInsensitive = true }; private sealed record ErrorResponse(int ErrorCode, string Message); private sealed record LoginOkResponse(string Token); private readonly TestFixture _fixture; public AuthTests(TestFixture fixture) => _fixture = fixture; [Fact] public async Task Login_with_valid_admin_credentials_returns_200_and_token() { // Arrange using var client = _fixture.CreateApiClient(); // Act using var response = await client.PostAsync("/login", new { email = _fixture.AdminEmail, password = _fixture.AdminPassword }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(ResponseJsonOptions); body.Should().NotBeNull(); body!.Token.Should().NotBeNullOrWhiteSpace(); } [Fact] public async Task Jwt_contains_expected_claims_and_lifetime() { // Arrange using var client = _fixture.CreateApiClient(); // Act using var loginResponse = await client.PostAsync("/login", new { email = _fixture.AdminEmail, password = _fixture.AdminPassword }); var loginBody = await loginResponse.Content.ReadFromJsonAsync(ResponseJsonOptions); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(loginBody!.Token); // Assert loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); jwt.Issuer.Should().Be("AzaionApi"); jwt.Audiences.Should().Contain("Annotators/OrangePi/Admins"); var iatSeconds = long.Parse( jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Iat).Value, System.Globalization.CultureInfo.InvariantCulture); var expSeconds = long.Parse( jwt.Claims.Single(c => c.Type == JwtRegisteredClaimNames.Exp).Value, System.Globalization.CultureInfo.InvariantCulture); // AZ-531 — access tokens are now 15-min (refresh-flow shortened from 4h). TimeSpan.FromSeconds(expSeconds - iatSeconds) .Should().BeCloseTo(TimeSpan.FromMinutes(15), TimeSpan.FromSeconds(60)); jwt.Claims.Should().Contain(c => c.Type == "role"); // AZ-531 / AZ-535 — sid and jti claims are needed for logout + per-token denylist. jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Sid); jwt.Claims.Should().Contain(c => c.Type == JwtRegisteredClaimNames.Jti); } // AZ-556 AC-1 — unknown email is now indistinguishable from wrong password. [Fact] public async Task Login_with_unknown_email_returns_401_invalid_credentials() { // Arrange — use a fresh per-test email so the audit assertion below cannot // false-pass on a leftover row from another test. var unknownEmail = $"unknown-{Guid.NewGuid():N}@authtest.example.com"; using var client = _fixture.CreateApiClient(); try { // Act using var response = await client.PostAsync("/login", new { email = unknownEmail, password = "irrelevant" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); var err = await response.Content.ReadFromJsonAsync(ResponseJsonOptions); err.Should().NotBeNull(); err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)"); // AZ-556 AC-6 — audit log records the unknown-email category internally // even though the wire response is opaque. (await _fixture.Db.CountAuditEvents("login_failed_unknown_email", unknownEmail)) .Should().BeGreaterOrEqualTo(1, "audit must still record the unknown-email reason"); } finally { await _fixture.Db.DeleteAuditEventsFor(unknownEmail); } } // AZ-556 AC-2 — wrong password collapses to the same response shape as unknown email. [Fact] public async Task Login_with_wrong_password_returns_401_invalid_credentials() { // Arrange using var client = _fixture.CreateApiClient(); // Act using var response = await client.PostAsync("/login", new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); var err = await response.Content.ReadFromJsonAsync(ResponseJsonOptions); err.Should().NotBeNull(); err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)"); } // AZ-556 AC-1 + AC-2 — wire response (status, body) for unknown email and wrong // password MUST be byte-equivalent except for the human-readable message text // (which is identical too because both throw the same ExceptionEnum). [Fact] public async Task Login_unknown_email_and_wrong_password_produce_identical_response() { // Arrange using var client = _fixture.CreateApiClient(); // Act using var unknown = await client.PostAsync("/login", new { email = "nonexistent@example.com", password = "irrelevant" }); using var wrong = await client.PostAsync("/login", new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" }); // Assert unknown.StatusCode.Should().Be(HttpStatusCode.Unauthorized); wrong.StatusCode.Should().Be(HttpStatusCode.Unauthorized); var unknownBody = await unknown.Content.ReadAsStringAsync(); var wrongBody = await wrong.Content.ReadAsStringAsync(); unknownBody.Should().Be(wrongBody, "AZ-556 — wire payloads must be byte-identical"); } // AZ-556 AC-3 — disabled-account response is indistinguishable from wrong password. [Fact] public async Task Login_with_disabled_account_returns_401_invalid_credentials_indistinguishable_from_wrong_password() { // Arrange — create a fresh user, then disable them via the admin endpoint. var email = $"disabled-{Guid.NewGuid():N}@authtest.example.com"; const string password = "Correct2026!"; using (var create = await _fixture.HttpClient.PostAsJsonAsync("/users", new { email, password, role = 10 })) create.IsSuccessStatusCode.Should().BeTrue($"setup: create user {email}"); try { using (var disable = await _fixture.HttpClient.PutAsync( $"/users/{Uri.EscapeDataString(email)}/disable", content: null)) disable.IsSuccessStatusCode.Should().BeTrue("setup: disable the user"); using var anon = _fixture.CreateApiClient(); // Act — present the correct password to the disabled account, and a wrong // password to a known-enabled account. The wire responses must match. using var disabledResp = await anon.PostAsync("/login", new { email, password }); using var wrongResp = await anon.PostAsync("/login", new { email = _fixture.AdminEmail, password = "DefinitelyWrongPassword" }); // Assert disabledResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); wrongResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); var disabledBody = await disabledResp.Content.ReadAsStringAsync(); var wrongBody = await wrongResp.Content.ReadAsStringAsync(); disabledBody.Should().Be(wrongBody, "AZ-556 — disabled-account body must match wrong-password body"); // AZ-556 AC-6 — audit log preserves the internal granularity even though // the wire response was unified. (await _fixture.Db.CountAuditEvents("login_failed_disabled", email)) .Should().BeGreaterOrEqualTo(1, "audit must still record the disabled-account reason"); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } }