import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { readFileSync } from 'fs'; import { createTestApp, type TestContext } from '../helpers/setup.ts'; import { createUser } from '../../src/db/users.ts'; import { hashPassword } from '../../src/services/auth.ts'; async function attempt( ctx: TestContext, username: string, password: string, ip = '203.0.113.7', ) { return ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', headers: { 'content-type': 'application/json', 'x-forwarded-for': ip }, body: JSON.stringify({ username, password }), }); } describe('account lockout — JSON login', () => { let ctx: TestContext; beforeEach(async () => { ctx = createTestApp({ lockoutThreshold: 3, lockoutBaseSeconds: 60, loginMinResponseMs: 0, loginRateLimitMax: 1000, // effectively off for these cases }); const hash = await hashPassword('correct-pw'); createUser(ctx.db, { username: 'alice', passwordHash: hash }); }); afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); it('locks after threshold failed attempts and emits AUTH_LOCKOUT_TRIGGERED', async () => { await attempt(ctx, 'alice', 'wrong'); await attempt(ctx, 'alice', 'wrong'); const third = await attempt(ctx, 'alice', 'wrong'); expect(third.statusCode).toBe(401); const log = readFileSync(ctx.logFile, 'utf-8'); expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/); expect(log).toMatch(/duration_seconds=60/); }); it('rejects correct password while locked with Retry-After header', async () => { await attempt(ctx, 'alice', 'wrong'); await attempt(ctx, 'alice', 'wrong'); await attempt(ctx, 'alice', 'wrong'); const blocked = await attempt(ctx, 'alice', 'correct-pw'); expect(blocked.statusCode).toBe(401); expect(blocked.headers['retry-after']).toBeDefined(); expect(parseInt(String(blocked.headers['retry-after']), 10)).toBeGreaterThan(0); // No success log was written for the locked attempt const log = readFileSync(ctx.logFile, 'utf-8'); expect(log).not.toMatch(/AUTH_SUCCESS/); }); it('successful login resets the counter', async () => { await attempt(ctx, 'alice', 'wrong'); await attempt(ctx, 'alice', 'wrong'); const ok = await attempt(ctx, 'alice', 'correct-pw'); expect(ok.statusCode).toBe(200); // After reset, two more wrong attempts should NOT lock (threshold is 3) await attempt(ctx, 'alice', 'wrong'); const second = await attempt(ctx, 'alice', 'wrong'); expect(second.statusCode).toBe(401); expect(second.headers['retry-after']).toBeUndefined(); }); it('canonicalizes username — ALICE and alice share the same lockout row', async () => { await attempt(ctx, 'ALICE', 'wrong'); await attempt(ctx, 'Alice', 'wrong'); const third = await attempt(ctx, 'alice', 'wrong'); expect(third.statusCode).toBe(401); const log = readFileSync(ctx.logFile, 'utf-8'); expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/); }); it('unknown user accumulates failures (no enumeration via bypass)', async () => { await attempt(ctx, 'ghost', 'x'); await attempt(ctx, 'ghost', 'x'); await attempt(ctx, 'ghost', 'x'); const fourth = await attempt(ctx, 'ghost', 'x'); expect(fourth.statusCode).toBe(401); expect(fourth.headers['retry-after']).toBeDefined(); }); }); describe('account lockout — form login', () => { let ctx: TestContext; beforeEach(async () => { ctx = createTestApp({ lockoutThreshold: 2, lockoutBaseSeconds: 30, loginMinResponseMs: 0, loginRateLimitMax: 1000, }); const hash = await hashPassword('correct-pw'); createUser(ctx.db, { username: 'alice', passwordHash: hash }); }); afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); async function formAttempt(username: string, password: string) { return ctx.app.inject({ method: 'POST', url: '/login', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, }); } it('locks the form login and renders generic error with Retry-After', async () => { await formAttempt('alice', 'wrong'); await formAttempt('alice', 'wrong'); const blocked = await formAttempt('alice', 'correct-pw'); expect(blocked.statusCode).toBe(200); // login page re-render, not redirect expect(blocked.body).toContain('Invalid username or password'); expect(blocked.headers['retry-after']).toBeDefined(); }); });