217 lines
8.1 KiB
JavaScript
217 lines
8.1 KiB
JavaScript
|
|
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`);
|
||
|
|
}
|
||
|
|
});
|