Files
CyberStrikeAI/web/static/js/vulnerability-retest.test.cjs
T
Sycun 6a67364947 feat: add vulnerability retest branches with an isolated evidence log
Retesting a finding creates an independent conversation branch that
snapshots the source history and model trace, so verification can run
multi-turn without writing to the original session.

- POST /api/vulnerabilities/:id/retest creates the branch and persists the
  prompt, so a lost response, navigation or refresh can resume it
- new update_vulnerability_status tool closes the loop; a retest branch may
  only read and update its own finding
- {{vulnerability_id}}, {{conversation_id}} and {{source_conversation_id}}
  resolve on send, in the first turn and in follow-ups
- evidence appends to a read-only vulnerabilities.retest_log column instead
  of retest_notes: that field is operator-authored and the edit form
  rewrites it wholesale, so a routine edit used to erase the retest trail
- the retest prompt is prefilled with the operator's retest_notes, which
  already states how to verify the fix
- copied messages are re-bound as time.Time. Scanning a DATETIME column into
  a string yields RFC3339Nano while AddMessage writes the driver's
  space-separated format, and messages sort on the raw created_at text, so a
  branch rendered its new turn above the inherited history
2026-10-10 07:21:20 +08:00

155 lines
7.4 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
function setup() {
const source = fs.readFileSync('web/static/js/vulnerability.js', 'utf8');
const elements = Object.fromEntries(['vulnerability-retest-prompt', 'vulnerability-retest-error', 'vulnerability-retest-confirm', 'vulnerability-retest-title'].map(id => [id, { value: '', textContent: '', disabled: false }]));
elements['vulnerability-retest-prompt'].value = 'Check {{vulnerability_id}} here {{conversation_id}}';
elements['chat-input'] = { value: '', dispatchEvent() {} };
const sent = [], requests = [], alerts = [];
let navigateResolve;
const navigation = new Promise(resolve => { navigateResolve = resolve; });
const ctx = vm.createContext({
document: { getElementById: id => elements[id], dispatchEvent() {} },
window: { currentConversationId: 'source' },
Event: class {}, CustomEvent: class {},
vulnT: key => key, closeAppModal() {}, openAppModal() {}, alert: message => alerts.push(message),
apiFetch: async (url, options) => {
requests.push({url, ...options});
return { ok: true, json: async () => ({ conversation: {id: 'branch', projectId: 'project'}, prompt: 'resolved verification for finding in branch' }) };
},
navigateToConversation: () => navigation,
sendMessage: async options => { sent.push({ id: ctx.window.currentConversationId, prompt: elements['chat-input'].value }); options.onRequestAccepted(); },
});
vm.runInContext(source.slice(source.indexOf("let vulnerabilityRetestId = '';")), ctx);
vm.runInContext("vulnerabilityRetestId = 'finding';", ctx);
return {ctx, elements, sent, requests, alerts, navigateResolve};
}
test('confirmation creates one branch and waits for navigation before sending', async () => {
const s = setup();
const first = s.ctx.confirmVulnerabilityRetest();
await s.ctx.confirmVulnerabilityRetest();
await new Promise(resolve => setImmediate(resolve));
assert.equal(s.requests.length, 1);
assert.equal(s.requests[0].url, '/api/vulnerabilities/finding/retest');
assert.equal(JSON.parse(s.requests[0].body).prompt, s.elements['vulnerability-retest-prompt'].value);
assert.equal(s.sent.length, 0);
s.ctx.window.currentConversationId = 'branch';
s.navigateResolve();
await first;
assert.deepEqual(s.sent, [{id: 'branch', prompt: 'resolved verification for finding in branch'}]);
assert.equal(s.elements['vulnerability-retest-confirm'].disabled, false);
});
test('switching to another conversation prevents retest prompt being sent there', async () => {
const s = setup();
const work = s.ctx.confirmVulnerabilityRetest();
await new Promise(resolve => setImmediate(resolve));
s.ctx.window.currentConversationId = 'unrelated';
s.navigateResolve();
await work;
assert.equal(s.sent.length, 0);
assert.equal(s.alerts.length, 1);
assert.equal(s.elements['chat-input'].value, '');
});
test('an empty prompt does not create a branch', async () => {
const s = setup();
s.elements['vulnerability-retest-prompt'].value = ' ';
await s.ctx.confirmVulnerabilityRetest();
assert.equal(s.requests.length, 0);
assert.equal(s.elements['vulnerability-retest-error'].textContent, 'vulnerabilityRetest.promptRequired');
});
test('an active retest stream does not lock out the next retest confirmation', async () => {
const s = setup();
let finish;
const running = new Promise(resolve => { finish = resolve; });
s.ctx.sendMessage = options => { options.onRequestAccepted(); return running; };
s.ctx.window.currentConversationId = 'branch';
s.navigateResolve();
await s.ctx.confirmVulnerabilityRetest();
await s.ctx.confirmVulnerabilityRetest();
assert.equal(s.requests.length, 2);
finish();
});
test('slow responses from an older finding cannot overwrite the selected finding', async () => {
const s = setup();
const pending = {};
s.ctx.apiFetch = url => new Promise(resolve => { pending[url] = resolve; });
const first = s.ctx.openVulnerabilityRetest('first');
const second = s.ctx.openVulnerabilityRetest('second');
pending['/api/vulnerabilities/second']({ok: true, json: async () => ({title: 'Second finding'})});
await second;
pending['/api/vulnerabilities/first']({ok: true, json: async () => ({title: 'First finding'})});
await first;
assert.equal(s.elements['vulnerability-retest-title'].textContent, 'Second finding');
assert.equal(vm.runInContext('vulnerabilityRetestId', s.ctx), 'second');
});
test('the modal stays locked through send preflight, but unlocks on HTTP acceptance', async () => {
const s = setup();
let accept, finish;
const stream = new Promise(resolve => { finish = resolve; });
s.ctx.sendMessage = options => { accept = options.onRequestAccepted; return stream; };
s.ctx.window.currentConversationId = 'branch';
s.navigateResolve();
const work = s.ctx.confirmVulnerabilityRetest();
await new Promise(resolve => setImmediate(resolve));
await s.ctx.confirmVulnerabilityRetest();
assert.equal(s.requests.length, 1);
assert.equal(s.elements['vulnerability-retest-confirm'].disabled, true);
accept();
await work;
assert.equal(s.elements['vulnerability-retest-confirm'].disabled, false);
finish();
});
test('a send cancelled before acceptance is reported with its recoverable branch ID', async () => {
const s = setup();
s.ctx.sendMessage = async () => {};
s.ctx.window.currentConversationId = 'branch';
s.navigateResolve();
await s.ctx.confirmVulnerabilityRetest();
assert.equal(s.alerts.length, 1);
assert.match(s.alerts[0], /branch/);
assert.match(s.alerts[0], /startFailed/);
});
test('navigation while creating a branch does not take over the new page', async () => {
const s = setup();
let respond;
s.ctx.window.location = {hash: '#vulnerabilities'};
s.ctx.apiFetch = () => new Promise(resolve => { respond = resolve; });
let navigations = 0;
s.ctx.navigateToConversation = () => { navigations++; };
const work = s.ctx.confirmVulnerabilityRetest();
s.ctx.window.location.hash = '#chat?conversation=other';
respond({ok: true, json: async () => ({conversation:{id:'branch'},prompt:'saved prompt'})});
await work;
assert.equal(navigations, 0);
assert.equal(s.sent.length, 0);
assert.match(s.alerts[0], /branch/);
});
test('the retest prompt carries the operator-written verification method', async () => {
const s = setup();
s.ctx.apiFetch = () => Promise.resolve({ok: true, json: async () => ({title: 'SQLi', retest_notes: '重放登录请求,应返回 403'})});
await s.ctx.openVulnerabilityRetest('finding-1');
assert.equal(s.elements['vulnerability-retest-title'].textContent, 'SQLi');
assert.equal(s.elements['vulnerability-retest-prompt'].value,
'vulnerabilityRetest.defaultPrompt\n\nvulnerabilityRetest.guidanceHeading\n重放登录请求,应返回 403');
});
test('a finding without a verification method gets the plain default prompt', async () => {
const s = setup();
s.ctx.apiFetch = () => Promise.resolve({ok: true, json: async () => ({title: 'SQLi', retest_notes: ' '})});
await s.ctx.openVulnerabilityRetest('finding-2');
assert.equal(s.elements['vulnerability-retest-prompt'].value, 'vulnerabilityRetest.defaultPrompt');
});