const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const contextSource = fs.readFileSync('web/static/js/attack-chain-context.js', 'utf8');
const chatSource = fs.readFileSync('web/static/js/chat.js', 'utf8');
class Element {
constructor(tagName, sinks) {
Object.assign(this, { tagName, children: [], listeners: new Map(), attributes: {}, style: {}, textContent: '', hidden: false, open: false, sinks });
this.classList = { remove() {}, add() {} };
}
append(...children) { children.forEach(child => this.appendChild(child)); }
appendChild(child) { this.children.push(child); child.parent = this; return child; }
replaceChildren(...children) { this.children.forEach(child => { child.parent = null; }); this.children = []; this.append(...children); }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
addEventListener(type, listener) { this.listeners.set(type, listener); }
setAttribute(name, value) { this.attributes[name] = value; }
focus() {}
select() {}
set innerHTML(value) { this.sinks.push(String(value)); this.replaceChildren(); this._html = value; }
get innerHTML() { return this._html || ''; }
}
function deferred() {
let resolve, reject;
const promise = new Promise((r, j) => { resolve = r; reject = j; });
return { promise, resolve, reject };
}
function harness(options = {}) {
const sinks = [], copied = [], timers = [], listeners = new Map(), ids = new Map();
for (const id of ['attack-chain-context', 'attack-chain-context-content', 'attack-chain-context-title', 'attack-chain-context-notice', 'attack-chain-container', 'attack-chain-stats', 'attack-chain-modal', 'attack-chain-details']) ids.set(id, new Element('div', sinks));
ids.get('attack-chain-context').hidden = true;
const button = new Element('button', sinks), sidebar = new Element('div', sinks), body = new Element('body', sinks);
const document = {
body, getElementById: id => ids.get(id), createElement: tag => new Element(tag, sinks),
addEventListener(type, callback) { const callbacks = listeners.get(type) || []; callbacks.push(callback); listeners.set(type, callbacks); },
querySelector: selector => selector === '.attack-chain-sidebar' ? sidebar : button,
execCommand: () => { copied.push(body.children.find(child => child.tagName === 'textarea').value); return options.copyWorks !== false; }
};
let language = 'zh-CN';
const dictionaries = Object.fromEntries(['zh-CN', 'en-US', 'ru-RU'].map(lang => [lang, JSON.parse(fs.readFileSync(`web/static/i18n/${lang}.json`, 'utf8'))]));
const window = {
t(key, values) {
let translated = key.split('.').reduce((value, part) => value && value[part], dictionaries[language]) || key;
// Mirror real i18next: interpolated values are escaped unless the caller opts out.
const escape = !(values && values.interpolation && values.interpolation.escapeValue === false);
Object.entries(values || {}).forEach(([name, value]) => {
if (name === 'interpolation') return;
const text = escape
? String(value).replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/')
: String(value);
translated = translated.replace('{{' + name + '}}', text);
});
return translated;
}, attackChainOriginalData: null
};
const runtime = vm.createContext({
document, window, navigator: { clipboard: options.clipboard === false ? undefined : { writeText: async text => { if (options.clipboardRejects) throw Error('denied'); copied.push(text); } } },
setTimeout: callback => { timers.push(callback); }, console: { error() {}, log() {} },
closeAppModal() {}, openAppModal() {}, isAppModalOpen: () => false, closeNodeDetails() {},
alert() {},
currentAttackChainConversationId: 'A', attackChainCytoscape: null,
apiFetch: options.apiFetch || (() => { throw Error('unexpected API request'); })
});
vm.runInContext(contextSource, runtime);
vm.runInContext('let attackChainRequestSequence = 0; const attackChainRequestOwners = new Map(); const attackChainLoadingMap = new Map();', runtime);
for (const [name, nextName] of [
['beginAttackChainRequest', '_acBuildNodeIconDataUrl'], ['isAttackChainLoading', 'addAttackChainButton'],
['resetAttackChainContext', 'showAttackChain'], ['showAttackChain', 'loadAttackChain'],
['loadAttackChain', 'renderAttackChain'], ['renderAttackChain', 'getEdgeNodes'],
['updateAttackChainStats', 'closeNodeDetails'], ['closeAttackChainModal', 'refreshAttackChain'],
['refreshAttackChain', 'regenerateAttackChain'], ['regenerateAttackChain', '_acEscapeXml'],
['exportAttackChain', 'projectFilterT']
]) {
const pattern = name => new RegExp(`(?:async )?function ${name}\\(`);
const start = chatSource.search(pattern(name)), tail = chatSource.slice(start), end = tail.search(pattern(nextName));
assert.ok(start >= 0 && end > 0, `actual ${name} function is present`);
vm.runInContext(tail.slice(0, end), runtime);
}
return { runtime, window, ids, button, sinks, copied, timers, body, api: window.CyberStrikeAttackChainContext,
changeLanguage(next) { language = next; (listeners.get('languagechange') || []).forEach(listener => listener()); },
entries() { return ids.get('attack-chain-context-content').children; } };
}
const summary = content => ({ id: 'summary-123', provenance: 'system_generated_summary', content });
const chain = content => ({ nodes: [], edges: [], context_summaries: [summary(content)] });
const response = data => ({ ok: true, json: async () => data });
test('summary text and snapshot paths remain inert and copying preserves the exact original', async () => {
const h = harness();
const content = '关键线索\n
\n[证据](javascript:alert(1))\n/快照/transcript-ab123-时间.txt\nCookie: sample=fixture\n';
h.api.render(chain(content));
assert.equal(h.ids.get('attack-chain-context').hidden, false);
assert.equal(h.ids.get('attack-chain-context').open, false);
const entry = h.entries()[0];
assert.equal(entry.children[1].textContent, content);
assert.deepEqual(entry.children.map(element => element.tagName), ['summary', 'pre', 'button', 'span']);
assert.deepEqual(h.sinks, [], 'no raw summary content ever reaches an HTML sink');
await entry.children[2].listeners.get('click')();
assert.deepEqual(h.copied, [content]);
assert.equal(entry.children[3].textContent, '已复制');
});
test('only explicit provenance qualifies and empty/new context clears old bodies', () => {
const h = harness();
h.api.render({ context_summaries: [summary('trusted'), { content: 'summary-looking user text' }, { provenance: 'skills', content: 'not a summary' }, summary(' ')] });
assert.equal(h.entries().length, 1);
h.ids.get('attack-chain-context').open = true;
h.api.render({ context_summaries: [] });
assert.equal(h.entries().length, 0);
assert.equal(h.ids.get('attack-chain-context').hidden, true);
assert.equal(h.ids.get('attack-chain-context').open, false);
h.api.render(chain('next conversation'));
assert.equal(h.entries()[0].children[1].textContent, 'next conversation');
h.api.clear();
assert.equal(h.entries().length, 0);
});
test('language changes retain expansion and original content while translating generated labels', () => {
const h = harness();
h.api.render(chain('原始正文'));
const entry = h.entries()[0];
entry.open = true;
h.ids.get('attack-chain-context').open = true;
h.changeLanguage('en-US');
assert.equal(entry.children[0].textContent, 'Context summary 1');
assert.equal(entry.children[2].textContent, 'Copy full summary');
assert.equal(entry.children[1].textContent, '原始正文');
assert.equal(entry.open, true);
assert.equal(h.ids.get('attack-chain-context').open, true);
});
test('re-rendering the same conversation keeps the card expanded, clear still collapses', () => {
const h = harness();
h.api.render(chain('first pass'));
h.ids.get('attack-chain-context').open = true;
h.api.render(chain('second pass'));
assert.equal(h.ids.get('attack-chain-context').open, true);
assert.equal(h.entries()[0].children[1].textContent, 'second pass');
h.api.clear();
assert.equal(h.ids.get('attack-chain-context').open, false);
});
test('clipboard restrictions use the text fallback and failures retain selectable content', async () => {
for (const options of [{ clipboard: false }, { clipboardRejects: true }, { clipboard: false, copyWorks: false }]) {
const h = harness(options);
h.api.render(chain('full evidence path'));
const entry = h.entries()[0];
await entry.children[2].listeners.get('click')();
assert.deepEqual(h.copied, ['full evidence path']);
assert.equal(h.body.children.length, 0);
assert.equal(entry.children[2].disabled, false);
assert.equal(entry.children[1].textContent, 'full evidence path');
assert.equal(entry.children[3].textContent, options.copyWorks === false ? '复制失败,可选中文本手动复制。' : '已复制');
}
});
test('an empty graph still displays its context without changing nodes or statistics', () => {
const h = harness(), data = chain('summary without graph nodes');
let destroyed = 0;
h.runtime.attackChainCytoscape = { destroy: () => { destroyed += 1; } };
h.runtime.renderAttackChain(data);
h.runtime.updateAttackChainStats(data);
assert.equal(h.entries()[0].children[1].textContent, 'summary without graph nodes');
assert.equal(h.ids.get('attack-chain-stats').textContent, '节点: 0 | 边: 0');
assert.equal(data.nodes.length, 0);
assert.equal(h.window.attackChainOriginalData, data);
assert.equal(destroyed, 1);
assert.equal(h.runtime.attackChainCytoscape, null, 'old graph is unavailable to filters and exports');
const alerts = [];
h.runtime.alert = message => alerts.push(message);
h.runtime.exportAttackChain('svg');
assert.equal(alerts.length, 1);
assert.equal(h.timers.length, 0, 'export cannot schedule an old graph download');
});
test('late 409/errors cannot replace another conversation or unlock its request', async () => {
for (const operation of ['loadAttackChain', 'regenerateAttackChain']) for (const result of ['409', 'error', 'json-error']) {
const request = deferred(), h = harness({ apiFetch: () => request.promise });
const pending = operation === 'loadAttackChain' ? h.runtime[operation]('A') : h.runtime[operation]();
h.runtime.currentAttackChainConversationId = 'B';
h.api.render(chain('B evidence'));
h.button.disabled = true;
const marker = h.ids.get('attack-chain-container').innerHTML;
if (result === 'error') request.reject(Error('
'));
else if (result === 'json-error') request.resolve({ ok: false, status: 500, json: async () => ({ error: 'old error' }) });
else request.resolve({ ok: false, status: 409 });
await pending;
assert.equal(h.entries()[0].children[1].textContent, 'B evidence', `${operation}/${result}`);
assert.equal(h.ids.get('attack-chain-container').innerHTML, marker);
assert.equal(h.button.disabled, true);
assert.equal(h.timers.length, 0);
}
});
test('same-conversation requests keep the latest response and old completion cannot clear loading', async () => {
const requests = [deferred(), deferred()];
let index = 0;
const h = harness({ apiFetch: () => requests[index++].promise });
const older = h.runtime.loadAttackChain('A');
h.runtime.setAttackChainLoading('A', false);
const newer = h.runtime.loadAttackChain('A');
requests[0].resolve(response(chain('old evidence')));
await older;
assert.equal(h.runtime.isAttackChainLoading('A'), true);
requests[1].resolve(response(chain('new evidence')));
await newer;
assert.equal(h.entries()[0].children[1].textContent, 'new evidence');
assert.equal(h.runtime.isAttackChainLoading('A'), false);
const reverse = [deferred(), deferred()];
index = 0;
h.runtime.apiFetch = () => reverse[index++].promise;
const slow = h.runtime.loadAttackChain('A');
h.runtime.setAttackChainLoading('A', false);
const fast = h.runtime.loadAttackChain('A');
reverse[1].resolve(response(chain('newest evidence')));
await fast;
reverse[0].resolve(response(chain('stale evidence')));
await slow;
assert.equal(h.entries()[0].children[1].textContent, 'newest evidence');
});
test('current errors clear context and display server text without HTML execution', async () => {
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: '
' }) }) });
h.api.render(chain('old evidence'));
let destroyed = 0;
h.runtime.attackChainCytoscape = { destroy: () => { destroyed += 1; } };
await h.runtime.loadAttackChain('A');
assert.equal(h.entries().length, 0);
assert.equal(h.ids.get('attack-chain-container').children[0].textContent.includes('
'), true);
assert.equal(h.sinks.some(html => html.includes('onerror')), false);
assert.equal(destroyed, 1);
assert.equal(h.runtime.attackChainCytoscape, null);
const alerts = [];
h.runtime.alert = message => alerts.push(message);
h.runtime.exportAttackChain('svg');
assert.equal(alerts.length, 1);
assert.equal(h.timers.length, 0);
});
test('failure text stays readable instead of showing i18next escape entities', async () => {
const upstream = '生成攻击链失败: Post "https://api.example.com/v1/chat/completions": context deadline exceeded';
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: upstream }) }) });
await h.runtime.loadAttackChain('A');
const shown = h.ids.get('attack-chain-container').children[0].textContent;
assert.equal(shown.includes(upstream), true);
assert.equal(//|"|&|'/.test(shown), false);
assert.equal(h.sinks.some(html => html.includes('api.example.com')), false);
});
test('regenerate failure reports its own message key unescaped', async () => {
const upstream = 'AI生成失败: Post "https://api.example.com/v1/chat/completions": EOF';
const h = harness({ apiFetch: async () => ({ ok: false, status: 500, json: async () => ({ error: upstream }) }) });
await h.runtime.regenerateAttackChain();
const shown = h.ids.get('attack-chain-container').children[0].textContent;
assert.equal(shown.startsWith('重新生成失败: '), true);
assert.equal(shown.includes(upstream), true);
assert.equal(h.sinks.some(html => html.includes('api.example.com')), false);
});
test('regenerate 409 refreshes after completion while superseded timers stay inert', async () => {
const h = harness({ apiFetch: async () => ({ ok: false, status: 409 }) });
let refreshes = 0;
h.runtime.refreshAttackChain = () => { refreshes += 1; };
await h.runtime.regenerateAttackChain();
assert.equal(h.runtime.isAttackChainLoading('A'), false);
h.timers[0]();
assert.equal(refreshes, 1);
h.runtime.beginAttackChainRequest('A');
h.timers[0]();
assert.equal(refreshes, 1);
});
test('closing/reopening pending context clears old bodies and invalidates old responses', async () => {
const requests = [deferred(), deferred()];
let index = 0;
const h = harness({ apiFetch: () => requests[index++].promise });
const oldRequest = h.runtime.loadAttackChain('A');
h.api.render(chain('old context'));
h.runtime.closeAttackChainModal();
assert.equal(h.entries().length, 0);
assert.equal(h.window.attackChainOriginalData, null);
const reopened = h.runtime.showAttackChain('A');
assert.equal(index, 2);
requests[0].resolve(response(chain('stale closed context')));
await oldRequest;
assert.equal(h.entries().length, 0);
assert.equal(h.runtime.isAttackChainLoading('A'), true);
requests[1].resolve(response(chain('reopened context')));
await reopened;
assert.equal(h.entries()[0].children[1].textContent, 'reopened context');
});