const fs = require('node:fs'); const vm = require('node:vm'); const test = require('node:test'); const assert = require('node:assert/strict'); const source = fs.readFileSync('web/static/js/update.js', 'utf8'); const template = fs.readFileSync('web/templates/index.html', 'utf8'); const router = fs.readFileSync('web/static/js/router.js', 'utf8'); const settings = fs.readFileSync('web/static/js/settings.js', 'utf8'); const sheet = fs.readFileSync('web/static/css/style.css', 'utf8'); const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8')); const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8')); const ru = JSON.parse(fs.readFileSync('web/static/i18n/ru-RU.json', 'utf8')); // The only handlers this console is allowed to wire into markup. const HANDLES = ['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', 'updateRestartChoiceChanged', 'loadUpdateConsole', 'saveUpdateSource', 'previewAdoptSource', 'confirmAdoptSource', 'startRestartNow', 'refreshUpdatePage']; // The envelope shape GET /api/system/update answers with, plus what the source endpoints return. const officialRepo = 'https://github.com/AIPentest/CyberStrikeAI.git'; const emptySource = { repo: '', configured: false, defaultRepo: officialRepo }; const configuredSource = { repo: 'https://github.com/Sycun/CyberStrikeAI.git', configured: true, defaultRepo: officialRepo }; const adoptPlan = { root: '/srv/csai', source: 'https://github.com/Sycun/CyberStrikeAI.git', branch: 'main', commit: 'abc1234', subject: 'target head', incoming: 42, overwrittenTotal: 2, overwritten: ['web/static/js/a.js', 'web/static/js/b.js'], protectedTotal: 1, protected: ['roles/我的角色.yaml'], }; function flatKeys(obj, prefix, out) { Object.keys(obj).forEach(k => { const v = obj[k]; const path = prefix ? prefix + '.' + k : k; if (v && typeof v === 'object') flatKeys(v, path, out); else out.add(path); }); return out; } function resolve(dict, dotted) { let node = dict; for (const part of dotted.split('.')) { if (!node || typeof node !== 'object') return undefined; node = node[part]; } return typeof node === 'string' ? node : undefined; } const baseStatus = { root: '/srv/csai', installed: true, branch: 'main', repo: officialRepo, repoConfigured: false, targetBranch: 'main', commit: 'a1b2c3d', subject: 'fix: 修好一个东西', committedAt: '2026-09-30 18:04:11 +0800', behind: 3, ahead: 0, diverged: false, updateAvailable: true, remoteCommit: 'f9e8d7c', remoteSubject: 'feat: 新东西', incoming: [{ commit: 'f9e8d7c', subject: 'feat: 新东西' }], incomingTotal: 3, localChanges: [], blockingChanges: [], goToolchain: 'go1.23.4', canBuild: true, hasBinary: true, hasRollback: true, rollbackTo: 'a1b2c3d', checkError: '', }; function statusOf(patch) { return Object.assign({}, baseStatus, patch || {}); } // The shape is the endpoint's own: {status, job, canRestart, supervised, needsRestart, source}. // job is null until an update ran. function envelope(status, job, canRestart, source, flags) { return { status: status || statusOf(), job: job === undefined ? null : job, canRestart: canRestart !== false, supervised: !!(flags && flags.supervised), needsRestart: !!(flags && flags.needsRestart), binaryBuiltAt: (flags && flags.binaryBuiltAt) || '', source: source || emptySource, }; } const runningJob = { id: 'upd-1', state: 'running', started: '2026-10-01T04:00:00+08:00', steps: [{ phase: 'fetch', message: '开始拉取更新源(默认分支 main)', at: '2026-10-01T04:00:01+08:00' }], restartRequested: false, }; const succeededJob = { id: 'upd-1', state: 'succeeded', started: '2026-10-01T04:00:00+08:00', finished: '2026-10-01T04:03:12+08:00', steps: [ { phase: 'fetch', message: officialRepo + '(默认分支 main)有 3 个新提交:a1b2c3d → f9e8d7c', at: '04:00:01' }, { phase: 'protect', message: '已暂存 2 个本地内容文件', at: '04:00:05' }, { phase: 'build', message: '开始编译二进制', at: '04:00:09' }, { phase: 'done', message: '已更新 3 个提交并换好二进制', at: '04:03:10' }, ], result: { fromCommit: 'a1b2c3d', toCommit: 'f9e8d7c', commits: 3, filesTouched: 27, keptContent: ['roles/我的角色.md', 'tools/semgrep.yaml'], binaryPath: '/srv/csai/cyberstrike-ai', binaryBuilt: true, prevBinary: '/srv/csai/cyberstrike-ai.prev', backupDir: '/srv/csai/.update-backup/20261001_040005', needsRestart: true, duration: '3m12s', }, restartRequested: true, }; const failedJob = { id: 'upd-2', state: 'failed', started: '2026-10-01T05:00:00+08:00', finished: '2026-10-01T05:00:40+08:00', steps: [{ phase: 'build', message: '开始编译二进制(首次会下载依赖,可能需要几分钟)', at: '05:00:20' }], failure: { reason: 'build_failed', message: '编译失败,二进制保持原版本:\ninternal/foo.go:12: undefined: bar', items: ['roles/我的角色.md', 'internal/foo.go'], }, restartRequested: false, }; // harness() loads the page for real (loadUpdateConsole) before handing it back, so every test // starts from the markup that would be on screen and can execute the handlers that markup emits. function harness(options) { const opts = options || {}; const nodes = new Map(); const calls = []; const toasts = []; const confirms = []; const timers = []; let active = true; let confirmAnswer = opts.confirm === false ? false : true; let nextTimerId = 1; function makeEl(id) { const el = { id, innerHTML: '', checked: false, insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; }, }; // 控制台住在系统设置页的「一键更新」分区里:页面和分区都得是 active,它才算在前台。 let seeded = ['page']; if (id === 'page-settings') seeded = ['page'].concat(active ? ['active'] : []); if (id === 'settings-section-update') seeded = ['settings-section-content'].concat(active ? ['active'] : []); el.classList = { _set: new Set(seeded), contains(c) { return this._set.has(c); }, add(c) { this._set.add(c); }, remove(c) { this._set.delete(c); }, }; return el; } const responses = Object.assign({ 'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart, opts.source, opts.flags) }], 'POST /api/system/update/check': [{ status: 200, body: { status: statusOf(opts.checkedStatus) } }], 'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }], 'GET /api/system/update/job': [{ status: 200, body: { job: opts.job || runningJob } }], 'POST /api/system/update/rollback': [{ status: 200, body: { result: { fromCommit: 'f9e8d7c', toCommit: 'a1b2c3d' } } }], 'POST /api/system/update/source': [{ status: 200, body: { source: opts.source || emptySource } }], 'POST /api/system/update/adopt': [{ status: 200, body: { plan: adoptPlan } }], }, opts.responses || {}); // The watchdog probes with plain fetch (the app's apiFetch turns a 401 into "logged out", // and the whole point here is to read that 401 as "the new process is up"). Tests script // the answers: 'ok' (old process still answering), 'unauthorized' (a new process without // our session), 'down' (nothing is listening), 'bad gateway'. const probeQueue = (opts.probes || ['ok']).slice(); const fetchProbes = []; const sandbox = { document: { getElementById(id) { if (!nodes.has(id)) nodes.set(id, makeEl(id)); return nodes.get(id); }, createElement() { const el = { textContent: '', innerHTML: '' }; Object.defineProperty(el, 'textContent', { set(v) { el._t = String(v == null ? '' : v); // The browser's textContent -> innerHTML leaves quotes alone; a harness that // escaped them would make the attribute-injection assertions test nothing. el.innerHTML = el._t.replace(/&/g, '&').replace(//g, '>'); }, get() { return el._t; }, }); return el; }, querySelectorAll() { return []; }, }, console, Promise, JSON, Set, Object, String, Date, Math, escapeHtml(text) { const div = sandbox.document.createElement('div'); div.textContent = text; return div.innerHTML; }, escapeAttr(text) { return sandbox.escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); }, showNotification(msg, type) { toasts.push({ msg, type }); }, setInterval(fn, ms) { const t = { id: nextTimerId++, fn, ms, cleared: false }; timers.push(t); return t.id; }, clearInterval(id) { const t = timers.find(x => x.id === id); if (t) t.cleared = true; }, // The page schedules one deferred remote check; a harness without setTimeout would make // that code path untestable rather than absent, so the browser global is provided and the // timer is recorded instead of fired. setTimeout(fn, ms) { const t = { id: nextTimerId++, fn, ms, cleared: false, deferred: true }; timers.push(t); return t.id; }, clearTimeout(id) { const t = timers.find(x => x.id === id); if (t) t.cleared = true; }, window: { // Resolved against the same zh-CN.json the browser loads, with {{var}} interpolated the // way i18next does. A key the dictionary does not carry comes back as the raw key, so a // missing translation fails the copy assertions instead of quietly painting a key name. // i18next escapes interpolated values by default (including / -> /); a call that // asks for escapeValue:false gets the raw value, exactly like the browser would. t(key, o) { const found = resolve(zh, key); if (found === undefined) return key; const escape = !(o && o.interpolation && o.interpolation.escapeValue === false); const esc = (v) => escape ? String(v) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/') : String(v); return found.replace(/\{\{(\w+)\}\}/g, (m, name) => (o && name in o ? esc(o[name]) : m)); }, showNotification(msg, type) { toasts.push({ msg, type }); }, confirm(message) { confirms.push(message); return confirmAnswer; }, applyRBACToUI() {}, }, apiFetch(url, reqOpts = {}) { const method = reqOpts.method || 'GET'; calls.push({ url, method, body: reqOpts.body }); if ((opts.rejects || []).includes(method + ' ' + url)) { // Not a status code: the request itself dies (401 handling, a dropped socket), // which is the path that leaves a page stuck on a spinner if it is not unwound. return Promise.reject(new Error('network unreachable')); } const queue = responses[method + ' ' + url]; const entry = queue && queue.length ? (queue.length > 1 ? queue.shift() : queue[0]) : null; if (!entry) { return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ error: 'no fixture for ' + method + ' ' + url }), }); } const status = entry.status === undefined ? 200 : entry.status; return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(entry.body), }); }, fetch(url, reqOpts = {}) { fetchProbes.push({ url, opts: reqOpts }); const answer = probeQueue.length > 1 ? probeQueue.shift() : (probeQueue[0] || 'ok'); if (answer === 'down') { return Promise.reject(new TypeError('Failed to fetch')); } if (answer === 'unauthorized') { return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) }); } if (answer === 'bad gateway') { return Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({}) }); } return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(envelope(opts.status, opts.job, opts.canRestart, opts.source, opts.flags)), }); }, location: { pathname: '/', hash: '', replaced: [], reloads: 0, replace(target) { this.replaced.push(target); }, reload() { this.reloads++; }, }, }; sandbox.window.document = sandbox.document; vm.createContext(sandbox); vm.runInContext(source, sandbox); vm.runInContext(` this.api = { loadUpdateConsole, checkForUpdates, startUpdateApply, rollbackUpdate, updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling, startRestartNow }; this.vars = { setBusy(v) { updateBusy = v; }, getRestartChoice() { return updateRestartChoice; }, getPollTimer() { return updatePollTimer; }, getCheckDone() { return updateCheck.done; }, autoCheckDone() { return updateAutoCheckDone; }, getWatchdogTimer() { return updateWatchdogTimer; }, getWatchdogSlow() { return updateWatchdogSlow; }, restartPending() { return updateRestartPending; }, // The deadline is wall-clock; tests move it into the past instead of sleeping 90s. expireWatchdog() { updateWatchdogDeadline = Date.now() - 1; } }; `, sandbox); const flush = async () => { for (let i = 0; i < 14; i++) await new Promise(r => setImmediate(r)); }; // Running the attribute text inside the page's own context is the point of this harness: a // handler wired to the wrong function, or reading the wrong element, still looks perfectly // fine when the attribute is only ever inspected as a string. function runHandler(text, thisArg) { const fn = vm.runInContext('(function(){' + text + '})', sandbox); return fn.call(thisArg || {}); } function extractHandlers(html) { const found = { onclick: [], onchange: [] }; const re = /on(click|change)="([^"]*)"/g; let m; while ((m = re.exec(html)) !== null) found['on' + m[1]].push(m[2]); return found; } const h = { sandbox, nodes, calls, toasts, confirms, timers, flush, runHandler, extractHandlers, responses, api: sandbox.api, vars: sandbox.vars, location: sandbox.location, probes: fetchProbes, // The deferred auto-check timer is inspected from this side: `timers` is the harness's own // array, not something the page's context can see. deferredTimers() { return timers.filter(t => t.deferred && !t.cleared).map(t => t.ms); }, fireDeferred() { timers.filter(t => t.deferred && !t.cleared).forEach(t => { t.cleared = true; t.fn(); }); }, html() { return nodes.get('update-console').innerHTML; }, fire(name, thisArg) { const all = extractHandlers(h.html()); const text = all.onclick.concat(all.onchange).find(v => v.startsWith(name + '(')); if (!text) throw new Error('handler ' + name + ' is not present in the rendered markup'); return runHandler(text, thisArg); }, // Flipping the router's active classes (the settings page and its update section) is all // that leaving means for this console. setActive(v) { active = v; ['page-settings', 'settings-section-update'].forEach(id => { const node = nodes.get(id); if (!node) return; if (v) node.classList.add('active'); else node.classList.remove('active'); }); }, setConfirm(v) { confirmAnswer = v; }, liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); }, tick() { const live = h.liveTimers(); if (!live.length) throw new Error('no poll timer is armed'); return live[live.length - 1].fn(); }, }; return Promise.resolve(sandbox.api.loadUpdateConsole()).then(flush).then(() => h); } test('the console lives inside the settings page as its own section', () => { // The entry is no longer a page of its own: it is one section of 系统设置, so the main sidebar // must not advertise a sibling item for it any more. assert.doesNotMatch(template, /data-page="system-update"/, 'the standalone sidebar entry must be gone: the console is a settings section now'); assert.doesNotMatch(template, /id="page-system-update"/, 'the standalone page must be gone: its content moved into 系统设置'); const settingsNavItem = template.indexOf('