Files
excalidraw-mcp-sentinel/tests/backend/mcp-tools-integration.test.ts
T
newblaccandClaude Sonnet 4.6 5539235004 feat(security): harden canvas server with auth, rate-limiting, and validation
- Add security.ts: helmet, CORS allowlist, timing-safe API key auth, prototype
  pollution guard, Mermaid input limits, rate limiting (general/destructive/burst)
- WS auth challenge-response with 5 s timeout and close code 4001
- Fix sync crash: array check before logger access (500 → 400)
- Fix sync/v2: validate element type before write (invalid → 400)
- Upgrade zod 3.22.4 → 3.25.5 (fixes ERR_PACKAGE_PATH_NOT_EXPORTED on startup)
- Extract ElementSharedFieldsSchema; move VALID_ELEMENT_TYPES to module level
- Docker: resource limits, .dockerignore hardening
- Add .project-hooks/pre-commit; expand test coverage (369 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:06:04 +02:00

523 lines
17 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, getCurrentSyncVersion } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let app: any;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Clear Canvas Token Flow (via REST) ─────────────────────
// Simulates the clear_canvas MCP tool's token-based confirmation
describe('Clear canvas confirmation flow', () => {
it('DELETE /api/elements/clear removes all elements', async () => {
setElement('cl-1', makeElement({ id: 'cl-1' }));
setElement('cl-2', makeElement({ id: 'cl-2' }));
expect(getAllElements()).toHaveLength(2);
const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.body.success).toBe(true);
expect(res.body.count).toBeDefined();
expect(getAllElements()).toHaveLength(0);
});
it('clear on empty canvas returns zero count', async () => {
const res = await request(app).delete('/api/elements/clear?confirm=true');
expect(res.body.success).toBe(true);
});
it('cleared elements stay gone on subsequent GET requests', async () => {
setElement('stay-gone', makeElement({ id: 'stay-gone' }));
await request(app).delete('/api/elements/clear?confirm=true');
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(0);
}
});
});
// ─── Import Scene (Replace Mode) ────────────────────────────
// Tests the REST layer that import_scene MCP tool uses
describe('Import scene - replace mode via sync', () => {
it('POST /api/elements/sync replaces all elements atomically', async () => {
setElement('old-1', makeElement({ id: 'old-1' }));
setElement('old-2', makeElement({ id: 'old-2' }));
const newElements = [
makeElement({ id: 'new-1', x: 0 }),
makeElement({ id: 'new-2', x: 100 }),
makeElement({ id: 'new-3', x: 200 }),
];
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: newElements });
expect(res.body.success).toBe(true);
const elements = getAllElements();
expect(elements).toHaveLength(3);
const ids = elements.map(e => e.id).sort();
expect(ids).toEqual(['new-1', 'new-2', 'new-3']);
});
it('POST /api/elements/sync with empty array clears all', async () => {
setElement('will-be-replaced', makeElement({ id: 'will-be-replaced' }));
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: [] });
expect(res.body.success).toBe(true);
expect(getAllElements()).toHaveLength(0);
});
it('old elements do not reappear after replace', async () => {
setElement('ghost', makeElement({ id: 'ghost' }));
await request(app)
.post('/api/elements/sync')
.send({ elements: [makeElement({ id: 'replacement' })] });
// Multiple GET requests should consistently show only the replacement
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].id).toBe('replacement');
}
});
});
// ─── Import Scene (Merge Mode) ──────────────────────────────
describe('Import scene - merge mode via batch', () => {
it('POST /api/elements/batch adds without removing existing', async () => {
setElement('existing', makeElement({ id: 'existing', x: 0 }));
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
makeElement({ id: 'imported-1', x: 100 }),
makeElement({ id: 'imported-2', x: 200 }),
],
});
expect(res.body.success).toBe(true);
const elements = getAllElements();
expect(elements).toHaveLength(3);
const ids = elements.map(e => e.id).sort();
expect(ids).toEqual(['existing', 'imported-1', 'imported-2']);
});
});
// ─── Restore Snapshot ───────────────────────────────────────
describe('Snapshot create and restore flow', () => {
it('save snapshot, clear, verify snapshot still exists', async () => {
setElement('snap-1', makeElement({ id: 'snap-1' }));
setElement('snap-2', makeElement({ id: 'snap-2' }));
// Save snapshot
const snapRes = await request(app)
.post('/api/snapshots')
.send({ name: 'before-clear' });
expect(snapRes.body.success).toBe(true);
// Clear
await request(app).delete('/api/elements/clear?confirm=true');
expect(getAllElements()).toHaveLength(0);
// Snapshot should still contain the elements
const getRes = await request(app).get('/api/snapshots/before-clear');
expect(getRes.body.success).toBe(true);
expect(getRes.body.snapshot.elements).toHaveLength(2);
});
it('restore via sync endpoint preserves all snapshot elements', async () => {
const elements = [
makeElement({ id: 'rs-1', x: 0 }),
makeElement({ id: 'rs-2', x: 100 }),
];
for (const el of elements) setElement(el.id, el);
// Save snapshot
await request(app).post('/api/snapshots').send({ name: 'restore-test' });
// Clear and add different elements
await request(app).delete('/api/elements/clear?confirm=true');
setElement('different', makeElement({ id: 'different' }));
// Get snapshot
const snapRes = await request(app).get('/api/snapshots/restore-test');
const snapshotElements = snapRes.body.snapshot.elements;
// Restore via sync (atomic replace)
const syncRes = await request(app)
.post('/api/elements/sync')
.send({ elements: snapshotElements });
expect(syncRes.body.success).toBe(true);
// Verify restored state
const final = getAllElements();
expect(final).toHaveLength(2);
const ids = final.map(e => e.id).sort();
expect(ids).toEqual(['rs-1', 'rs-2']);
});
it('restore non-existent snapshot returns 404', async () => {
const res = await request(app).get('/api/snapshots/nonexistent');
expect(res.status).toBe(404);
});
it('snapshot overwrites with same name', async () => {
setElement('v1-el', makeElement({ id: 'v1-el' }));
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
setElement('v2-el', makeElement({ id: 'v2-el' }));
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
const res = await request(app).get('/api/snapshots/overwrite-test');
expect(res.body.snapshot.elements).toHaveLength(2); // Both elements
});
});
// ─── Duplicate Elements ─────────────────────────────────────
describe('Duplicate elements via API', () => {
it('duplicating elements creates new IDs', async () => {
setElement('dup-src', makeElement({ id: 'dup-src', x: 0, y: 0 }));
// Get the original
const getRes = await request(app).get('/api/elements/dup-src');
expect(getRes.body.success).toBe(true);
// Create a duplicate via batch (simulating what duplicate_elements does)
const original = getRes.body.element;
const duplicate = {
...original,
id: 'dup-copy',
x: original.x + 20,
y: original.y + 20,
};
const batchRes = await request(app)
.post('/api/elements/batch')
.send({ elements: [duplicate] });
expect(batchRes.body.success).toBe(true);
expect(getAllElements()).toHaveLength(2);
});
it('duplicated arrow with remapped bindings points to duplicated shapes', async () => {
// Create shape + arrow
const rect = makeElement({ id: 'dup-rect', x: 0, y: 0, width: 100, height: 50 });
const rect2 = makeElement({ id: 'dup-rect2', x: 300, y: 0, width: 100, height: 50 });
setElement('dup-rect', rect);
setElement('dup-rect2', rect2);
// Create arrow binding references
const arrow = {
id: 'dup-arrow',
type: 'arrow',
x: 100, y: 25,
width: 200, height: 0,
start: { id: 'dup-rect' },
end: { id: 'dup-rect2' },
};
// Simulate duplication with ID remapping
const idMap = new Map([
['dup-rect', 'copy-rect'],
['dup-rect2', 'copy-rect2'],
['dup-arrow', 'copy-arrow'],
]);
const dupArrow: any = {
...arrow,
id: 'copy-arrow',
x: arrow.x + 20,
y: arrow.y + 20,
start: { id: idMap.get(arrow.start.id) || arrow.start.id },
end: { id: idMap.get(arrow.end.id) || arrow.end.id },
};
expect(dupArrow.start.id).toBe('copy-rect');
expect(dupArrow.end.id).toBe('copy-rect2');
// Create the duplicated shapes and arrow
const batchRes = await request(app)
.post('/api/elements/batch')
.send({
elements: [
makeElement({ id: 'copy-rect', x: 20, y: 20, width: 100, height: 50 }),
makeElement({ id: 'copy-rect2', x: 320, y: 20, width: 100, height: 50 }),
dupArrow,
],
});
expect(batchRes.body.success).toBe(true);
const createdArrow = batchRes.body.elements.find((e: any) => e.id === 'copy-arrow');
expect(createdArrow).toBeDefined();
});
});
// ─── Mermaid Conversion Relay ───────────────────────────────
describe('Mermaid conversion relay', () => {
it('POST /api/elements/from-mermaid accepts valid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({
mermaidDiagram: 'graph TD\n A-->B',
config: {},
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.mermaidDiagram).toBe('graph TD\n A-->B');
expect(res.body.message).toContain('frontend');
});
it('rejects empty mermaid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({ mermaidDiagram: '' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects missing mermaid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({});
expect(res.status).toBe(400);
});
it('accepts diagram with config options', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({
mermaidDiagram: 'sequenceDiagram\n A->>B: Hello',
config: { theme: 'dark' },
});
expect(res.body.success).toBe(true);
expect(res.body.config).toEqual({ theme: 'dark' });
});
});
// ─── Image Export Relay ─────────────────────────────────────
describe('Image export relay', () => {
it('POST /api/export/image without connected browser returns 503', async () => {
const res = await request(app)
.post('/api/export/image')
.send({ format: 'png', background: true });
expect(res.status).toBe(503);
expect(res.body.success).toBe(false);
});
it('POST /api/export/image accepts captureViewport parameter', async () => {
const res = await request(app)
.post('/api/export/image')
.send({ format: 'png', background: true, captureViewport: true });
// Will be 503 since no browser, but should not 400 on the parameter
expect(res.status).toBe(503);
});
});
// ─── Viewport Relay ─────────────────────────────────────────
describe('Viewport relay', () => {
it('POST /api/viewport without connected browser returns 503', async () => {
const res = await request(app)
.post('/api/viewport')
.send({ action: 'scrollToContent' });
expect(res.status).toBe(503);
});
it('accepts various viewport actions', async () => {
for (const action of ['scrollToContent', 'zoomToFit']) {
const res = await request(app)
.post('/api/viewport')
.send({ action });
// 503 expected (no browser), but validates the action is accepted
expect(res.status).toBe(503);
}
});
});
// ─── Files API ──────────────────────────────────────────────
describe('Files API comprehensive', () => {
it('GET /api/files returns empty initially', async () => {
const res = await request(app).get('/api/files');
expect(res.body.success).toBe(true);
expect(Object.keys(res.body.files)).toHaveLength(0);
});
it('POST /api/files adds files and GET returns them', async () => {
await request(app)
.post('/api/files')
.send({
files: {
'f1': { id: 'f1', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
'f2': { id: 'f2', mimeType: 'image/jpeg', dataURL: 'data:image/jpeg;base64,xyz', created: Date.now() },
},
});
const res = await request(app).get('/api/files');
expect(Object.keys(res.body.files)).toHaveLength(2);
expect(res.body.files['f1'].mimeType).toBe('image/png');
expect(res.body.files['f2'].mimeType).toBe('image/jpeg');
});
it('DELETE /api/files/:id removes the file', async () => {
await request(app)
.post('/api/files')
.send({
files: {
'del-f': { id: 'del-f', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
},
});
const delRes = await request(app).delete('/api/files/del-f');
expect(delRes.body.success).toBe(true);
const listRes = await request(app).get('/api/files');
expect(listRes.body.files['del-f']).toBeUndefined();
});
it('DELETE /api/files/:id for non-existent file returns 404', async () => {
const res = await request(app).delete('/api/files/nonexistent');
expect(res.status).toBe(404);
});
it('POST /api/files rejects non-object body', async () => {
const res = await request(app)
.post('/api/files')
.send({ files: 'not-an-object' });
expect(res.status).toBe(400);
});
});
// ─── Sync Status ────────────────────────────────────────────
describe('Sync status endpoint', () => {
it('GET /api/sync/status returns element count', async () => {
setElement('ss-1', makeElement({ id: 'ss-1' }));
setElement('ss-2', makeElement({ id: 'ss-2' }));
const res = await request(app).get('/api/sync/status');
expect(res.body.success).toBe(true);
expect(res.body.elementCount).toBe(2);
});
});
// ─── Element Version History ────────────────────────────────
describe('Element version history via API', () => {
it('element has version after creation and update', async () => {
const createRes = await request(app)
.post('/api/elements')
.send({ id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
expect(createRes.body.success).toBe(true);
const updateRes = await request(app)
.put('/api/elements/hist-el')
.send({ x: 500 });
expect(updateRes.body.success).toBe(true);
const getRes = await request(app).get('/api/elements/hist-el');
expect(getRes.body.element.x).toBe(500);
});
});
// ─── Error Handling ─────────────────────────────────────────
describe('API error handling', () => {
it('POST /api/elements with invalid JSON returns 400', async () => {
const res = await request(app)
.post('/api/elements')
.set('Content-Type', 'application/json')
.send('not-json');
// Express body-parser returns 400 or 500 on parse failure depending on version
expect([400, 500]).toContain(res.status);
});
it('PUT /api/elements/:id on non-existent element returns 404', async () => {
const res = await request(app)
.put('/api/elements/nonexistent')
.send({ x: 100 });
expect(res.status).toBe(404);
});
it('DELETE /api/elements/:id on non-existent returns 404', async () => {
const res = await request(app)
.delete('/api/elements/nonexistent');
expect(res.status).toBe(404);
});
it('POST /api/elements/batch rejects non-array elements', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: 'not-an-array' });
expect(res.status).toBe(400);
});
it('POST /api/snapshots rejects missing name', async () => {
const res = await request(app)
.post('/api/snapshots')
.send({});
expect(res.status).toBe(400);
});
});