// @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);