Files
scan-receipts/tests/e2e/lockout_security.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

230 lines
8.6 KiB
TypeScript

/**
* 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<number, number> = { 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<number, number> = { 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);
});