mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The repo had no automated tests. Every change was verified by hand. Adds four layers, 2023 tests in total, runnable with one command: - 1191 unit tests over the pure logic in lib/, including the full computeProjectAccess permission matrix and the billing gate - 167 component and hook tests in jsdom, covering the hooks that hold real logic rather than presentational wrappers - 647 API integration tests against a real Postgres, with only auth() mocked, including a data-driven sweep asserting that none of the 60 route modules answers 2xx to an unauthenticated caller - 18 Playwright specs driving a real browser against a real build Infrastructure: vitest.config.ts with three projects, a disposable Postgres and MinIO in docker-compose.test.yml, factories and helpers under tests/, scripts/test.sh as the single entry point, a pre-push hook running bun run verify, and CI split into check, test and e2e jobs. The test database is built with prisma db push plus a replay of the hand-written SQL, because prisma migrate deploy cannot build this schema from empty: the migration history has no captured baseline. This mirrors what scripts/docker-db-bootstrap.ts already does in production, and tests/setup/db-global.ts carries a drift guard so a new migration fails the run until someone reviews it. Production code is unchanged apart from one pure-function extraction out of use-video-player.ts, which was too large to test in jsdom. Several tests pin behaviour that looks wrong, each marked KNOWN BUG in place. TESTING.md section 12 records where the plan turned out to be wrong, and AGENTS.md now states which layer a change needs a test in.
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
// Exercises the DB-backed rate limiter against real Postgres.
|
|
//
|
|
// The counter lives in an UNLOGGED `rate_limits` table and is maintained by a
|
|
// single INSERT ... ON CONFLICT DO UPDATE whose CASE arms decide between
|
|
// "increment inside the window" and "start a new window". Neither that SQL nor
|
|
// `cleanup_rate_limits()` (a plpgsql function that only exists because
|
|
// tests/setup/db-global.ts replays it) can be checked without a database.
|
|
//
|
|
// .env.test sets DISABLE_RATE_LIMIT=true so that one test file cannot exhaust a
|
|
// window for the next one. isRateLimitDisabled() reads the variable on every
|
|
// call rather than at import, so re-enabling it per test is enough, and
|
|
// tests/setup/api.ts undoes the stub afterwards.
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
RATE_LIMIT_CONFIGS,
|
|
checkRateLimit,
|
|
cleanupRateLimits,
|
|
getClientIp,
|
|
rateLimit,
|
|
rateLimitHeaders,
|
|
} from '@/lib/rate-limit';
|
|
import { POST as postComment } from '@/app/api/versions/[versionId]/comments/route';
|
|
import { apiRequest, callRoute } from '../helpers/request';
|
|
import { signedInAs } from '../helpers/session';
|
|
import { countRows } from '../helpers/db';
|
|
import { seedVersion } from '../factories';
|
|
|
|
const CONFIG = { windowMs: 60_000, maxRequests: 3 };
|
|
|
|
function enableRateLimiting(): void {
|
|
vi.stubEnv('DISABLE_RATE_LIMIT', 'false');
|
|
}
|
|
|
|
describe('RATE_LIMIT_CONFIGS', () => {
|
|
it('gives every action a positive window and a positive maximum', () => {
|
|
const entries = Object.entries(RATE_LIMIT_CONFIGS);
|
|
|
|
expect(entries.length).toBeGreaterThan(0);
|
|
for (const [action, config] of entries) {
|
|
expect(config.windowMs, `${action} windowMs`).toBeGreaterThan(0);
|
|
expect(config.maxRequests, `${action} maxRequests`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('checkRateLimit while disabled', () => {
|
|
it('allows everything and writes no rows', async () => {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const result = await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.remaining).toBe(CONFIG.maxRequests);
|
|
}
|
|
|
|
expect(await countRows('rate_limits')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('checkRateLimit', () => {
|
|
beforeEach(() => {
|
|
enableRateLimiting();
|
|
});
|
|
|
|
it('allows the first N calls and blocks the next one', async () => {
|
|
const outcomes: Array<{ allowed: boolean; remaining: number }> = [];
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const result = await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
outcomes.push({ allowed: result.allowed, remaining: result.remaining });
|
|
}
|
|
|
|
expect(outcomes).toEqual([
|
|
{ allowed: true, remaining: 2 },
|
|
{ allowed: true, remaining: 1 },
|
|
{ allowed: true, remaining: 0 },
|
|
{ allowed: false, remaining: 0 },
|
|
{ allowed: false, remaining: 0 },
|
|
]);
|
|
|
|
const row = await db.rateLimit.findFirstOrThrow();
|
|
expect(row.key).toBe('1.2.3.4');
|
|
expect(row.action).toBe('login');
|
|
expect(row.count).toBe(5);
|
|
});
|
|
|
|
it('keeps one row per key and action pair', async () => {
|
|
await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
await checkRateLimit('5.6.7.8', 'login', CONFIG);
|
|
await checkRateLimit('1.2.3.4', 'register', CONFIG);
|
|
|
|
const rows = await db.rateLimit.findMany({ orderBy: [{ key: 'asc' }, { action: 'asc' }] });
|
|
expect(rows.map((row) => [row.key, row.action, row.count])).toEqual([
|
|
['1.2.3.4', 'login', 2],
|
|
['1.2.3.4', 'register', 1],
|
|
['5.6.7.8', 'login', 1],
|
|
]);
|
|
});
|
|
|
|
it('does not let one key exhaust another key budget', async () => {
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
}
|
|
|
|
expect((await checkRateLimit('1.2.3.4', 'login', CONFIG)).allowed).toBe(false);
|
|
expect((await checkRateLimit('5.6.7.8', 'login', CONFIG)).allowed).toBe(true);
|
|
});
|
|
|
|
it('does not let one action exhaust another action budget', async () => {
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
}
|
|
|
|
expect((await checkRateLimit('1.2.3.4', 'comment', CONFIG)).allowed).toBe(true);
|
|
});
|
|
|
|
it('derives resetAt from the stored window start plus the window length', async () => {
|
|
const result = await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
const row = await db.rateLimit.findFirstOrThrow();
|
|
|
|
expect(result.resetAt.getTime()).toBe(row.windowStart.getTime() + CONFIG.windowMs);
|
|
});
|
|
|
|
// The CASE arms in the upsert: once window_start is older than the window, the
|
|
// count resets to 1 and window_start moves to now rather than incrementing.
|
|
it('starts a fresh window once the old one has expired', async () => {
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
}
|
|
expect((await checkRateLimit('1.2.3.4', 'login', CONFIG)).allowed).toBe(false);
|
|
|
|
// Age the window past its length instead of waiting a real minute.
|
|
await db.$executeRaw`
|
|
UPDATE rate_limits
|
|
SET window_start = NOW() - INTERVAL '2 minutes'
|
|
WHERE key = '1.2.3.4' AND action = 'login'
|
|
`;
|
|
|
|
const afterReset = await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
|
|
expect(afterReset.allowed).toBe(true);
|
|
expect(afterReset.remaining).toBe(CONFIG.maxRequests - 1);
|
|
const row = await db.rateLimit.findFirstOrThrow();
|
|
expect(row.count).toBe(1);
|
|
expect(Date.now() - row.windowStart.getTime()).toBeLessThan(30_000);
|
|
});
|
|
|
|
it('keeps blocking while the window is still open', async () => {
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
await checkRateLimit('1.2.3.4', 'login', CONFIG);
|
|
}
|
|
|
|
await db.$executeRaw`
|
|
UPDATE rate_limits
|
|
SET window_start = NOW() - INTERVAL '30 seconds'
|
|
WHERE key = '1.2.3.4' AND action = 'login'
|
|
`;
|
|
|
|
expect((await checkRateLimit('1.2.3.4', 'login', CONFIG)).allowed).toBe(false);
|
|
});
|
|
|
|
it('falls back to the api config for an unknown action', async () => {
|
|
const result = await checkRateLimit('1.2.3.4', 'no-such-action');
|
|
|
|
expect(result.remaining).toBe(RATE_LIMIT_CONFIGS.api.maxRequests - 1);
|
|
});
|
|
|
|
// Defence in depth against oversized values reaching the query. The call is
|
|
// allowed but nothing is recorded, so an attacker cannot use a huge key to
|
|
// bloat the table either.
|
|
it('allows and records nothing for an over-long key or action', async () => {
|
|
const longKey = await checkRateLimit('x'.repeat(257), 'login', CONFIG);
|
|
const longAction = await checkRateLimit('1.2.3.4', 'y'.repeat(65), CONFIG);
|
|
|
|
expect(longKey.allowed).toBe(true);
|
|
expect(longAction.allowed).toBe(true);
|
|
expect(await countRows('rate_limits')).toBe(0);
|
|
});
|
|
|
|
it('records a key of exactly 255 characters, the column width', async () => {
|
|
const result = await checkRateLimit('x'.repeat(255), 'login', CONFIG);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(await countRows('rate_limits')).toBe(1);
|
|
});
|
|
|
|
// Documents an off-by-one, reported rather than fixed. The guard in
|
|
// lib/rate-limit.ts rejects `key.length > 256`, but rate_limits.key is
|
|
// VARCHAR(255), so a 256-character key clears the guard and then fails the
|
|
// INSERT with P2010. The catch treats any database error as "allow", so such a
|
|
// key is never counted and the limit silently stops applying to it.
|
|
//
|
|
// Not reachable from the product today: every call site builds a key from an
|
|
// IP, a user id or a 24-character hash. The failure mode is fail-open, so a
|
|
// future longer key would disable a limit rather than break a page.
|
|
it('fails open for a 256-character key instead of counting it', async () => {
|
|
// Kept to four attempts, one past the limit, because each one logs the
|
|
// swallowed Postgres error and the point is made without ten copies of it.
|
|
const key = 'x'.repeat(256);
|
|
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
const result = await checkRateLimit(key, 'login', CONFIG);
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.remaining).toBe(CONFIG.maxRequests);
|
|
}
|
|
|
|
expect(await countRows('rate_limits')).toBe(0);
|
|
});
|
|
|
|
it('counts concurrent calls exactly once each', async () => {
|
|
await Promise.all(Array.from({ length: 6 }, () => checkRateLimit('1.2.3.4', 'login', CONFIG)));
|
|
|
|
expect((await db.rateLimit.findFirstOrThrow()).count).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('cleanup_rate_limits()', () => {
|
|
beforeEach(() => {
|
|
enableRateLimiting();
|
|
});
|
|
|
|
it('deletes windows older than an hour and keeps the rest', async () => {
|
|
await db.$executeRaw`
|
|
INSERT INTO rate_limits (key, action, count, window_start) VALUES
|
|
('stale', 'login', 5, NOW() - INTERVAL '61 minutes'),
|
|
('fresh', 'login', 5, NOW() - INTERVAL '59 minutes'),
|
|
('now', 'login', 1, NOW())
|
|
`;
|
|
|
|
await cleanupRateLimits();
|
|
|
|
const remaining = (await db.rateLimit.findMany({ select: { key: true } })).map(
|
|
(row) => row.key
|
|
);
|
|
expect(remaining.sort()).toEqual(['fresh', 'now']);
|
|
});
|
|
|
|
it('is safe to call against an empty table', async () => {
|
|
await cleanupRateLimits();
|
|
|
|
expect(await countRows('rate_limits')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('rateLimit', () => {
|
|
beforeEach(() => {
|
|
enableRateLimiting();
|
|
});
|
|
|
|
it('returns null while allowed and a 429 with headers once blocked', async () => {
|
|
const request = apiRequest('/api/anything');
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
expect(await rateLimit(request, 'login', CONFIG)).toBeNull();
|
|
}
|
|
|
|
const blocked = await rateLimit(request, 'login', CONFIG);
|
|
|
|
expect(blocked?.status).toBe(429);
|
|
expect(blocked?.headers.get('X-RateLimit-Limit')).toBe('3');
|
|
expect(blocked?.headers.get('X-RateLimit-Remaining')).toBe('0');
|
|
expect(blocked?.headers.get('X-RateLimit-Reset')).toMatch(/^\d+$/);
|
|
expect(await blocked!.json()).toEqual({
|
|
error: 'Too many requests. Please try again later.',
|
|
});
|
|
});
|
|
|
|
// With TRUSTED_PROXY_MODE unset, getClientIp() collapses every caller to
|
|
// 127.0.0.1, so the limit is per process rather than per client. Pinned here
|
|
// because it is a deliberate trade-off, not an accident.
|
|
it('shares one bucket across every caller when no trusted proxy is configured', async () => {
|
|
const first = apiRequest('/api/anything', { headers: { 'x-forwarded-for': '9.9.9.9' } });
|
|
const second = apiRequest('/api/anything', { headers: { 'x-forwarded-for': '8.8.8.8' } });
|
|
|
|
expect(await rateLimit(first, 'login', CONFIG)).toBeNull();
|
|
expect(await rateLimit(second, 'login', CONFIG)).toBeNull();
|
|
expect(await rateLimit(first, 'login', CONFIG)).toBeNull();
|
|
expect((await rateLimit(second, 'login', CONFIG))?.status).toBe(429);
|
|
expect((await db.rateLimit.findFirstOrThrow()).key).toBe('127.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('getClientIp', () => {
|
|
it('returns 127.0.0.1 and ignores proxy headers with no trusted proxy mode', () => {
|
|
const request = apiRequest('/api/anything', {
|
|
headers: {
|
|
'x-forwarded-for': '203.0.113.9',
|
|
'x-real-ip': '203.0.113.9',
|
|
'cf-connecting-ip': '203.0.113.9',
|
|
},
|
|
});
|
|
|
|
expect(getClientIp(request)).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('trusts cf-connecting-ip in cloudflare mode', () => {
|
|
vi.stubEnv('TRUSTED_PROXY_MODE', 'cloudflare');
|
|
const request = apiRequest('/api/anything', {
|
|
headers: { 'cf-connecting-ip': '203.0.113.9', 'x-forwarded-for': '10.0.0.1' },
|
|
});
|
|
|
|
expect(getClientIp(request)).toBe('203.0.113.9');
|
|
});
|
|
|
|
it('rejects an implausible cf-connecting-ip in cloudflare mode', () => {
|
|
vi.stubEnv('TRUSTED_PROXY_MODE', 'cloudflare');
|
|
const request = apiRequest('/api/anything', {
|
|
headers: { 'cf-connecting-ip': 'not-an-ip; drop table' },
|
|
});
|
|
|
|
expect(getClientIp(request)).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('prefers x-real-ip and otherwise the last x-forwarded-for entry in nginx mode', () => {
|
|
vi.stubEnv('TRUSTED_PROXY_MODE', 'nginx');
|
|
|
|
expect(
|
|
getClientIp(
|
|
apiRequest('/api/anything', {
|
|
headers: { 'x-real-ip': '203.0.113.9', 'x-forwarded-for': '10.0.0.1' },
|
|
})
|
|
)
|
|
).toBe('203.0.113.9');
|
|
|
|
// The last entry is the one nginx appends, so a client-supplied prefix
|
|
// cannot spoof it.
|
|
expect(
|
|
getClientIp(
|
|
apiRequest('/api/anything', {
|
|
headers: { 'x-forwarded-for': 'spoofed, 198.51.100.7' },
|
|
})
|
|
)
|
|
).toBe('198.51.100.7');
|
|
});
|
|
|
|
it('ignores proxy headers for an unrecognised mode', () => {
|
|
vi.stubEnv('TRUSTED_PROXY_MODE', 'haproxy');
|
|
const request = apiRequest('/api/anything', {
|
|
headers: { 'x-real-ip': '203.0.113.9', 'cf-connecting-ip': '203.0.113.9' },
|
|
});
|
|
|
|
expect(getClientIp(request)).toBe('127.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('rateLimitHeaders', () => {
|
|
it('renders the reset time as unix seconds', () => {
|
|
const resetAt = new Date(1_700_000_123_456);
|
|
|
|
expect(rateLimitHeaders({ allowed: true, remaining: 7, resetAt }, 10)).toEqual({
|
|
'X-RateLimit-Limit': '10',
|
|
'X-RateLimit-Remaining': '7',
|
|
'X-RateLimit-Reset': '1700000123',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('rate limiting on a real route', () => {
|
|
beforeEach(() => {
|
|
enableRateLimiting();
|
|
});
|
|
|
|
it('blocks comment creation after the configured number of comments', async () => {
|
|
const scenario = await seedVersion({ duration: 600 });
|
|
signedInAs(scenario.owner);
|
|
const max = RATE_LIMIT_CONFIGS.comment.maxRequests;
|
|
|
|
const statuses: number[] = [];
|
|
for (let attempt = 0; attempt <= max; attempt += 1) {
|
|
const response = await callRoute(
|
|
postComment,
|
|
apiRequest(`/api/versions/${scenario.version.id}/comments`, {
|
|
body: { content: `comment ${attempt}`, timestamp: attempt },
|
|
}),
|
|
{ versionId: scenario.version.id }
|
|
);
|
|
statuses.push(response.status);
|
|
}
|
|
|
|
expect(statuses.slice(0, max)).toEqual(Array.from({ length: max }, () => 201));
|
|
expect(statuses[max]).toBe(429);
|
|
// The blocked request must not have written a row.
|
|
expect(await db.comment.count()).toBe(max);
|
|
expect((await db.rateLimit.findFirstOrThrow()).action).toBe('comment');
|
|
});
|
|
});
|