import { describe, it, expect, beforeEach } from 'vitest'; import type Database from 'better-sqlite3'; import { initDb } from '../../src/db/schema.ts'; import type { Config } from '../../src/config.ts'; import { createLockoutService } from '../../src/services/lockout.ts'; import { recordFailure as dbRecordFailure } from '../../src/db/login-attempts.ts'; function makeConfig(overrides: Partial = {}): Config { return { port: 0, host: '127.0.0.1', jwtSecret: 'x', dbPath: ':memory:', uploadDir: '/tmp', logFile: '/tmp/x.log', maxFileSize: 0, baseUrl: '', cookieSecure: false, trustProxy: false, lockoutThreshold: 3, lockoutBaseSeconds: 10, lockoutMaxSeconds: 80, loginMinResponseMs: 0, loginRateLimitMax: 0, loginRateLimitWindowSeconds: 0, ...overrides, }; } describe('lockout service', () => { let db: Database.Database; let nowMs: number; const now = (): Date => new Date(nowMs); beforeEach(() => { db = initDb(':memory:'); nowMs = Date.UTC(2026, 0, 1, 0, 0, 0); }); describe('check', () => { it('returns not-locked when no row exists', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); expect(svc.check('alice')).toEqual({ locked: false }); }); it('returns not-locked when locked_until is in the past', () => { const past = new Date(nowMs - 1000).toISOString(); dbRecordFailure(db, 'alice', past); const svc = createLockoutService({ db, config: makeConfig(), now }); expect(svc.check('alice')).toEqual({ locked: false }); }); it('returns locked with retry-after seconds when locked_until is in the future', () => { const future = new Date(nowMs + 30_000).toISOString(); dbRecordFailure(db, 'alice', future); const svc = createLockoutService({ db, config: makeConfig(), now }); expect(svc.check('alice')).toEqual({ locked: true, retryAfterSeconds: 30 }); }); it('rounds up sub-second remainder so retry-after is never 0', () => { const future = new Date(nowMs + 100).toISOString(); dbRecordFailure(db, 'alice', future); const svc = createLockoutService({ db, config: makeConfig(), now }); expect(svc.check('alice').retryAfterSeconds).toBe(1); }); }); describe('recordFailure', () => { it('does not lock under threshold', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); const r1 = svc.recordFailure('alice'); expect(r1).toEqual({ locked: false, durationSeconds: 0, failedCount: 1 }); const r2 = svc.recordFailure('alice'); expect(r2).toEqual({ locked: false, durationSeconds: 0, failedCount: 2 }); }); it('locks with base duration at threshold', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); svc.recordFailure('alice'); svc.recordFailure('alice'); const r3 = svc.recordFailure('alice'); expect(r3.locked).toBe(true); expect(r3.durationSeconds).toBe(10); expect(r3.failedCount).toBe(3); }); it('doubles duration past threshold', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); svc.recordFailure('alice'); // 1 svc.recordFailure('alice'); // 2 expect(svc.recordFailure('alice').durationSeconds).toBe(10); // 3 -> base expect(svc.recordFailure('alice').durationSeconds).toBe(20); // 4 expect(svc.recordFailure('alice').durationSeconds).toBe(40); // 5 expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 6 -> cap expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 7 -> still cap }); it('persists locked_until reachable via check', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); svc.recordFailure('alice'); svc.recordFailure('alice'); svc.recordFailure('alice'); const status = svc.check('alice'); expect(status.locked).toBe(true); expect(status.retryAfterSeconds).toBe(10); }); }); describe('recordSuccess', () => { it('clears the attempt row', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); svc.recordFailure('alice'); svc.recordFailure('alice'); svc.recordSuccess('alice'); // Next failure starts at 1, no lock const r = svc.recordFailure('alice'); expect(r.failedCount).toBe(1); expect(r.locked).toBe(false); }); it('is a no-op for unknown username', () => { const svc = createLockoutService({ db, config: makeConfig(), now }); expect(() => svc.recordSuccess('ghost')).not.toThrow(); }); }); });