mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 11:47:54 +02:00
fix(plan-canvas): sanitize PDF failures
This commit is contained in:
@@ -24,9 +24,9 @@ artifact as a real PDF file without sending the plan to an external converter.
|
||||
|
||||
- RED: the focused server suite produced 30 passes and 2 failures because the
|
||||
Canvas had no Download PDF control or PDF endpoint.
|
||||
- GREEN: renderer unit tests pass 7/7, Plan Canvas server tests pass 34/34,
|
||||
and the end-to-end review workflow passes 12/12.
|
||||
- FULL SUITE: the final review-hardened implementation passes all 4,008
|
||||
- GREEN: renderer unit tests pass 7/7, Plan Canvas server tests pass 35/35,
|
||||
and the end-to-end review workflow passes 13/13.
|
||||
- FULL SUITE: the final review-hardened implementation passes all 4,010
|
||||
discovered tests; hosted security reruns are recorded on PR #2894.
|
||||
- COVERAGE: `npm run coverage` passes 4,003/4,003 with 88.97% statements,
|
||||
80.58% branches, 94.22% functions, and 88.97% lines. The Plan Canvas
|
||||
|
||||
@@ -433,8 +433,18 @@ function createPlanCanvasServer({
|
||||
});
|
||||
return sendPdf(res, pdf);
|
||||
} catch (error) {
|
||||
const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500;
|
||||
return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' });
|
||||
const code = error.code || 'PDF_EXPORT_FAILED';
|
||||
log(`[plan-canvas] PDF export failed (${code}): ${error.stack || error.message}`);
|
||||
if (code === 'PDF_BROWSER_NOT_FOUND') {
|
||||
return sendJson(res, 503, {
|
||||
error: 'PDF export requires Google Chrome, Chromium, or Microsoft Edge; configure ECC_PLAN_CANVAS_CHROME_PATH if auto-discovery cannot find it',
|
||||
code
|
||||
});
|
||||
}
|
||||
return sendJson(res, 500, {
|
||||
error: 'PDF export failed; check the Plan Canvas server log for details',
|
||||
code: 'PDF_EXPORT_FAILED'
|
||||
});
|
||||
} finally {
|
||||
pdfSnapshot = null;
|
||||
pdfExportActive = false;
|
||||
|
||||
+17
-4
@@ -179,19 +179,20 @@ async function withServerStartLock(port, task, {
|
||||
const lockPort = validatePort(port);
|
||||
const startedAt = Date.now();
|
||||
let socket = null;
|
||||
let socketError = null;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
socket = await new Promise((resolve, reject) => {
|
||||
const candidate = dgramImpl.createSocket('udp4');
|
||||
const onError = error => {
|
||||
candidate.close();
|
||||
try { candidate.close(); } catch { /* failed bind has no open handle */ }
|
||||
reject(error);
|
||||
};
|
||||
candidate.once('error', onError);
|
||||
candidate.bind(lockPort, DEFAULT_HOST, () => {
|
||||
candidate.removeListener('error', onError);
|
||||
candidate.on('error', () => {});
|
||||
candidate.on('error', error => { socketError = error; });
|
||||
candidate.unref();
|
||||
resolve(candidate);
|
||||
});
|
||||
@@ -206,11 +207,23 @@ async function withServerStartLock(port, task, {
|
||||
}
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
return await task();
|
||||
result = await task();
|
||||
} finally {
|
||||
if (socket) socket.close();
|
||||
if (socket) {
|
||||
await new Promise(resolve => {
|
||||
try { socket.close(resolve); } catch { resolve(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (socketError) {
|
||||
const error = new Error(`Plan Canvas startup lock failed on port ${port}: ${socketError.message}`);
|
||||
error.code = 'PLAN_CANVAS_START_LOCK_FAILED';
|
||||
error.cause = socketError;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serverIsCompatible(health) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { EventEmitter } = require('events');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
@@ -169,6 +170,21 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await test('port-scoped startup lock propagates socket failures', async () => {
|
||||
class FailingLockSocket extends EventEmitter {
|
||||
bind(_port, _host, callback) { setImmediate(callback); }
|
||||
unref() {}
|
||||
close(callback) { if (callback) setImmediate(callback); }
|
||||
}
|
||||
const socket = new FailingLockSocket();
|
||||
await assert.rejects(
|
||||
withServerStartLock(port + 4, async () => {
|
||||
socket.emit('error', new Error('simulated UDP failure'));
|
||||
}, { dgramImpl: { createSocket: () => socket }, timeoutMs: 2000 }),
|
||||
error => error.code === 'PLAN_CANVAS_START_LOCK_FAILED' && error.message.includes('simulated UDP failure')
|
||||
);
|
||||
});
|
||||
|
||||
await test('concurrent opens serialize replacement of a same-version legacy server', async () => {
|
||||
const legacyPort = port + 1;
|
||||
const legacyStateDir = path.join(tmp, 'legacy-state');
|
||||
|
||||
@@ -104,6 +104,8 @@ async function main() {
|
||||
const pdfRequests = [];
|
||||
let holdPdfExport = false;
|
||||
let releasePdfExport = null;
|
||||
let pdfFailure = null;
|
||||
const serverLogs = [];
|
||||
let idleFired = false;
|
||||
const canvas = createPlanCanvasServer({
|
||||
store,
|
||||
@@ -112,9 +114,11 @@ async function main() {
|
||||
idleTimeoutMs: 0,
|
||||
pdfExporter: async options => {
|
||||
pdfRequests.push(options);
|
||||
if (pdfFailure) throw pdfFailure;
|
||||
if (holdPdfExport) await new Promise(resolve => { releasePdfExport = resolve; });
|
||||
return { buffer: Buffer.from('%PDF-1.4\n%%EOF\n'), filename: 'demo.pdf' };
|
||||
},
|
||||
log: line => serverLogs.push(line),
|
||||
onIdleShutdown: () => {
|
||||
idleFired = true;
|
||||
}
|
||||
@@ -260,22 +264,53 @@ async function main() {
|
||||
holdPdfExport = true;
|
||||
const snapshot = '<!doctype html><html><body><h1>Already rendered diagram</h1><svg><text>Local SVG</text></svg><script>fetch("https://example.invalid")</script></body></html>';
|
||||
const first = request(port, 'POST', `/api/session/${key}/pdf`, { body: { html: snapshot } });
|
||||
await waitFor(() => typeof releasePdfExport === 'function');
|
||||
const printable = await request(port, 'GET', `/artifact/${key}/?pdf=1`);
|
||||
assert.ok(printable.body.includes('Already rendered diagram'));
|
||||
assert.ok(printable.body.includes('Local SVG'));
|
||||
assert.ok(printable.headers['content-security-policy'].includes("script-src 'none'"));
|
||||
const overloaded = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
assert.strictEqual(overloaded.statusCode, 429);
|
||||
assert.strictEqual(overloaded.headers['retry-after'], '1');
|
||||
assert.deepStrictEqual(jsonBody(overloaded), {
|
||||
error: 'another PDF export is already in progress',
|
||||
code: 'PDF_EXPORT_BUSY'
|
||||
});
|
||||
releasePdfExport();
|
||||
try {
|
||||
await waitFor(() => typeof releasePdfExport === 'function');
|
||||
const printable = await request(port, 'GET', `/artifact/${key}/?pdf=1`);
|
||||
assert.ok(printable.body.includes('Already rendered diagram'));
|
||||
assert.ok(printable.body.includes('Local SVG'));
|
||||
assert.ok(printable.headers['content-security-policy'].includes("script-src 'none'"));
|
||||
const overloaded = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
assert.strictEqual(overloaded.statusCode, 429);
|
||||
assert.strictEqual(overloaded.headers['retry-after'], '1');
|
||||
assert.deepStrictEqual(jsonBody(overloaded), {
|
||||
error: 'another PDF export is already in progress',
|
||||
code: 'PDF_EXPORT_BUSY'
|
||||
});
|
||||
} finally {
|
||||
const release = releasePdfExport;
|
||||
holdPdfExport = false;
|
||||
releasePdfExport = null;
|
||||
if (release) release();
|
||||
}
|
||||
assert.strictEqual((await first).statusCode, 200);
|
||||
holdPdfExport = false;
|
||||
releasePdfExport = null;
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('PDF failures log diagnostics without disclosing local paths', async () => {
|
||||
try {
|
||||
const rendererError = new Error('Chromium failed at /Users/private/browser-profile');
|
||||
rendererError.code = 'PDF_EXPORT_FAILED';
|
||||
pdfFailure = rendererError;
|
||||
const failed = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
assert.strictEqual(failed.statusCode, 500);
|
||||
assert.deepStrictEqual(jsonBody(failed), {
|
||||
error: 'PDF export failed; check the Plan Canvas server log for details',
|
||||
code: 'PDF_EXPORT_FAILED'
|
||||
});
|
||||
assert.ok(!failed.body.includes('/Users/private'));
|
||||
assert.ok(serverLogs.some(line => line.includes('/Users/private/browser-profile')));
|
||||
|
||||
const browserError = new Error('missing override /Users/private/Chrome');
|
||||
browserError.code = 'PDF_BROWSER_NOT_FOUND';
|
||||
pdfFailure = browserError;
|
||||
const missing = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
assert.strictEqual(missing.statusCode, 503);
|
||||
assert.strictEqual(jsonBody(missing).code, 'PDF_BROWSER_NOT_FOUND');
|
||||
assert.ok(jsonBody(missing).error.includes('ECC_PLAN_CANVAS_CHROME_PATH'));
|
||||
assert.ok(!missing.body.includes('/Users/private'));
|
||||
} finally {
|
||||
pdfFailure = null;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => {
|
||||
|
||||
Reference in New Issue
Block a user