Files
admin/e2e/Azaion.E2E/Tests/AuthTests.cs
T
Oleksandr Bezdieniezhnykh 4bf2e689cb [AZ-556] [AZ-557] Unify login errors + share MFA lockout pipeline
AZ-556 collapses every /login rejection (unknown email, wrong password,
disabled account, lockout, per-account rate limit) to a single opaque
InvalidCredentials (70) → 401 response. Timing equalised by a new
Security.VerifyDummy using the same Argon2id parameters. Audit log keeps
the rejection category internally (login_failed_unknown_email,
login_failed_disabled).

AZ-557 wires /login/mfa into the existing per-account lockout +
rate-limit pipeline. MFA failures now feed UserService's shared failure
accounting (RegisterMfaFailedLogin → RegisterFailedLoginCore) and
CountRecentFailedLogins aggregates both login_failed and
mfa_login_failed rows. Successful TOTP / recovery resets the counter.

Deprecated five legacy ExceptionEnum members (NoEmailFound,
WrongPassword, UserDisabled, AccountLocked, LoginRateLimited) — kept
defined for cross-workspace verifier compatibility during the
deprecation window.

E2E coverage updated: AuthTests (byte-identical body assertion +
disabled-account audit row), LoginRateLimitTests, PasswordHashingTests,
SecurityTests, plus four new MfaLoginTests (AC1, AC2, AC5, AC7).

Code review verdict: PASS_WITH_WARNINGS (batch_06_cycle2_review.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:56:00 +03:00

190 lines
8.3 KiB
C#

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<LoginOkResponse>(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<LoginOkResponse>(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<ErrorResponse>(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<ErrorResponse>(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);
}
}
}