const { test, beforeEach } = require('node:test') const assert = require('node:assert/strict') const lp = require('../src/middleware/loginProtection') const { loginLimiter } = require('../src/middleware/rateLimit') const { startApp } = require('./_helper') beforeEach(() => lp._reset()) test('recordFailure escalates the lockout exponentially', () => { const ip = '198.51.100.50' const now = 1_000_000 const first = lp.recordFailure(ip, now) const second = lp.recordFailure(ip, now) const third = lp.recordFailure(ip, now) assert.equal(first, lp.BASE_MS) // 2^0 assert.equal(second, lp.BASE_MS * 2) // 2^1 assert.equal(third, lp.BASE_MS * 4) // 2^2 }) test('lockout is capped at MAX_MS', () => { const ip = '198.51.100.51' const now = 1_000_000 let last = 0 for (let i = 0; i < 40; i++) last = lp.recordFailure(ip, now) assert.equal(last, lp.MAX_MS) }) test('retryAfterMs reflects the active lockout and clears after it elapses', () => { const ip = '198.51.100.52' const now = 2_000_000 lp.recordFailure(ip, now) // locks BASE_MS assert.ok(lp.retryAfterMs(ip, now) > 0) assert.equal(lp.retryAfterMs(ip, now + lp.BASE_MS + 1), 0) }) test('recordSuccess clears the failure streak', () => { const ip = '198.51.100.53' const now = 2_000_000 lp.recordFailure(ip, now) lp.recordSuccess(ip) assert.equal(lp.retryAfterMs(ip, now), 0) }) test('streak resets after a long quiet period (does not grow forever)', () => { const ip = '198.51.100.54' const t0 = 3_000_000 lp.recordFailure(ip, t0) lp.recordFailure(ip, t0) // Come back after RESET_MS+ of quiet: next failure starts the streak over. const later = t0 + lp.RESET_MS + 1 const delay = lp.recordFailure(ip, later) assert.equal(delay, lp.BASE_MS) // back to 2^0 }) test('backoffGuard returns a generic 429 while locked out', async () => { // Pre-lock this IP, then confirm the guard blocks it with a generic message. const app = await startApp((a) => { a.set('trust proxy', 1) a.post('/login', lp.backoffGuard, (req, res) => res.json({ ok: true })) }) try { lp.recordFailure('203.0.113.40') // lock the test client IP const res = await fetch(`${app.url}/login`, { method: 'POST', headers: { 'X-Forwarded-For': '203.0.113.40' }, }) assert.equal(res.status, 429) const body = await res.json() assert.match(body.message, /too many login attempts/i) // Message must not reveal whether username or password was the problem. assert.doesNotMatch(body.message, /password|username/i) assert.ok(res.headers.get('retry-after')) } finally { await app.close() } }) test('hard rate limiter caps attempts per IP (429 after the cap)', async () => { const app = await startApp((a) => { a.set('trust proxy', 1) a.post('/login', loginLimiter, (req, res) => res.json({ ok: true })) }) try { const headers = { 'X-Forwarded-For': '203.0.113.41' } let sawLimit = false // Cap is 10/15min; the 11th should be blocked. for (let i = 0; i < 12; i++) { const res = await fetch(`${app.url}/login`, { method: 'POST', headers }) if (res.status === 429) sawLimit = true } assert.ok(sawLimit, 'expected the hard limiter to return 429 after the cap') } finally { await app.close() } })