Test suite (Vitest) + GitHub Actions CI
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.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
jobs:
|
||||
app:
|
||||
name: proof-of-work (Next.js app)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: proof-of-work
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: proof-of-work/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Prisma validate
|
||||
run: npx prisma validate
|
||||
- name: Prisma generate
|
||||
run: npx prisma generate
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
startos:
|
||||
name: start9/0.4 (StartOS package code)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: start9/0.4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: start9/0.4/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Type-check StartOS package source
|
||||
run: npm run check
|
||||
Generated
+1364
-13
File diff suppressed because it is too large
Load Diff
+17
-12
@@ -11,30 +11,35 @@
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "npx tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"sync-library": "node scripts/sync-library.cjs"
|
||||
"sync-library": "node scripts/sync-library.cjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.0.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.31",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"postcss": "^8.4.31",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^5.0.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"typescript": "^5.2.2",
|
||||
"@types/node": "^20.5.0",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"tsx": "^4.7.0"
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"prisma": "^5.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword } from '@/lib/auth';
|
||||
|
||||
// Pure-function bits of lib/auth.ts (no Prisma, no cookies).
|
||||
|
||||
describe('hashPassword / verifyPassword', () => {
|
||||
it('verifies a correct password', async () => {
|
||||
const hash = await hashPassword('correct horse battery staple');
|
||||
expect(await verifyPassword('correct horse battery staple', hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an incorrect password', async () => {
|
||||
const hash = await hashPassword('correct horse battery staple');
|
||||
expect(await verifyPassword('wrong horse', hash)).toBe(false);
|
||||
});
|
||||
|
||||
it('produces different hashes for the same password (salt-randomness)', async () => {
|
||||
const a = await hashPassword('same input');
|
||||
const b = await hashPassword('same input');
|
||||
expect(a).not.toBe(b);
|
||||
expect(await verifyPassword('same input', a)).toBe(true);
|
||||
expect(await verifyPassword('same input', b)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces a bcrypt-format hash starting with $2', async () => {
|
||||
const hash = await hashPassword('whatever');
|
||||
expect(hash.startsWith('$2')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Per-test-suite SQLite DB. Creates a fresh schema-only DB at a temp
|
||||
* path, returns a PrismaClient pointed at it, and exposes a teardown.
|
||||
*
|
||||
* Each test file calls `setupTestDb()` in beforeAll and the returned
|
||||
* `cleanup()` in afterAll. State is NOT cleared between individual
|
||||
* tests inside a file — write tests so they either tear down their
|
||||
* own rows or use unique values.
|
||||
*/
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export interface TestDb {
|
||||
prisma: PrismaClient;
|
||||
dbPath: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function setupTestDb(): Promise<TestDb> {
|
||||
const id = `${process.pid}-${Date.now()}-${++counter}`;
|
||||
const dbPath = path.resolve('/tmp', `pow-test-${id}.db`);
|
||||
|
||||
// Apply current Prisma schema to the temp DB. `prisma db push` is the
|
||||
// right tool here (skips migration history bookkeeping).
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'prisma',
|
||||
'db',
|
||||
'push',
|
||||
'--skip-generate',
|
||||
'--accept-data-loss',
|
||||
],
|
||||
{
|
||||
env: { ...process.env, DATABASE_URL: `file:${dbPath}` },
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: { db: { url: `file:${dbPath}` } },
|
||||
log: ['error'],
|
||||
});
|
||||
|
||||
return {
|
||||
prisma,
|
||||
dbPath,
|
||||
cleanup: async () => {
|
||||
await prisma.$disconnect();
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// Clean up SQLite sidecar files too.
|
||||
for (const ext of ['-journal', '-wal', '-shm']) {
|
||||
try {
|
||||
fs.unlinkSync(dbPath + ext);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { setupTestDb, type TestDb } from './helpers/db';
|
||||
|
||||
let db: TestDb;
|
||||
let library: { name: string }[];
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await setupTestDb();
|
||||
// The library JSON shipped with the app — same one consumed in prod.
|
||||
library = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '..', 'prisma', 'exercises.seed.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.cleanup();
|
||||
});
|
||||
|
||||
describe('ensureExerciseLibrary.cjs (runtime entrypoint script)', () => {
|
||||
it('inserts the full library for every user, idempotently', async () => {
|
||||
// Two users, no exercises yet.
|
||||
const alice = await db.prisma.user.create({
|
||||
data: { email: 'alice@test', passwordHash: 'x', isAdmin: true },
|
||||
});
|
||||
const bob = await db.prisma.user.create({
|
||||
data: { email: 'bob@test', passwordHash: 'x' },
|
||||
});
|
||||
|
||||
const scriptPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'prisma',
|
||||
'ensureExerciseLibrary.cjs',
|
||||
);
|
||||
const jsonPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'prisma',
|
||||
'exercises.seed.json',
|
||||
);
|
||||
|
||||
const run = () =>
|
||||
execFileSync(
|
||||
'node',
|
||||
[scriptPath, '--db', db.dbPath, '--json', jsonPath],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
|
||||
run();
|
||||
|
||||
const aliceCount = await db.prisma.exercise.count({
|
||||
where: { userId: alice.id },
|
||||
});
|
||||
const bobCount = await db.prisma.exercise.count({
|
||||
where: { userId: bob.id },
|
||||
});
|
||||
expect(aliceCount).toBe(library.length);
|
||||
expect(bobCount).toBe(library.length);
|
||||
|
||||
// Idempotency — second run must not duplicate or change counts.
|
||||
run();
|
||||
expect(
|
||||
await db.prisma.exercise.count({ where: { userId: alice.id } }),
|
||||
).toBe(library.length);
|
||||
expect(
|
||||
await db.prisma.exercise.count({ where: { userId: bob.id } }),
|
||||
).toBe(library.length);
|
||||
|
||||
// A user's own custom exercise with the same name as a library entry
|
||||
// must NOT be overwritten by a subsequent ensure pass.
|
||||
const targetName = library[0].name;
|
||||
await db.prisma.exercise.deleteMany({
|
||||
where: { userId: bob.id, name: targetName },
|
||||
});
|
||||
await db.prisma.exercise.create({
|
||||
data: {
|
||||
userId: bob.id,
|
||||
name: targetName,
|
||||
muscleGroups: '[]',
|
||||
type: 'custom-by-bob',
|
||||
isCustom: true,
|
||||
},
|
||||
});
|
||||
|
||||
run();
|
||||
|
||||
const bobOwn = await db.prisma.exercise.findUnique({
|
||||
where: { userId_name: { userId: bob.id, name: targetName } },
|
||||
});
|
||||
expect(bobOwn?.isCustom).toBe(true);
|
||||
expect(bobOwn?.type).toBe('custom-by-bob');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
// Tests that touch the DB pull a fresh schema into a temp file in a
|
||||
// beforeAll hook (see tests/helpers/db.ts). Single fork so distinct
|
||||
// test files don't trample each other on the same `app.db` path
|
||||
// resolution.
|
||||
pool: 'forks',
|
||||
forks: { singleFork: true },
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user