fix(19): CR-04 scope login lockout to username, add TTL auto-expiry + admin-reset unlock
This commit is contained in:
@@ -5,8 +5,9 @@
|
||||
* Test 1: valid username+password → 200 { ok:true } + Set-Cookie for local-session
|
||||
* Test 2: wrong password → 401 { error: 'Invalid credentials' }
|
||||
* Test 3: unknown username → 401 with SAME body as Test 2 (no enumeration / no field discrimination)
|
||||
* Test 4: 5 consecutive failures from one IP → 6th returns 429
|
||||
* Test 4: 5 consecutive failures for one username → 6th returns 429
|
||||
* Test 5: 10 failures → 423 (lockedOut); a cleared map resets the counter
|
||||
* Test 5b (CR-04): a 423 lockout auto-expires after LOCKOUT_TTL_MS (no admin reset needed)
|
||||
* Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie)
|
||||
* Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie
|
||||
* Test 7 (no-echo): malformed body (missing password) → 400 { error: 'Invalid request' };
|
||||
@@ -246,8 +247,9 @@ describe('POST /api/auth/local/login', () => {
|
||||
expect(body.error).toBe('Account locked');
|
||||
|
||||
// Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked)
|
||||
// CR-04: the limiter is keyed on the USERNAME ('alice'), not the IP.
|
||||
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
||||
loginAttempts.delete('10.0.0.2');
|
||||
loginAttempts.delete('alice');
|
||||
|
||||
const resAfterReset = await app.fetch(
|
||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
||||
@@ -255,6 +257,36 @@ describe('POST /api/auth/local/login', () => {
|
||||
expect(resAfterReset.status).toBe(401);
|
||||
});
|
||||
|
||||
it('Test 5b (CR-04): a 423 lockout auto-expires after the TTL — no admin reset needed', async () => {
|
||||
mockCredRow = undefined;
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
// 10 failures → lockout for username 'bob'
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await app.fetch(makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'));
|
||||
}
|
||||
|
||||
// Confirm locked (423)
|
||||
const resLocked = await app.fetch(
|
||||
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
|
||||
);
|
||||
expect(resLocked.status).toBe(423);
|
||||
|
||||
// Simulate the TTL elapsing by back-dating lockedAt well past LOCKOUT_TTL_MS (15 min).
|
||||
const { loginAttempts } = await import('../../src/routes/localAuth.js');
|
||||
const entry = loginAttempts.get('bob');
|
||||
expect(entry?.lockedOut).toBe(true);
|
||||
if (entry) entry.lockedAt = Date.now() - 16 * 60 * 1000;
|
||||
|
||||
// Next attempt: the lockout has expired → handler drops the entry and processes the
|
||||
// login normally, so a wrong password is a fresh 401 (not a 423). CR-04: self-healing.
|
||||
const resAfterTtl = await app.fetch(
|
||||
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
|
||||
);
|
||||
expect(resAfterTtl.status).toBe(401);
|
||||
});
|
||||
|
||||
it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => {
|
||||
const app = await getApp();
|
||||
// Body with username but missing password (Zod will reject)
|
||||
|
||||
Reference in New Issue
Block a user