Files
hub/tools/smoke-test.js
T
nmemmertandClaude Sonnet 5.5 12a1e50a0e
build-container / build (push) Successful in 1m58s
Always revalidate the app's files, tell open pages when a newer version exists, and log why a session is refused
An open page kept running the old version after a deploy (the single page never reloads), so the new
People screens did not appear. Static files are now sent with no-cache + ETag (304 when unchanged), icons
are cached for a week, /api/config reports a build stamp, and the app shows "A new version is available -
Reload" when it comes back into view or every 10 minutes. The service worker cache name is bumped.

The server also logs, at most once a minute per reason, why it refused a session cookie (unknown account,
turned off, signed out everywhere, bad signature, expired) without printing the cookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:55:44 -04:00

65 lines
6.4 KiB
JavaScript

#!/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);
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);
})();