import { describe, it, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { mock } from "node:test"; import { checkLoginGuard, clearLoginAttempts, recordLoginFailure, resetLoginGuardForTests, getLoginGuardSizeForTests, LOGIN_GUARD_TUNABLES, } from "../../../src/server/auth/loginGuard"; describe("loginGuard", () => { beforeEach(() => { resetLoginGuardForTests(); }); it("is a no-op when bruteForceProtection is disabled", () => { for (let i = 0; i < 20; i++) { const decision = recordLoginFailure("1.2.3.4", { enabled: false }); assert.equal(decision.allowed, true); } assert.equal(checkLoginGuard("1.2.3.4", { enabled: false }).allowed, true); }); it("allows the first attempts up to threshold-1, locks on the threshold hit", () => { const ip = "10.0.0.1"; for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1; i++) { const dec = recordLoginFailure(ip, { enabled: true }); assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`); } const lockingHit = recordLoginFailure(ip, { enabled: true }); assert.equal(lockingHit.allowed, false); assert.ok((lockingHit.retryAfterSeconds || 0) > 0); const subsequent = checkLoginGuard(ip, { enabled: true }); assert.equal(subsequent.allowed, false); assert.ok((subsequent.retryAfterSeconds || 0) > 0); }); it("scopes lockouts per IP", () => { const ipA = "10.0.0.1"; const ipB = "10.0.0.2"; for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { recordLoginFailure(ipA, { enabled: true }); } assert.equal(checkLoginGuard(ipA, { enabled: true }).allowed, false); assert.equal(checkLoginGuard(ipB, { enabled: true }).allowed, true); }); it("clearLoginAttempts releases the lock for that IP only", () => { const ip = "10.0.0.7"; for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { recordLoginFailure(ip, { enabled: true }); } assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, false); clearLoginAttempts(ip); assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, true); }); it("treats null/undefined ip as a single bucket", () => { for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { recordLoginFailure(null, { enabled: true }); } assert.equal(checkLoginGuard(undefined, { enabled: true }).allowed, false); }); it("prunes expired, unlocked entries so the attempts map does not grow without bound", () => { mock.timers.enable({ apis: ["Date"] }); try { // Many distinct IPs each fail once (single, unlocked attempts). for (let i = 0; i < 300; i++) { recordLoginFailure(`9.${Math.floor(i / 256)}.${i % 256}.1`, { enabled: true }); } assert.ok(getLoginGuardSizeForTests() > 256, "entries should accumulate before pruning"); // Advance past the sliding window so all those entries are expired + unlocked. mock.timers.tick(LOGIN_GUARD_TUNABLES.WINDOW_MS + 1000); // The next failure (map size > threshold) triggers the opportunistic prune. recordLoginFailure("1.1.1.1", { enabled: true }); // Only the fresh entry should remain; the stale ones were reaped. assert.equal(getLoginGuardSizeForTests(), 1); } finally { mock.timers.reset(); } }); it("never prunes a still-locked entry", () => { mock.timers.enable({ apis: ["Date"] }); try { const lockedIp = "5.5.5.5"; for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { recordLoginFailure(lockedIp, { enabled: true }); } assert.equal(checkLoginGuard(lockedIp, { enabled: true }).allowed, false); // Fill past the prune threshold with unlocked entries, then trigger a prune // while still inside the lockout window. for (let i = 0; i < 300; i++) { recordLoginFailure(`8.${Math.floor(i / 256)}.${i % 256}.1`, { enabled: true }); } recordLoginFailure("2.2.2.2", { enabled: true }); // The locked IP must still be locked (not reaped). assert.equal(checkLoginGuard(lockedIp, { enabled: true }).allowed, false); } finally { mock.timers.reset(); } }); });