279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* DC-120: log-insights perimeter section smoke test.
|
|
*
|
|
* Validates that the Log Insights module:
|
|
* 1. still declares the sections it always had (insights, summary, IPs,
|
|
* storage) — refactor guard
|
|
* 2. wires the new Perimeter section (li-perimeter div + loadPerimeter)
|
|
* 3. escapes hostile IP/host strings before innerHTML insertion
|
|
* (perimeter data comes from the public internet via caddy logs —
|
|
* a malicious Host header is attacker-controlled input)
|
|
* 4. keeps the perimeter fetch failure-isolated: a rejected perimeter
|
|
* fetch must NOT blank the insights panel
|
|
*
|
|
* We load the script in a sandboxed VM with a mocked DOM (same pattern as
|
|
* share-modal.test.js) and drive loadPerimeter directly via the exposed
|
|
* test handle.
|
|
*
|
|
* Source path resolution: the judge worktree may flatten files with a
|
|
* numeric prefix (e.g. `0_log-insights.js`) — same fallback scan as the
|
|
* share-modal test.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
function findTarget() {
|
|
const candidates = [
|
|
path.join(__dirname, '..', 'js', 'log-insights.js'),
|
|
path.join(__dirname, 'log-insights.js'),
|
|
];
|
|
for (const c of candidates) {
|
|
if (fs.existsSync(c)) return c;
|
|
}
|
|
// Flat-worktree fallback: scan cwd + tests dir for the module name.
|
|
for (const dir of [__dirname, process.cwd()]) {
|
|
try {
|
|
const hit = fs.readdirSync(dir).find((f) => /log-insights\.js$/.test(f));
|
|
if (hit) return path.join(dir, hit);
|
|
} catch (_) { /* keep scanning */ }
|
|
}
|
|
throw new Error('log-insights.js not found');
|
|
}
|
|
|
|
function makeDom() {
|
|
const elements = {};
|
|
function el(id) {
|
|
if (!elements[id]) {
|
|
elements[id] = {
|
|
id,
|
|
innerHTML: '',
|
|
style: {},
|
|
listeners: {},
|
|
addEventListener(ev, fn) { this.listeners[ev] = fn; },
|
|
click() { this.listeners.click && this.listeners.click(); },
|
|
};
|
|
}
|
|
return elements[id];
|
|
}
|
|
return {
|
|
getElementById: (id) => (id === 'nonexistent' ? null : el(id)),
|
|
createElement: () => ({ innerHTML: '', firstElementChild: { id: 'spawned' } }),
|
|
body: { appendChild() {} },
|
|
};
|
|
}
|
|
|
|
test('module still declares the core sections (refactor guard)', () => {
|
|
const src = fs.readFileSync(findTarget(), 'utf8');
|
|
for (const id of ['li-insights', 'li-summary', 'li-ips-table', 'li-storage', 'li-perimeter']) {
|
|
assert.ok(src.includes(`id="${id}"`), `missing section #${id}`);
|
|
}
|
|
});
|
|
|
|
test('module wires loadPerimeter and fetches the perimeter endpoint', async () => {
|
|
const document = makeDom();
|
|
const calls = [];
|
|
const sandbox = {
|
|
document,
|
|
fetch: async (url) => {
|
|
calls.push(url);
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
summary: { events: 42, uniqueIPs: 7, denied: 3, error: 1 },
|
|
topIPs: [{ ip: '1.2.3.4', count: 10, denied: 2, error: 0, hosts: ['a.example'] }],
|
|
byHost: [{ host: 'a.example', count: 10, denied: 2, error: 0 }],
|
|
}),
|
|
};
|
|
},
|
|
prompt: () => null,
|
|
alert: () => {},
|
|
confirm: () => false,
|
|
console,
|
|
setTimeout,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fs.readFileSync(findTarget(), 'utf8'), sandbox, { filename: 'log-insights.js' });
|
|
|
|
// Open the modal → loadInsights runs → perimeter fetch fires.
|
|
const openBtn = document.getElementById('log-insights-btn');
|
|
openBtn.click();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
assert.ok(calls.some((u) => String(u).includes('/api/v1/security/events/perimeter')),
|
|
'perimeter endpoint never fetched');
|
|
const html = document.getElementById('li-perimeter').innerHTML;
|
|
assert.ok(html.includes('1.2.3.4'), 'top IP not rendered');
|
|
assert.ok(html.includes('42 requests from 7 IPs'), 'summary line not rendered');
|
|
assert.ok(html.includes('3 denied'), 'denied count not rendered');
|
|
});
|
|
|
|
test('hostile IP/host strings are HTML-escaped before innerHTML', async () => {
|
|
const document = makeDom();
|
|
const sandbox = {
|
|
document,
|
|
fetch: async () => ({
|
|
json: async () => ({
|
|
success: true,
|
|
summary: { events: 1, uniqueIPs: 1, denied: 0, error: 0 },
|
|
topIPs: [{ ip: '<script>alert(1)</script>', count: 1, denied: 0, error: 0, hosts: ['<img src=x onerror=alert(2)>'] }],
|
|
byHost: [{ host: '<b>evil</b>', count: 1, denied: 0, error: 0 }],
|
|
}),
|
|
}),
|
|
prompt: () => null,
|
|
alert: () => {},
|
|
confirm: () => false,
|
|
console,
|
|
setTimeout,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fs.readFileSync(findTarget(), 'utf8'), sandbox, { filename: 'log-insights.js' });
|
|
|
|
document.getElementById('log-insights-btn').click();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
const html = document.getElementById('li-perimeter').innerHTML;
|
|
assert.ok(!html.includes('<script>'), 'raw <script> leaked into perimeter HTML');
|
|
assert.ok(!html.includes('<img src=x'), 'raw onerror img leaked into perimeter HTML');
|
|
assert.ok(html.includes('<script>'), 'IP not escaped');
|
|
});
|
|
|
|
test('perimeter fetch failure leaves insights intact (isolation)', async () => {
|
|
const document = makeDom();
|
|
const sandbox = {
|
|
document,
|
|
fetch: async (url) => {
|
|
if (String(url).includes('perimeter')) {
|
|
return { json: async () => ({ success: false, error: 'boom' }) };
|
|
}
|
|
// Main insights endpoint succeeds.
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
insights: [{ severity: 'ok', title: 'All quiet', plain: 'nothing' }],
|
|
summary: { totalRequests: 5, uniqueIPs: 1, securityEvents: 0, failedActions: 0 },
|
|
topIPs: [{ ip: '127.0.0.1', count: 5, failures: 0, topActions: [['auth.login', 5]], lastSeen: '2026-01-01T00:00:00Z' }],
|
|
storage: { auditLog: { sizeMB: 1, entries: 10 } },
|
|
}),
|
|
};
|
|
},
|
|
prompt: () => null,
|
|
alert: () => {},
|
|
confirm: () => false,
|
|
console,
|
|
setTimeout,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fs.readFileSync(findTarget(), 'utf8'), sandbox, { filename: 'log-insights.js' });
|
|
|
|
document.getElementById('log-insights-btn').click();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
const insights = document.getElementById('li-insights').innerHTML;
|
|
assert.ok(insights.includes('All quiet'), 'insights panel was blanked by perimeter failure');
|
|
const perimeter = document.getElementById('li-perimeter').innerHTML;
|
|
assert.ok(perimeter.includes('Perimeter unavailable'), 'perimeter error state not shown');
|
|
});
|
|
|
|
test('true rejected perimeter fetch (network error) exercises catch path with stale guard', async () => {
|
|
const document = makeDom();
|
|
const sandbox = {
|
|
document,
|
|
fetch: async (url) => {
|
|
if (String(url).includes('perimeter')) {
|
|
throw new Error('Network error');
|
|
}
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
insights: [{ severity: 'ok', title: 'All quiet', plain: 'nothing' }],
|
|
summary: { totalRequests: 5, uniqueIPs: 1, securityEvents: 0, failedActions: 0 },
|
|
topIPs: [{ ip: '127.0.0.1', count: 5, failures: 0, topActions: [['auth.login', 5]], lastSeen: '2026-01-01T00:00:00Z' }],
|
|
storage: { auditLog: { sizeMB: 1, entries: 10 } },
|
|
}),
|
|
};
|
|
},
|
|
prompt: () => null,
|
|
alert: () => {},
|
|
confirm: () => false,
|
|
console,
|
|
setTimeout,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fs.readFileSync(findTarget(), 'utf8'), sandbox, { filename: 'log-insights.js' });
|
|
|
|
document.getElementById('log-insights-btn').click();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
const perimeter = document.getElementById('li-perimeter').innerHTML;
|
|
assert.ok(perimeter.includes('Perimeter failed to load'), 'catch path not executed');
|
|
assert.ok(perimeter.includes('Network error'), 'network error not shown');
|
|
});
|
|
|
|
test('stale rejected perimeter fetch does not overwrite fresh data', async () => {
|
|
const document = makeDom();
|
|
let perimeterCallCount = 0;
|
|
let resolveOld;
|
|
const oldPromise = new Promise((res) => { resolveOld = res; });
|
|
const sandbox = {
|
|
document,
|
|
fetch: async (url) => {
|
|
if (String(url).includes('perimeter')) {
|
|
perimeterCallCount++;
|
|
if (perimeterCallCount === 1) {
|
|
// First (stale) call: returns a promise that rejects when resolved
|
|
return oldPromise.then(() => { throw new Error('Old rejected'); });
|
|
}
|
|
// Second (fresh) call: immediate success
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
summary: { events: 10, uniqueIPs: 2, denied: 0, error: 0 },
|
|
topIPs: [{ ip: '1.2.3.4', count: 5, denied: 0, error: 0, hosts: ['a.example'] }],
|
|
byHost: [{ host: 'a.example', count: 5, denied: 0, error: 0 }],
|
|
}),
|
|
};
|
|
}
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
insights: [{ severity: 'ok', title: 'All quiet', plain: 'nothing' }],
|
|
summary: { totalRequests: 5, uniqueIPs: 1, securityEvents: 0, failedActions: 0 },
|
|
topIPs: [{ ip: '127.0.0.1', count: 5, failures: 0, topActions: [['auth.login', 5]], lastSeen: '2026-01-01T00:00:00Z' }],
|
|
storage: { auditLog: { sizeMB: 1, entries: 10 } },
|
|
}),
|
|
};
|
|
},
|
|
prompt: () => null,
|
|
alert: () => {},
|
|
confirm: () => false,
|
|
console,
|
|
setTimeout,
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fs.readFileSync(findTarget(), 'utf8'), sandbox, { filename: 'log-insights.js' });
|
|
|
|
document.getElementById('log-insights-btn').click();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
// Simulate a rapid period change — fires a NEW request before old resolves.
|
|
const periodSel = document.getElementById('li-period');
|
|
periodSel.value = '6';
|
|
const listeners = periodSel.listeners || {};
|
|
if (listeners.change) await listeners.change();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
// Now resolve the OLD request's rejection.
|
|
resolveOld(undefined);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
const perimeter = document.getElementById('li-perimeter').innerHTML;
|
|
assert.ok(perimeter.includes('1.2.3.4') || perimeter.includes('10 requests') || perimeter.includes('requests from'),
|
|
'stale rejection overwrote fresh perimeter: ' + perimeter);
|
|
});
|