65f4b7a7c7
Test suite (proof-of-work/tests/)
- vitest 4 + @vitest/coverage-v8 added as devDeps. New scripts: test,
test:watch, test:coverage.
- vitest.config.ts: single-fork pool so DB-backed tests don't trample
each other on temp file paths. `@/` alias mirrors tsconfig.
- tests/helpers/db.ts: setupTestDb() spins up a fresh schema-only
SQLite file per test suite via `prisma db push --skip-generate`,
returns a scoped PrismaClient + cleanup that removes WAL/SHM
sidecars too.
- tests/rateLimit.test.ts: under-limit / over-limit / per-key
isolation / window-slides-and-allows-again. Plus tests for
clientIpFromHeaders header preference order.
- tests/auth-pure.test.ts: hashPassword roundtrips, salt-randomness
(same input, different hash), bcrypt format ($2 prefix).
- tests/library.test.ts: actually runs the runtime
ensureExerciseLibrary.cjs against a temp DB with two users — verifies
the full library lands for every user, idempotent across two runs,
and a user's own custom exercise with a colliding name is NOT
overwritten on subsequent ensure passes. This is the highest-stakes
test in the suite (covers the exact code path that runs on every
container boot).
12 tests, ~1.0s total.
GitHub Actions CI (.github/workflows/ci.yml)
- Two jobs running in parallel on push + PR to master/main:
- `app`: cd proof-of-work && npm ci && prisma validate && prisma
generate && tsc --noEmit && npm test
- `startos`: cd start9/0.4 && npm ci && npm run check (the
StartOS package's existing tsc --noEmit script)
- Both jobs use Node 20 with npm cache keyed off the package-lock.
57 lines
1.9 KiB
TypeScript
57 lines
1.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
|
|
|
describe('rateLimit', () => {
|
|
it('allows requests under the limit', () => {
|
|
const key = `test-allow-${Math.random()}`;
|
|
for (let i = 0; i < 5; i++) {
|
|
const r = rateLimit(key, { limit: 5, windowMs: 60_000 });
|
|
expect(r.ok).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('blocks requests over the limit and reports retryAfterSec > 0', () => {
|
|
const key = `test-block-${Math.random()}`;
|
|
for (let i = 0; i < 3; i++) {
|
|
rateLimit(key, { limit: 3, windowMs: 60_000 });
|
|
}
|
|
const r = rateLimit(key, { limit: 3, windowMs: 60_000 });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.retryAfterSec).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('isolates buckets by key', () => {
|
|
const a = `iso-a-${Math.random()}`;
|
|
const b = `iso-b-${Math.random()}`;
|
|
for (let i = 0; i < 2; i++) {
|
|
rateLimit(a, { limit: 2, windowMs: 60_000 });
|
|
}
|
|
expect(rateLimit(a, { limit: 2, windowMs: 60_000 }).ok).toBe(false);
|
|
expect(rateLimit(b, { limit: 2, windowMs: 60_000 }).ok).toBe(true);
|
|
});
|
|
|
|
it('lets requests in again after the window slides', async () => {
|
|
const key = `slide-${Math.random()}`;
|
|
rateLimit(key, { limit: 1, windowMs: 50 });
|
|
expect(rateLimit(key, { limit: 1, windowMs: 50 }).ok).toBe(false);
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
expect(rateLimit(key, { limit: 1, windowMs: 50 }).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('clientIpFromHeaders', () => {
|
|
it('uses the leftmost x-forwarded-for entry', () => {
|
|
const h = new Headers({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
|
|
expect(clientIpFromHeaders(h)).toBe('1.2.3.4');
|
|
});
|
|
|
|
it('falls back to x-real-ip', () => {
|
|
const h = new Headers({ 'x-real-ip': '9.9.9.9' });
|
|
expect(clientIpFromHeaders(h)).toBe('9.9.9.9');
|
|
});
|
|
|
|
it('returns "unknown" when no headers present', () => {
|
|
expect(clientIpFromHeaders(new Headers())).toBe('unknown');
|
|
});
|
|
});
|