using System.Net; using System.Net.Http.Json; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Xunit; namespace Azaion.E2E.Tests; // AZ-537 — /login per-IP and per-account rate limit + account lockout. // // AC-1 (per-IP) is intentionally skipped at the e2e layer: every test in this // container shares one source IP, so reliably triggering the per-IP partition // without polluting the shared budget for other tests is impractical. The per-IP // rate limit is implemented via ASP.NET Core's built-in SlidingWindowRateLimiter // applied to /login via .RequireRateLimiting("login-per-ip") and is exercised by // the framework's own tests; manual verification covers the wiring. [Collection("E2E")] public sealed class LoginRateLimitTests { private static readonly JsonSerializerOptions ResponseJsonOptions = new() { PropertyNameCaseInsensitive = true }; private sealed record ErrorResponse(int ErrorCode, string Message); private readonly TestFixture _fixture; public LoginRateLimitTests(TestFixture fixture) => _fixture = fixture; [Fact(Skip = "Per-IP rate limit not testable in shared-IP container env; verified by ASP.NET Core RateLimiter unit tests + manual probe (AZ-537 AC-1).")] public Task AC1_Per_ip_rate_limit_returns_429() => Task.CompletedTask; [Fact] public async Task AC2_Per_account_rate_limit_returns_429_with_retry_after() { // Arrange — fresh user; spec: 5 attempts / 5 min, the 6th is rate-limited. var email = $"ratelimit-{Guid.NewGuid():N}@authtest.example.com"; const string correct = "Correct2026!"; await CreateUser(email, correct); try { using var client = _fixture.CreateApiClient(); // Act — 5 wrong attempts seed the per-account counter. AZ-556 unifies the // response to InvalidCredentials (401), so every attempt — wrong, rate- // limited, or locked — looks the same on the wire. Retry-After is the only // signal that the rate-limit branch is in play. for (var i = 0; i < 5; i++) { using var r = await client.PostAsync("/login", new { email, password = $"wrong-{i}" }); r.StatusCode.Should().Be(HttpStatusCode.Unauthorized, $"attempt {i + 1} should be wrong-password / InvalidCredentials"); } // The 6th attempt — even with the *correct* password — must be rate-limited. using var sixth = await client.PostAsync("/login", new { email, password = correct }); // Assert sixth.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "AZ-556 collapses lockout/rate-limit responses to InvalidCredentials too"); sixth.Headers.RetryAfter.Should().NotBeNull("Retry-After should hint when to try again"); var err = await sixth.Content.ReadFromJsonAsync(ResponseJsonOptions); err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)"); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } [Fact] public async Task AC3_Account_locks_after_threshold_consecutive_failures() { // Arrange — bypass per-account rate-limit by force-seeding the failure counter // close to the lockout threshold; one more wrong attempt then trips it. // This avoids flooding the audit_events table with 10 failures in 5 min, which // the per-account window would block at the 6th. var email = $"lockout-{Guid.NewGuid():N}@authtest.example.com"; const string correct = "Correct2026!"; await CreateUser(email, correct); try { // Set FailedLoginCount = 9 directly; next failed login crosses the 10-attempt threshold. await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9); using var client = _fixture.CreateApiClient(); using var trip = await client.PostAsync("/login", new { email, password = "wrong-final" }); // Assert — AZ-556 collapses the lockout-trip response into the same // InvalidCredentials shape as a wrong-password rejection, distinguished // only by the Retry-After header. trip.StatusCode.Should().Be(HttpStatusCode.Unauthorized); var err = await trip.Content.ReadFromJsonAsync(ResponseJsonOptions); err!.ErrorCode.Should().Be(70, "InvalidCredentials == 70 (AZ-556)"); trip.Headers.RetryAfter.Should().NotBeNull(); // DB state reflects the lockout var (count, until) = await _fixture.Db.GetLockoutState(email); count.Should().Be(10); until.Should().NotBeNull().And.Subject.Should().BeAfter(DateTime.UtcNow); // Subsequent attempts with the *correct* password also return InvalidCredentials // until the lockout expires. using var locked = await client.PostAsync("/login", new { email, password = correct }); locked.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } [Fact] public async Task AC4_Successful_login_resets_failed_counter() { // Arrange var email = $"reset-{Guid.NewGuid():N}@authtest.example.com"; const string correct = "Correct2026!"; await CreateUser(email, correct); try { // Park the user with 5 prior failures (still below the 5/5min rate-limit // count because we set them via DB, not via /login attempts). await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 5); using var client = _fixture.CreateApiClient(); // Act — correct password now using var ok = await client.PostAsync("/login", new { email, password = correct }); // Assert ok.StatusCode.Should().Be(HttpStatusCode.OK); var (count, until) = await _fixture.Db.GetLockoutState(email); count.Should().Be(0); until.Should().BeNull(); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } [Fact] public async Task AC5_Lockout_expires_after_duration_elapses() { // Arrange — set a lockout that already expired one second ago var email = $"expired-{Guid.NewGuid():N}@authtest.example.com"; const string correct = "Correct2026!"; await CreateUser(email, correct); try { await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: DateTime.UtcNow.AddSeconds(-1), failedCount: 10); using var client = _fixture.CreateApiClient(); // Act — correct password after lockout expiry using var ok = await client.PostAsync("/login", new { email, password = correct }); // Assert ok.StatusCode.Should().Be(HttpStatusCode.OK); var (count, until) = await _fixture.Db.GetLockoutState(email); count.Should().Be(0); until.Should().BeNull(); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } [Fact] public async Task AC6_Lockout_event_is_recorded_in_audit_log() { // Arrange — same setup as AC3 but assert audit_events var email = $"audit-{Guid.NewGuid():N}@authtest.example.com"; const string correct = "Correct2026!"; await CreateUser(email, correct); try { await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9); using var client = _fixture.CreateApiClient(); using var trip = await client.PostAsync("/login", new { email, password = "wrong-final" }); // AZ-556 — same opaque InvalidCredentials response now. trip.StatusCode.Should().Be(HttpStatusCode.Unauthorized); // Act var lockoutCount = await _fixture.Db.CountAuditEvents("login_lockout", email); var failedCount = await _fixture.Db.CountAuditEvents("login_failed", email); // Assert lockoutCount.Should().Be(1, "exactly one login_lockout audit event must be present"); failedCount.Should().BeGreaterOrEqualTo(1, "the failing attempt is also audited"); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } private async Task CreateUser(string email, string password) { using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); var created = await admin.PostAsync("/users", new { email, password, role = 10 }); created.IsSuccessStatusCode.Should().BeTrue($"setup: create user {email}"); } }