/** * Lockout Security Suite * * Pure-logic tests for the progressive-delay lockout tracker * (`src/lib/auth/lockout.ts`): escalating tiers, reset on success, * sweep/eviction, the injectable clock, and the guarantee that no amount of * failures ever produces a permanent lockout. No database required. * * Self-executing: run with `node --import tsx tests/e2e/lockout_security.test.ts`. */ import { describe, test, expect, runAllTests } from "./runner"; import { createLockoutTracker, delaySecondsForFailures, MAX_LOCKOUT_SECONDS, } from "../../src/lib/auth/lockout"; describe("Lockout — escalating tiers", () => { test("the first four failures never lock the key", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); for (let failures = 1; failures <= 4; failures += 1) { const state = tracker.recordFailure("email:a@b.de"); expect(state.locked).toBe(false); expect(state.retryAfterSeconds).toBe(0); expect(state.failures).toBe(failures); } }); test("the fifth failure escalates to a 30-second delay", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); for (let i = 0; i < 4; i += 1) tracker.recordFailure("email:a@b.de"); const state = tracker.recordFailure("email:a@b.de"); expect(state.locked).toBe(true); expect(state.retryAfterSeconds).toBe(30); expect(state.failures).toBe(5); }); test("later tiers escalate to 2 minutes, 15 minutes and a 1-hour cap", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); const expected: Record = { 8: 120, 11: 900, 15: 3600, 50: 3600 }; for (let failures = 1; failures <= 50; failures += 1) { const state = tracker.recordFailure("ip:203.0.113.7"); if (failures in expected) { expect(state.locked).toBe(true); expect(state.retryAfterSeconds).toBe(expected[failures]); } } }); test("tier boundaries hold: 7 stays 30s, 10 stays 120s, 14 stays 900s", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); const boundaries: Record = { 7: 30, 10: 120, 14: 900 }; for (let failures = 1; failures <= 14; failures += 1) { const state = tracker.recordFailure("email:a@b.de"); if (failures in boundaries) { expect(state.retryAfterSeconds).toBe(boundaries[failures]); } } }); }); describe("Lockout — reset on success", () => { test("a successful login clears the counter and any pending delay", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); for (let i = 0; i < 15; i += 1) tracker.recordFailure("email:a@b.de"); expect(tracker.checkLockout("email:a@b.de").locked).toBe(true); tracker.recordSuccess("email:a@b.de"); const after = tracker.checkLockout("email:a@b.de"); expect(after.locked).toBe(false); expect(after.failures).toBe(0); // No escalation memory: the next failure starts over from one. expect(tracker.recordFailure("email:a@b.de").failures).toBe(1); }); test("resetting one key leaves the other key untouched", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); for (let i = 0; i < 8; i += 1) { tracker.recordFailure("email:a@b.de"); tracker.recordFailure("ip:203.0.113.7"); } tracker.recordSuccess("email:a@b.de"); expect(tracker.checkLockout("email:a@b.de").locked).toBe(false); expect(tracker.checkLockout("ip:203.0.113.7").locked).toBe(true); }); }); describe("Lockout — injectable clock and delay expiry", () => { test("the delay counts down and releases after the full window", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); for (let i = 0; i < 5; i += 1) tracker.recordFailure("email:a@b.de"); // locked for 30s clock.value += 29_000; const nearly = tracker.checkLockout("email:a@b.de"); expect(nearly.locked).toBe(true); expect(nearly.retryAfterSeconds).toBe(1); clock.value += 1_000; expect(tracker.checkLockout("email:a@b.de").locked).toBe(false); }); test("an unknown key checks as unlocked with zero failures", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); const state = tracker.checkLockout("email:never-touched@b.de"); expect(state.locked).toBe(false); expect(state.retryAfterSeconds).toBe(0); expect(state.failures).toBe(0); }); }); describe("Lockout — per-key isolation", () => { test("email and ip counters escalate independently", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); for (let i = 0; i < 8; i += 1) tracker.recordFailure("email:a@b.de"); for (let i = 0; i < 2; i += 1) tracker.recordFailure("ip:203.0.113.9"); const email = tracker.checkLockout("email:a@b.de"); expect(email.locked).toBe(true); expect(email.retryAfterSeconds).toBe(120); const ip = tracker.checkLockout("ip:203.0.113.9"); expect(ip.locked).toBe(false); expect(ip.failures).toBe(2); // A different IP is completely unaffected. expect(tracker.checkLockout("ip:203.0.113.5").failures).toBe(0); }); }); describe("Lockout — no permanent lockout (DoS safety)", () => { test("even 100 failures cap at one hour, never more", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); let state = tracker.recordFailure("email:a@b.de"); for (let i = 1; i < 100; i += 1) state = tracker.recordFailure("email:a@b.de"); expect(state.failures).toBe(100); expect(state.locked).toBe(true); expect(state.retryAfterSeconds).toBe(3600); // The delay always releases: after an hour the account is usable again. clock.value += 3_600_000; const after = tracker.checkLockout("email:a@b.de"); expect(after.locked).toBe(false); expect(after.failures).toBe(100); }); test("the cap constant matches the final tier for any failure count", () => { expect(MAX_LOCKOUT_SECONDS).toBe(3600); expect(delaySecondsForFailures(15)).toBe(3600); expect(delaySecondsForFailures(1000)).toBe(3600); expect(delaySecondsForFailures(0)).toBe(0); }); }); describe("Lockout — sweep bounds the map", () => { test("stale unlocked entries are evicted once the map grows", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value, maxEntries: 5, staleMs: 0, }); for (let i = 0; i < 5; i += 1) tracker.recordFailure(`email:user${i}@b.de`); expect(tracker.size()).toBe(5); // All five carry a single failure (no lock) and are instantly stale, so the // next insert triggers a sweep that evicts them. tracker.recordFailure("email:new@b.de"); expect(tracker.size()).toBe(1); expect(tracker.checkLockout("email:new@b.de").failures).toBe(1); expect(tracker.checkLockout("email:user0@b.de").failures).toBe(0); }); test("still-locked entries survive a sweep", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value, maxEntries: 3, staleMs: 1, }); for (let i = 0; i < 3; i += 1) { for (let f = 0; f < 5; f += 1) tracker.recordFailure(`email:user${i}@b.de`); } expect(tracker.size()).toBe(3); // Fast-forward past the idle threshold so any unlocked entry would be // evictable — all three here are mid-delay, so the sweep triggered by the // next insert must keep them. An attacker's escalation is never silently // forgotten while its delay is still running. clock.value += 2; tracker.recordFailure("email:new@b.de"); expect(tracker.size()).toBe(4); expect(tracker.checkLockout("email:user0@b.de").locked).toBe(true); expect(tracker.checkLockout("email:user1@b.de").locked).toBe(true); expect(tracker.checkLockout("email:user2@b.de").locked).toBe(true); }); test("clear empties the tracker", () => { const clock = { value: 0 }; const tracker = createLockoutTracker({ now: () => clock.value }); tracker.recordFailure("email:a@b.de"); tracker.recordFailure("ip:203.0.113.7"); tracker.clear(); expect(tracker.size()).toBe(0); expect(tracker.checkLockout("email:a@b.de").failures).toBe(0); }); }); async function main() { const passed = await runAllTests(); if (!passed) process.exit(1); } main().catch((error) => { console.error("Lockout suite crashed:", error); process.exit(1); });