Files
nmemmertandClaude Sonnet 5.5 9893883b19
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Split server/index.js into one file per area
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin);
index.js keeps setup only. A smoke test walks 90 API calls against the real
server and compares every status with the pre-split behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:52 -04:00

168 lines
12 KiB
JavaScript

// @vitest-environment node
// Starts the real server (server/index.js) on a spare port with a throwaway data folder and walks the whole API
// the way the app does: accounts, studies, synced reader data, sharing, co-teaching, admin. It records the status of
// every call, so reorganising the server's files can't quietly change what any route answers.
import { afterAll, beforeAll, expect, test } from 'vitest';
import { spawn } from 'child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createServer } from 'net';
const ADMIN = 'admin@x.test';
let proc; let dir; let base;
const jars = {}; // who -> cookie
const log = [];
const freePort = () => new Promise((resolve) => { const s = createServer(); s.listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
async function call(who, method, path, body, headers = {}) {
const res = await fetch(base + path, {
method, headers: { 'Content-Type': 'application/json', ...(jars[who] ? { Cookie: jars[who] } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual',
});
const set = res.headers.get('set-cookie');
if (set) jars[who] = set.split(';')[0];
const text = await res.text();
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
log.push(`${who} ${method} ${path.replace(/[0-9a-f]{20,}/g, ':token').replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, ':id').replace(/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/g, ':code')} ${res.status}`);
return { status: res.status, json, text };
}
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'smoke-'));
const port = await freePort();
base = `http://127.0.0.1:${port}`;
proc = spawn('node', ['server/index.js'], {
env: { ...process.env, PORT: String(port), DATA_DIR: dir, ADMIN_EMAIL: ADMIN, SESSION_SECRET: 'smoke', BACKUP_DISABLED: '1', NODE_ENV: 'development', IQBIBLE_API_KEY: '' },
stdio: 'ignore',
});
for (let i = 0; i < 100; i += 1) {
try { if ((await fetch(`${base}/api/health`)).ok) return; } catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 100));
}
throw new Error('server did not start');
}, 20000);
afterAll(() => { proc?.kill(); rmSync(dir, { recursive: true, force: true }); });
const study = (id, title = 'Romans') => ({ id, title, translation: 'BSB', lastEdited: 1, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }] });
test('the whole API answers the way it always has', async () => {
// ---- public ----
expect((await call('anon', 'GET', '/api/health')).status).toBe(200);
expect((await call('anon', 'GET', '/api/auth/signup-mode')).json.mode).toBe('open');
expect((await call('anon', 'GET', '/api/auth/me')).status).toBe(401);
expect((await call('anon', 'GET', '/api/projects')).status).toBe(401);
expect((await call('anon', 'GET', '/api/admin/users')).status).toBe(401);
// ---- accounts ----
expect((await call('admin', 'POST', '/api/auth/register', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
expect((await call('sam', 'POST', '/api/auth/register', { email: 'sam@x.test', password: 'correct-horse-2' })).status).toBe(200);
expect((await call('anon', 'POST', '/api/auth/register', { email: 'bad', password: 'x' })).status).toBe(400);
expect((await call('anon', 'POST', '/api/auth/login', { email: ADMIN, password: 'wrong-password' })).status).toBe(401);
expect((await call('admin', 'POST', '/api/auth/login', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
const me = await call('admin', 'GET', '/api/auth/me');
expect(me.json).toMatchObject({ email: ADMIN, isAdmin: true, totpEnabled: false, passkeyCount: 0 });
expect((await call('sam', 'GET', '/api/auth/me')).json.isAdmin).toBe(false);
expect((await call('anon', 'POST', '/api/auth/forgot', { email: 'sam@x.test' })).json.ok).toBe(true);
expect((await call('anon', 'POST', '/api/auth/reset', { email: 'sam@x.test', code: 'nope', newPassword: 'another-pass-3' })).status).toBe(400);
expect((await call('admin', 'PATCH', '/api/auth/profile', { podcastName: 'My Show' })).status).toBe(200);
expect((await call('admin', 'PATCH', '/api/auth/reminder-settings', { reminderEnabled: true, ntfyTopic: '', reminderTime: '08:00' })).status).toBe(400);
expect((await call('admin', 'POST', '/api/auth/reminder-test', { ntfyTopic: 'bad topic!' })).status).toBe(400);
expect((await call('admin', 'POST', '/api/auth/change-password', { currentPassword: 'nope', newPassword: 'brand-new-pass-4' })).status).toBe(401);
expect((await call('admin', 'POST', '/api/auth/mfa/setup')).json.secret).toBeTruthy();
expect((await call('admin', 'POST', '/api/auth/mfa/enable', { token: '000000' })).status).toBe(401);
expect((await call('anon', 'POST', '/api/auth/mfa/verify', { token: '000000' })).status).toBe(400);
expect((await call('admin', 'GET', '/api/account/export')).json.app).toBeTruthy();
// ---- studies ----
expect((await call('admin', 'PUT', '/api/projects/s1', study('s1'))).status).toBe(200);
expect((await call('admin', 'PUT', '/api/projects/s1', study('other'))).status).toBe(400);
expect((await call('admin', 'PUT', '/api/projects/s1', { id: 's1' })).status).toBe(400);
expect((await call('admin', 'GET', '/api/projects')).json).toHaveLength(1);
expect((await call('admin', 'GET', '/api/projects/s1')).json.title).toBe('Romans');
expect((await call('sam', 'GET', '/api/projects/s1')).status).toBe(404);
expect((await call('admin', 'PUT', '/api/projects/s1', { ...study('s1', 'Romans 8'), lastEdited: 2 }, { 'X-Base-Edited': '1' })).status).toBe(409); // another device saved since
expect((await call('admin', 'GET', '/api/projects/s1/versions')).json.versions).toBeInstanceOf(Array);
expect((await call('admin', 'GET', '/api/projects/s1/versions/999')).status).toBe(404);
expect((await call('admin', 'POST', '/api/projects/s1/versions/999/restore')).status).toBe(404);
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(404);
// ---- synced reader data ----
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: [{ p: 1 }] })).status).toBe(200);
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: 'no' })).status).toBe(400);
expect((await call('admin', 'PUT', '/api/reader/ink/ROMANS/8', { strokes: [] })).status).toBe(400);
expect(Object.keys((await call('admin', 'GET', '/api/reader/ink')).json)).toHaveLength(1);
expect((await call('admin', 'PUT', '/api/prefs', { prefs: { theme: { v: 'dark', at: 5 } } })).json.prefs.theme.v).toBe('dark');
expect((await call('admin', 'GET', '/api/prefs')).json.prefs.theme.v).toBe('dark');
expect((await call('admin', 'PUT', '/api/reader/marks', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] })).json.ok).toBe(true);
expect((await call('admin', 'GET', '/api/reader/marks')).json.bookmarks['JHN-3-16']).toBe('#fef08a');
expect((await call('admin', 'PUT', '/api/reader/marks', 'x')).status).toBe(400);
expect((await call('admin', 'PUT', '/api/prayers', { prayers: [{ id: 'p1', text: 'Peace', createdAt: 1, updatedAt: 2 }] })).json.prayers).toHaveLength(1);
expect((await call('admin', 'GET', '/api/prayers')).json.prayers).toHaveLength(1);
expect((await call('admin', 'PUT', '/api/reader/history', { 'JHN-3': 1 })).status).toBe(200);
expect((await call('admin', 'GET', '/api/reader/history')).status).toBe(200);
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-10-01'] })).json.ok).toBe(true);
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: 'no' })).status).toBe(400);
expect((await call('admin', 'GET', '/api/reading-plans')).json.progress).toHaveLength(1);
// ---- other areas mounted on the app ----
expect((await call('admin', 'GET', '/api/iq/status')).json.available).toBe(false);
expect((await call('admin', 'GET', '/api/iq/nope')).status).toBe(404);
expect((await call('admin', 'GET', '/api/passkeys')).json).toEqual([]);
expect((await call('admin', 'GET', '/api/sessions')).json).toHaveLength(1);
expect((await call('admin', 'POST', '/api/auth/passkey/options')).json.challenge).toBeTruthy();
expect((await call('anon', 'GET', '/api/bible/nope')).status).toBeGreaterThanOrEqual(400);
// ---- sharing ----
const share = await call('admin', 'POST', '/api/projects/s1/share', {});
expect(share.status).toBe(200);
expect((await call('admin', 'GET', '/api/projects/s1/share')).status).toBe(200);
expect((await call('anon', 'GET', `/api/share/${share.json.shareToken}`)).status).toBe(200);
expect((await call('anon', 'GET', '/api/share/doesnotexist')).status).toBe(404);
const outline = await call('admin', 'POST', '/api/projects/s1/outline-share', {});
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(200);
expect((await call('admin', 'GET', '/api/projects/s1/outline-share')).status).toBe(200);
expect((await call('admin', 'DELETE', '/api/projects/s1/outline-share')).status).toBe(200);
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(404);
expect((await call('admin', 'DELETE', '/api/projects/s1/share')).status).toBe(200);
const invite = await call('admin', 'POST', '/api/projects/s1/collaborators/invite');
expect(invite.status).toBe(200);
expect((await call('sam', 'POST', `/api/join/${invite.json.token}`)).status).toBe(200);
expect((await call('admin', 'GET', '/api/projects/s1/collaborators')).status).toBe(200);
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(200);
expect((await call('admin', 'DELETE', `/api/projects/s1/collaborators/${me.json.id}`)).status).toBe(200);
// ---- admin ----
expect((await call('sam', 'GET', '/api/admin/users')).status).toBe(403);
expect((await call('admin', 'GET', '/api/admin/users')).json).toHaveLength(2);
expect((await call('admin', 'GET', '/api/admin/projects')).status).toBe(200);
expect((await call('admin', 'GET', '/api/admin/projects/s1')).status).toBe(200);
expect((await call('admin', 'GET', '/api/admin/signup')).json.mode).toBe('open');
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'nonsense' })).status).toBe(400);
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'invite' })).status).toBe(200);
const code = await call('admin', 'POST', '/api/admin/invites', { note: 'x', days: 7 });
expect(code.status).toBe(200);
expect((await call('admin', 'DELETE', `/api/admin/invites/${encodeURIComponent(code.json.code ?? 'none')}`)).status).toBeGreaterThanOrEqual(200);
const health = await call('admin', 'GET', '/api/admin/health');
expect(health.json).toMatchObject({ iqConfigured: false });
expect(health.json.signinFailures.length).toBeGreaterThan(0); // the wrong password above
expect((await call('admin', 'POST', '/api/admin/backups')).status).toBe(200);
expect((await call('admin', 'GET', '/api/admin/backups/nope.db')).status).toBe(404);
expect((await call('admin', 'POST', '/api/admin/backups/projects-2000-01-01-00-00.db/restore', { confirm: 'x' })).status).toBe(404);
const samId = (await call('sam', 'GET', '/api/auth/me')).json.id;
expect((await call('admin', 'POST', `/api/admin/users/${samId}/reset-password`)).json.temporaryPassword).toBeTruthy();
expect((await call('sam', 'GET', '/api/auth/me')).status).toBe(401); // signed out everywhere
expect((await call('admin', 'DELETE', `/api/admin/users/${samId}`)).status).toBe(200);
// ---- finishing ----
expect((await call('admin', 'DELETE', '/api/projects/s1')).status).toBe(200);
expect((await call('admin', 'POST', '/api/auth/logout')).status).toBe(200);
expect((await call('admin', 'GET', '/api/auth/me')).status).toBe(401);
// Every call and its status, in order. Run with UPDATE_SMOKE=1 to rewrite it after a deliberate API change.
const file = new URL('./routes.smoke.expected.txt', import.meta.url);
if (process.env.UPDATE_SMOKE === '1') writeFileSync(file, `${log.join('\n')}\n`);
expect(`${log.join('\n')}\n`).toBe(readFileSync(file, 'utf8'));
}, 60000);