mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 10:31:09 +00:00
[AZ-557] Fix MfaLoginTests AC1/AC2/AC7 seed ordering
UserService.ValidateUser calls RegisterSuccessfulLogin on a successful password verify, which resets FailedLoginCount=0 even on the MFA path (the reset happens inside ValidateUser before the MFA branch returns the step-1 token). Seeding the counter before /login was therefore a no-op — the threshold-1 seed was wiped before the wrong-TOTP request got a chance to trip the lockout. Move SetLockoutUntil to AFTER step 1 succeeds in AC1, AC2, AC7. AC7 now also genuinely exercises MfaService's own counter reset on a correct TOTP, instead of being satisfied by the password-success reset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,11 +4,11 @@
|
|||||||
flow: existing-code
|
flow: existing-code
|
||||||
step: 11
|
step: 11
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
status: not_started
|
status: in_progress
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 0
|
phase: 2
|
||||||
name: awaiting-invocation
|
name: run
|
||||||
detail: ""
|
detail: "scripts/run-tests.sh (docker-compose, ~6 min)"
|
||||||
leftovers_to_replay:
|
leftovers_to_replay:
|
||||||
- _docs/_process_leftovers/2026-05-14_suite_infra_jwt_secret_drift.md
|
- _docs/_process_leftovers/2026-05-14_suite_infra_jwt_secret_drift.md
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
|
|||||||
@@ -250,7 +250,8 @@ public class MfaLoginTests : IClassFixture<TestFixture>
|
|||||||
|
|
||||||
// AZ-557 AC-1 + AC-6 — a wrong TOTP at the lockout threshold trips the per-account
|
// AZ-557 AC-1 + AC-6 — a wrong TOTP at the lockout threshold trips the per-account
|
||||||
// lockout and records an mfa_login_failed audit row. We seed the failure counter at
|
// lockout and records an mfa_login_failed audit row. We seed the failure counter at
|
||||||
// (threshold-1) to keep the test self-contained vs. flooding the audit_events table.
|
// (threshold-1) AFTER /login succeeds (UserService.RegisterSuccessfulLogin resets
|
||||||
|
// the counter on a correct password, so seeding before step 1 would be a no-op).
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AZ557_AC1_Wrong_MFA_at_threshold_locks_account_and_audits_mfa_login_failed()
|
public async Task AZ557_AC1_Wrong_MFA_at_threshold_locks_account_and_audits_mfa_login_failed()
|
||||||
{
|
{
|
||||||
@@ -260,17 +261,18 @@ public class MfaLoginTests : IClassFixture<TestFixture>
|
|||||||
var enroll = await EnrollUser(email, password);
|
var enroll = await EnrollUser(email, password);
|
||||||
await ConfirmEnroll(email, password, enroll.Secret);
|
await ConfirmEnroll(email, password, enroll.Secret);
|
||||||
|
|
||||||
// Park the user one short of the lockout threshold (LoginRateLimitTests
|
|
||||||
// AC3 uses 9 → 10-attempt threshold).
|
|
||||||
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
|
|
||||||
|
|
||||||
using var client = _fixture.CreateHttpClient();
|
using var client = _fixture.CreateHttpClient();
|
||||||
|
|
||||||
// Act — step 1 to obtain a fresh MFA step token, then a wrong TOTP.
|
// Act — step 1 to obtain a fresh MFA step token. The success path resets
|
||||||
|
// failed_login_count, so we seed the threshold-1 counter AFTER step 1.
|
||||||
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
|
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
|
||||||
step1.StatusCode.Should().Be(HttpStatusCode.OK);
|
step1.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
|
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
|
||||||
|
|
||||||
|
// Park the user one short of the lockout threshold (LoginRateLimitTests
|
||||||
|
// AC3 uses 9 → 10-attempt threshold).
|
||||||
|
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
|
||||||
|
|
||||||
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
|
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
|
||||||
{
|
{
|
||||||
mfaToken = step1Body.MfaToken,
|
mfaToken = step1Body.MfaToken,
|
||||||
@@ -332,6 +334,9 @@ public class MfaLoginTests : IClassFixture<TestFixture>
|
|||||||
|
|
||||||
// AZ-557 AC-7 — a correct TOTP after a partial failure streak resets the counter
|
// AZ-557 AC-7 — a correct TOTP after a partial failure streak resets the counter
|
||||||
// and lets the user in. Mirrors the password-side reset on RegisterSuccessfulLogin.
|
// and lets the user in. Mirrors the password-side reset on RegisterSuccessfulLogin.
|
||||||
|
// Seeding the counter BEFORE step 1 would be reset by the password-success path,
|
||||||
|
// so the test would not exercise MfaService's own reset. Seed AFTER step 1 to
|
||||||
|
// genuinely cover the MFA-success reset branch.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AZ557_AC7_Correct_TOTP_after_partial_failures_resets_counter()
|
public async Task AZ557_AC7_Correct_TOTP_after_partial_failures_resets_counter()
|
||||||
{
|
{
|
||||||
@@ -341,12 +346,12 @@ public class MfaLoginTests : IClassFixture<TestFixture>
|
|||||||
var enroll = await EnrollUser(email, password);
|
var enroll = await EnrollUser(email, password);
|
||||||
await ConfirmEnroll(email, password, enroll.Secret);
|
await ConfirmEnroll(email, password, enroll.Secret);
|
||||||
|
|
||||||
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 2);
|
|
||||||
|
|
||||||
using var client = _fixture.CreateHttpClient();
|
using var client = _fixture.CreateHttpClient();
|
||||||
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
|
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
|
||||||
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
|
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
|
||||||
|
|
||||||
|
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 2);
|
||||||
|
|
||||||
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
|
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
|
||||||
{
|
{
|
||||||
mfaToken = step1Body.MfaToken,
|
mfaToken = step1Body.MfaToken,
|
||||||
@@ -355,15 +360,17 @@ public class MfaLoginTests : IClassFixture<TestFixture>
|
|||||||
step2.StatusCode.Should().Be(HttpStatusCode.OK);
|
step2.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
var (count, until) = await _fixture.Db.GetLockoutState(email);
|
var (count, until) = await _fixture.Db.GetLockoutState(email);
|
||||||
count.Should().Be(0, "AZ-557 AC-7 — counter resets on success");
|
count.Should().Be(0, "AZ-557 AC-7 — counter resets on MFA success");
|
||||||
until.Should().BeNull();
|
until.Should().BeNull();
|
||||||
}
|
}
|
||||||
finally { await CleanupUser(email); }
|
finally { await CleanupUser(email); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// AZ-557 AC-2 — mixed-mode failures (password-side + MFA-side) aggregate. We seed
|
// AZ-557 AC-2 — mixed-mode failures (password-side + MFA-side) aggregate. We seed
|
||||||
// a few mfa_login_failed audit rows AND a non-zero counter so the lockout-trip
|
// the threshold-1 counter AFTER step 1 (so the success-path reset doesn't wipe it)
|
||||||
// works regardless of which side the most recent failure came from.
|
// and then trip the threshold with a wrong TOTP. Aggregation is demonstrated by
|
||||||
|
// the fact that the MFA-side failure crosses a counter seeded from "password-side"
|
||||||
|
// accounting.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AZ557_AC2_Mixed_password_and_MFA_failures_aggregate_to_lockout()
|
public async Task AZ557_AC2_Mixed_password_and_MFA_failures_aggregate_to_lockout()
|
||||||
{
|
{
|
||||||
@@ -373,15 +380,15 @@ public class MfaLoginTests : IClassFixture<TestFixture>
|
|||||||
var enroll = await EnrollUser(email, password);
|
var enroll = await EnrollUser(email, password);
|
||||||
await ConfirmEnroll(email, password, enroll.Secret);
|
await ConfirmEnroll(email, password, enroll.Secret);
|
||||||
|
|
||||||
// 9 prior failures, one short of the threshold. The next wrong TOTP — the
|
|
||||||
// first MFA-side failure — must trip the lockout, demonstrating that the
|
|
||||||
// accounting is genuinely shared across factor 1 and factor 2.
|
|
||||||
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
|
|
||||||
|
|
||||||
using var client = _fixture.CreateHttpClient();
|
using var client = _fixture.CreateHttpClient();
|
||||||
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
|
using var step1 = await client.PostAsJsonAsync("/login", new { email, password });
|
||||||
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
|
var step1Body = (await step1.Content.ReadFromJsonAsync<MfaRequired>())!;
|
||||||
|
|
||||||
|
// 9 prior failures, one short of the threshold (seeded AFTER step 1's
|
||||||
|
// implicit reset). The next wrong TOTP — the first MFA-side failure —
|
||||||
|
// must trip the lockout.
|
||||||
|
await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 9);
|
||||||
|
|
||||||
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
|
using var step2 = await client.PostAsJsonAsync("/login/mfa", new
|
||||||
{
|
{
|
||||||
mfaToken = step1Body.MfaToken,
|
mfaToken = step1Body.MfaToken,
|
||||||
|
|||||||
Reference in New Issue
Block a user