Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { loadConfig } from './config.js';3import { Client, ORIGIN, PASSWORD, createHarness, setupAdmin, type Harness } from './testkit/harness.js';45let h: Harness;6let admin: Client;78const LIMITS = { api: 30, preAuth: 5, status: 12, health: 40 };9const get = (url: string, remoteAddress: string, cookie?: string) =>10 h.server.app.inject({ method: 'GET', url: `/api/v1${url}`, remoteAddress, headers: cookie ? { cookie } : {} });11const postJson = (url: string, remoteAddress: string) =>12 h.server.app.inject({ method: 'POST', url: `/api/v1${url}`, remoteAddress, headers: { origin: ORIGIN, 'content-type': 'application/json' }, payload: '{}' });13async function statuses(n: number, fn: () => Promise<{ statusCode: number }>): Promise<number[]> {14 const out: number[] = [];15 for (let i = 0; i < n; i++) out.push((await fn()).statusCode);16 return out;17}1819beforeAll(async () => {20 // Generous while the administrator is created, then the limits under test (read per request).21 h = await createHarness();22 ({ client: admin } = await setupAdmin(h));23 Object.assign(h.server.ctx.config.rateLimit, LIMITS);24});25afterAll(async () => {26 await h.close();27});2829describe('rate limit buckets', () => {30 it('defaults: page loads and health checks are not on the login budget', () => {31 const c = loadConfig({ NODE_ENV: 'test', AOS_SECRET_KEY: 'ab'.repeat(32), AOS_DATA_DIR: '/tmp/aos-none', AOS_WORKSPACE_ROOT: '/tmp/ws' });32 expect(c.rateLimit).toEqual({ api: 600, preAuth: 20, status: 240, health: 600 });33 });3435 it('GET /auth/status has its own per-IP bucket, untouched by (and not touching) the credential bucket', async () => {36 const ip = '203.0.113.10';37 // The login budget of this client is exhausted...38 expect(await statuses(7, () => postJson('/auth/passkey/login/options', ip))).toEqual([200, 200, 200, 200, 200, 429, 429]);39 // ...page loads still work, up to their own (much larger) limit.40 const page = await statuses(14, () => get('/auth/status', ip));41 expect(page).toEqual([...Array<number>(12).fill(200), 429, 429]);42 const refused = await get('/auth/status', ip);43 expect(refused.json().error.code).toBe('rate_limited');44 expect(refused.headers['retry-after']).toBeDefined();45 // Another client: its status calls did not consume its login budget either.46 const other = '203.0.113.11';47 expect(await statuses(12, () => get('/auth/status', other))).toEqual(Array<number>(12).fill(200));48 expect(await statuses(5, () => postJson('/auth/passkey/login/options', other))).toEqual(Array<number>(5).fill(200));49 });5051 it('GET /health is throttled separately: neither logins nor page loads can make health checks fail', async () => {52 const ip = '203.0.113.10'; // both other buckets of this client are exhausted (previous test)53 expect((await get('/auth/status', ip)).statusCode).toBe(429);54 expect((await postJson('/auth/login', ip)).statusCode).toBe(429);55 expect(await statuses(40, () => get('/health', ip))).toEqual(Array<number>(40).fill(200));56 expect((await get('/health', ip)).statusCode).toBe(429); // still bounded57 expect((await get('/health', '203.0.113.12')).statusCode).toBe(200);58 });5960 it('an authenticated caller is limited per session by the API limit, wherever it comes from', async () => {61 const cookie = admin.cookieHeader;62 // More than the anonymous status limit, from an address whose anonymous bucket is exhausted.63 const calls = await statuses(20, () => get('/auth/status', '203.0.113.10', cookie));64 expect(calls).toEqual(Array<number>(20).fill(200));65 expect((await get('/auth/status', '203.0.113.10', cookie)).json()).toMatchObject({ authenticated: true });66 // They were counted in the session's API bucket (30/min here, a few already used while the67 // administrator was enrolled): at most 9 calls are left, shared with every other route.68 const rest = await statuses(11, () => get('/settings', '203.0.113.30', cookie));69 const ok = rest.filter((c) => c === 200).length;70 expect(ok).toBeGreaterThan(0);71 expect(ok).toBeLessThanOrEqual(9);72 expect(rest).toEqual([...Array<number>(ok).fill(200), ...Array<number>(11 - ok).fill(429)]);73 expect((await get('/auth/status', '203.0.113.31', cookie)).statusCode).toBe(429);74 });7576 it('the TOTP step of a half-authenticated session is on the strict per-IP budget too', async () => {77 const half = new Client(h); // injects from 127.0.0.178 expect((await half.post('/auth/login', { username: 'admin', password: PASSWORD })).json()).toMatchObject({ next: 'totp' });79 await half.sync();80 let refused = 0;81 for (let i = 0; i < 6 && refused === 0; i++) if ((await half.post('/auth/passkey/login/options')).statusCode === 429) refused++;82 expect(refused).toBe(1); // 5/min for this address, the login above included83 const failures = async (): Promise<number> => (await h.server.ctx.db.q<{ n: number }>('SELECT count(*)::int AS n FROM login_attempts WHERE NOT ok'))[0]?.n ?? -1;84 const before = await failures();85 const res = await half.post('/auth/totp', { code: '000000' });86 expect([res.statusCode, res.json().error.code]).toEqual([429, 'rate_limited']);87 expect(await failures()).toBe(before); // refused before the code was looked at88 });8990 it('a made-up cookie does not buy a fresh bucket', async () => {91 const ip = '203.0.113.40';92 const codes = await statuses(14, () => get('/auth/status', ip, `aos_sid=${'x'.repeat(30)}${Math.random().toString(36).slice(2).padEnd(13, 'y')}`));93 expect(codes).toEqual([...Array<number>(12).fill(200), 429, 429]);94 });95});96