'use strict'; /** * The update check against a forge that misbehaves: the admin page must say * what went wrong in words, never zlib's "incorrect header check". * * node tools/update-check.test.js * * Plain Node, no dependencies. Starts a mock forge whose answer to the * branch-archive download can be switched at runtime, starts server.js on a * spare port pointed at it, and reads the shell status the admin page shows * after each kind of answer: * * - a Gitea that requires a sign-in redirects the download to its login * page (a 200 of HTML) — the case that hit both booths on 2026-09-29; * - a captive portal or a wrong address answers HTML outright; * - a plain HTTP error; * - and a real archive, which must still parse and offer the update. * * Exits non-zero on the first failure and always stops what it started. */ const assert = require('node:assert/strict'); const { spawn, execFileSync } = require('node:child_process'); const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); function freePort() { return new Promise((resolve, reject) => { const probe = net.createServer(); probe.listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); probe.on('error', reject); }); } async function waitFor(url, ms = 15000) { const deadline = Date.now() + ms; let last = ''; while (Date.now() < deadline) { try { const r = await fetch(url); if (r.ok) return; last = 'HTTP ' + r.status; } catch (err) { last = err.message; } await new Promise((r) => setTimeout(r, 200)); } throw new Error('server did not come up: ' + last); } /** A branch archive the way a forge would build one: one top-level folder. */ function buildArchive(dir) { const top = path.join(dir, 'ProdDash-main'); fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true }); fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' })); fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' })); fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n'); const out = path.join(dir, 'main.tar.gz'); execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']); return fs.readFileSync(out); } (async () => { const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-')); const dataDir = path.join(work, 'data'); fs.mkdirSync(dataDir); const archive = buildArchive(path.join(work, 'forge')); // ── the mock forge ── let mode = 'ok'; const LOGIN_PAGE = '\nSign inSign in to continue'; const forge = http.createServer((req, res) => { const url = new URL(req.url, 'http://forge.invalid'); if (url.pathname === '/user/login') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(LOGIN_PAGE); } if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not here'); } switch (mode) { case 'login-redirect': res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) }); return res.end(); case 'html': res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('You must log in to this network first'); case 'notfound': res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('no such branch'); default: res.writeHead(200, { 'Content-Type': 'application/gzip' }); return res.end(archive); } }); const forgePort = await freePort(); await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r)); const forgeBase = `http://127.0.0.1:${forgePort}`; // ── ProdDash, pointed at it ── const port = await freePort(); const child = spawn(process.execPath, ['server.js'], { cwd: ROOT, env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_FORGE: forgeBase, PRODDASH_REPO: 'michael/ProdDash', PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); let log = ''; child.stdout.on('data', (d) => { log += d; }); child.stderr.on('data', (d) => { log += d; }); const base = `http://127.0.0.1:${port}`; /** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */ const check = async (m) => { mode = m; const r = await fetch(base + '/api/admin/catalog?refresh=1'); assert.equal(r.status, 200, `catalog: HTTP ${r.status}`); const body = await r.json(); return body.shell; }; let failed = false; try { await waitFor(base + '/api/theme'); let shell = await check('login-redirect'); assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error); assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named'); assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words'); assert.equal(shell.updateAvailable, false); shell = await check('html'); assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error); shell = await check('notfound'); assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error); shell = await check('ok'); assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error); assert.equal(shell.remote.version, '9.9.9', 'and is read'); assert.equal(shell.updateAvailable, true, 'and offered'); // Back to a broken forge: the last good read is not mistaken for a current one. shell = await check('login-redirect'); assert.match(shell.remote.error, /requires a sign-in/); console.log('update check: every check passed'); } catch (err) { failed = true; console.error('FAIL:', err.message); console.error('--- last of the server log ---\n' + log.slice(-2000)); } finally { child.kill('SIGTERM'); forge.close(); await new Promise((r) => setTimeout(r, 400)); fs.rmSync(work, { recursive: true, force: true }); } process.exit(failed ? 1 : 0); })();