Files
website/server/test/loginProtection.test.js
wtclaude ec2b530be7
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 8m57s
test(login): stop the backoff-guard test racing its own one-second lock
A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and
the test then does a real HTTP round trip against it. On CI that round trip took
1,456 ms and the guard correctly answered 200, failing the run for a reason that
has nothing to do with what the test is about.

Five failures lock for sixteen seconds. The subject is the guard's answer while
locked out, which is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 08:16:59 -05:00

103 lines
3.7 KiB
JavaScript

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 {
// Lock the test client IP. FIVE failures, not one: the lock is
// `BASE_MS * 2 ** (count - 1)`, so a single failure locks for exactly one
// second and this test then races the round trip. It lost that race on CI
// (200 instead of 429, request arriving 1,456 ms after the lock). Five
// failures lock for sixteen seconds, which is not a race. What is under
// test is the guard's ANSWER while locked out, and that is unchanged.
for (let i = 0; i < 5; i += 1) lp.recordFailure('203.0.113.40')
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()
}
})