#!/usr/bin/env node // Starts the real server on a spare port with a temporary data folder and calls every endpoint while signed in. // Fails if any request gets a 500 (502/504 for an unreachable Seafile/Plex is a normal answer), if the process dies, or if a route points at a function that does not exist. // Run: node tools/smoke-test.js (also run by the Gitea workflow before the container is built) const { spawn } = require('child_process'); const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto'); const root = path.join(__dirname, '..'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-smoke-')); const port = 20000 + Math.floor(Math.random() * 20000), base = `http://127.0.0.1:${port}`; let failed = 0; const ok = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond ? '' : ' -> ' + extra)); if (!cond) failed++; }; // 1) every function the router calls must exist const src = fs.readFileSync(path.join(root, 'server.js'), 'utf8'); const targets = [...new Set([...src.matchAll(/return (\w+)\(req, res\)/g)].map(m => m[1]))]; const missing = targets.filter(n => !new RegExp(`(async )?function ${n}\\b|const ${n} =`).test(src)); ok('every function the router calls is defined', !missing.length, missing); // Seed one admin account so the test can sign in without a passkey ceremony (the cookie is signed with the test's own secret). const SECRET = 'smoke-'.repeat(8), UID = 'usmoke'; fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] })); const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } }); let alive = true, log = ''; child.on('exit', () => { alive = false; }); child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d); const wait = ms => new Promise(r => setTimeout(r, ms)); (async () => { for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } } const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url'); const cookie = `hub_session=${UID}.1.${exp}.${sig}`; const get = async (p, method = 'GET', body) => { try { const r = await fetch(base + p, { method, redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); await r.text(); return r.status; } catch { return -1; } }; const pages = ['/', '/login', '/app.js', '/style.css', '/sw.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; const api = ['/api/config', '/api/system', '/api/settings', '/api/auth/state', '/api/auth/passkeys', '/api/tasks/lists', '/api/tasks?list=local:inbox', '/api/tasks/summary', '/api/me', '/api/users', '/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status', `/api/cal/events?from=${Date.now()}&to=${Date.now() + 864e5}&fd=2026-01-01&td=2026-01-02`]; const bad = []; for (const p of [...pages, ...api]) { const st = await get(p); if (st === 500 || st === -1 || !alive) bad.push(`${p} -> ${alive ? st : 'server died'}`); } ok(`${pages.length + api.length} pages and endpoints answer without a server error`, !bad.length, bad); // a few writes, including bad input, which must produce 4xx and never a crash const writes = [['/api/tasks', { list: 'local:inbox', fields: { title: 'smoke', notes: '', due: '', done: false } }], ['/api/tasks', { list: 'local:inbox', fields: { title: '' } }], ['/api/tasks', { list: 'nope', fields: {} }], ['/api/settings', { key: 'cal_view', value: 'month' }, 'PUT'], ['/api/settings', { key: 'evil', value: 1 }, 'PUT'], ['/api/cal/event', { cal: 'local', fields: { title: 'x' } }], ['/api/cal/event', { cal: 'icloud:0000000000', fields: {} }], ['/api/cal/add', { url: 'http://localhost/x' }], ['/api/cal/icloud/connect', { appleId: '', password: '' }], ['/api/plex/server', { id: 'x' }], ['/api/oo/config', {}], ['/api/plex/pin', {}]]; const wbad = []; for (const [p, b, m] of writes) { const st = await get(p, m || 'POST', b); if (st === 500 || st === -1 || !alive) wbad.push(`${m || 'POST'} ${p} -> ${alive ? st : 'server died'}`); } ok(`${writes.length} writes with good and bad input never crash the server`, !wbad.length, wbad); // malformed requests const rawSt = await get('/api/tasks', 'POST', undefined).catch(() => -1); const raw = await fetch(base + '/api/tasks', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: '{not json' }).then(async r => { await r.text(); return r; }).catch(() => ({ status: -1 })); ok('malformed JSON gets a clean error, not a crash', raw.status !== 500 && raw.status !== -1 && alive, raw.status); ok('signed-out requests are refused', await fetch(base + '/api/tasks/lists').then(r => r.status === 401).catch(() => false)); // browsers must always check for a new version of the app's files const a1 = await fetch(base + '/app.js', { headers: { cookie } }); await a1.text(); const etag = a1.headers.get('etag'); ok('app files are sent with no-cache and an ETag, so a new version is never missed', /no-cache/.test(a1.headers.get('cache-control') || '') && !!etag, [a1.headers.get('cache-control'), etag]); const a2 = await fetch(base + '/app.js', { headers: { cookie, 'if-none-match': etag } }); await a2.text(); ok('and an unchanged file is answered with a cheap 304', a2.status === 304, a2.status); const i1 = await fetch(base + '/icons/icon-192.png'); await i1.arrayBuffer(); ok('icons are cached for a week', /max-age=604800/.test(i1.headers.get('cache-control') || ''), i1.headers.get('cache-control')); const cfg = await (await fetch(base + '/api/config', { headers: { cookie } })).json(); ok('the server reports a build stamp so open pages can tell a newer version exists', /^[0-9a-f]{12}$/.test(cfg.build || ''), cfg.build); const home = await (await fetch(base + '/', { headers: { cookie } })).text(), cfg2 = await (await fetch(base + '/api/config', { headers: { cookie } })).json(); ok('the app page links its code with the current build number, so a cached old copy can never be used', home.includes(`/app.js?v=${cfg2.build}`) && home.includes(`/style.css?v=${cfg2.build}`), home.match(/app\.js[^"]*/)); const login = await (await fetch(base + '/login')).text(); ok('so does the sign-in page', login.includes(`/login.js?v=${cfg2.build}`), login.match(/login\.js[^"]*/)); const va = await fetch(base + `/app.js?v=${cfg2.build}`, { headers: { cookie } }); ok('the versioned address serves the file', va.status === 200 && (await va.text()).includes('addPersonSheet'), va.status); // hardening: headers, cross-site writes, health check, backup, links setting const h1 = await fetch(base + '/login'); await h1.text(); ok('responses carry security headers', h1.headers.get('x-content-type-options') === 'nosniff' && /frame-ancestors 'self'/.test(h1.headers.get('content-security-policy') || ''), [...h1.headers]); const hz = await fetch(base + '/healthz'); ok('/healthz answers without signing in', hz.status === 200 && (await hz.json()).ok === true, hz.status); const cs = await fetch(base + '/api/tasks/lists', { method: 'POST', headers: { cookie, origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' }); await cs.text(); ok('a write coming from another website is refused', cs.status === 403, cs.status); const cs2 = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, 'sec-fetch-site': 'cross-site', 'content-type': 'application/json' }, body: '{"key":"links","value":[]}' }); await cs2.text(); ok('and so is one the browser marks cross-site', cs2.status === 403, cs2.status); const sl = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, origin: base, 'content-type': 'application/json' }, body: JSON.stringify({ key: 'links', value: [{ id: 'a', title: 'T', url: 'https://example.com', group: '' }] }) }); await sl.text(); const gl = await (await fetch(base + '/api/settings', { headers: { cookie } })).json(); ok('links are saved with the settings and read back', sl.status === 200 && gl.values.links && gl.values.links[0].url === 'https://example.com', [sl.status, gl]); const bk = await fetch(base + '/api/backup', { headers: { cookie } }); const bb = Buffer.from(await bk.arrayBuffer()); ok('an admin can download a backup (a gzip file)', bk.status === 200 && bb[0] === 0x1f && bb[1] === 0x8b, [bk.status, bb.length]); const bn = await fetch(base + '/api/backup'); await bn.text(); ok('but not without signing in', bn.status === 401, bn.status); ok('the server is still running after all of that', alive); child.kill(); fs.rmSync(dir, { recursive: true, force: true }); if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); } console.log(failed ? `\n${failed} check(s) failed` : '\nAll smoke checks passed'); process.exit(failed ? 1 : 0); })();