import { test, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) throw new Error("DATABASE_URL must be set to run these tests"); const pool = new pg.Pool({ connectionString: DATABASE_URL }); const TEST_PASSWORD = "TestPass123!"; // bcrypt cost 10 hash for TEST_PASSWORD (matches other test files in this repo) const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; const createdUserIds = []; const createdUsernames = []; function uniqueIp(suffix) { // Pick a non-loopback IPv4 so the rate-limit middleware applies // (it skips loopback addresses in non-production). const a = (Math.floor(Math.random() * 200) + 11) % 240; // 11..250 const b = Math.floor(Math.random() * 250) + 1; const c = Math.floor(Math.random() * 250) + 1; return `10.${a}.${b}.${c}#${suffix}`.split("#")[0]; } async function createUser(username, isActive = true) { const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Rate Test', 'en', $4) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH, isActive], ); const id = rows[0].id; createdUserIds.push(id); createdUsernames.push(username); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [id], ); return id; } after(async () => { if (createdUserIds.length > 0) { await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = ANY($1::int[])`, [createdUserIds]); await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]); } if (createdUsernames.length > 0) { await pool.query(`DELETE FROM users WHERE username = ANY($1::text[])`, [createdUsernames]); } await pool.end(); }); async function postLogin({ username, password, ip }) { return fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": ip, }, body: JSON.stringify({ username, password }), }); } async function postForgot({ identifier, ip }) { return fetch(`${API_BASE}/api/auth/forgot-password`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": ip, }, body: JSON.stringify({ identifier }), }); } test("login: 11th attempt within window from same IP is throttled with 429", async () => { const username = `rl_login_${STAMP}`; await createUser(username); const ip = uniqueIp("login-ip"); // First 10 with WRONG password from same IP, but with DIFFERENT usernames so // we exercise only the per-IP limiter (default 10/min) and not the // per-username limiter (default 8/15min). for (let i = 0; i < 10; i++) { const r = await postLogin({ username: `rl_login_dummy_${STAMP}_${i}`, password: "wrong", ip, }); assert.equal(r.status, 401, `attempt ${i + 1} should be 401, got ${r.status}`); } // 11th attempt from the same IP must be throttled regardless of payload const blocked = await postLogin({ username, password: TEST_PASSWORD, ip }); assert.equal(blocked.status, 429); const body = await blocked.json(); assert.equal(body.error, "too_many_requests"); }); test("login: per-username limit blocks credential-stuffing across many IPs", async () => { const username = `rl_user_${STAMP}`; await createUser(username); // 8 wrong-password attempts each from a fresh IP — each one trips the // per-username limiter (default max 8) without ever exceeding any single // IP's per-IP budget. for (let i = 0; i < 8; i++) { const r = await postLogin({ username, password: "wrong", ip: uniqueIp(`username-${i}`), }); assert.equal(r.status, 401, `attempt ${i + 1} should be 401, got ${r.status}`); } // 9th attempt from yet another fresh IP must be 429 because the // username-keyed limiter is exhausted. const blocked = await postLogin({ username, password: TEST_PASSWORD, ip: uniqueIp("username-final"), }); assert.equal(blocked.status, 429); }); test("forgot-password: per-IP limit returns 429 after the budget", async () => { const ip = uniqueIp("forgot"); // Default forgot-password budget is 5 per 15 min per IP for (let i = 0; i < 5; i++) { const r = await postForgot({ identifier: `nobody_${STAMP}_${i}`, ip }); assert.equal(r.status, 200, `attempt ${i + 1} should be 200, got ${r.status}`); } const blocked = await postForgot({ identifier: `nobody_${STAMP}_final`, ip }); assert.equal(blocked.status, 429); }); test("register: per-IP limit returns 429 after the budget", async () => { const ip = uniqueIp("register"); // Default register budget is 5 per hour per IP. for (let i = 0; i < 5; i++) { const username = `rl_reg_${STAMP}_${i}`; createdUsernames.push(username); const r = await fetch(`${API_BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": ip }, body: JSON.stringify({ username, email: `${username}@example.com`, password: TEST_PASSWORD, displayNameEn: "Rate Reg", }), }); // Either 201 (created) or 403 (registration closed) is fine — we only // care that the route ran and consumed a slot, not 429. assert.notEqual(r.status, 429, `attempt ${i + 1} unexpectedly throttled`); } const blocked = await fetch(`${API_BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": ip }, body: JSON.stringify({ username: `rl_reg_${STAMP}_final`, email: `rl_reg_${STAMP}_final@example.com`, password: TEST_PASSWORD, displayNameEn: "Rate Reg", }), }); assert.equal(blocked.status, 429); }); test("reset-password (and verify) share a per-IP limit returning 429 after the budget", async () => { const ip = uniqueIp("reset"); // Default reset budget is 10 per 15 min per IP. Mix verify + reset hits to // confirm both endpoints draw from the same per-IP bucket. for (let i = 0; i < 10; i++) { const path = i % 2 === 0 ? "/api/auth/reset-password/verify" : "/api/auth/reset-password"; const body = i % 2 === 0 ? { token: `dummy_${i}` } : { token: `dummy_${i}`, newPassword: TEST_PASSWORD }; const r = await fetch(`${API_BASE}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": ip }, body: JSON.stringify(body), }); // Verify returns 200 with valid:false; reset returns 400 invalid token. assert.notEqual(r.status, 429, `attempt ${i + 1} on ${path} unexpectedly throttled`); } const blocked = await fetch(`${API_BASE}/api/auth/reset-password`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": ip }, body: JSON.stringify({ token: "dummy_final", newPassword: TEST_PASSWORD }), }); assert.equal(blocked.status, 429); }); test("loopback (no X-Forwarded-For) is exempt in non-production so existing tests don't trip", async () => { // We've just exhausted several limiters from synthetic IPs above. From // loopback (the default for direct localhost requests), more attempts // should still be processed normally — proving the dev-mode skip works. const username = `rl_loop_${STAMP}`; await createUser(username); // Hammer loopback well past every per-IP budget (10/min login). for (let i = 0; i < 15; i++) { const r = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: `__nope_${STAMP}_${i}`, password: "x" }), }); // Either 401 (no such user) or 400 (validation) — never 429. assert.notEqual(r.status, 429, `loopback attempt ${i + 1} unexpectedly throttled`); } });