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'); });