diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 03cb7ae..dd13a15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -185,10 +185,11 @@ function App(): JSX.Element { try { const response = await fetch('/api/elements', { headers: tenantHeaders() }) const result: ApiResponse = await response.json() - + if (result.success && result.elements) { if (result.elements.length === 0) { excalidrawAPI?.updateScene({ elements: [] }) + lastSyncedElementsRef.current = new Map() return } const cleanedElements = result.elements.map(cleanElementForExcalidraw) @@ -200,6 +201,30 @@ function App(): JSX.Element { const convertedElements = convertElementsPreservingImageProps(cleanedElements) excalidrawAPI?.updateScene({ elements: convertedElements }) } + + // Populate sync baseline so deletions are detected on next sync + const baselineMap = new Map() + for (const el of result.elements) { + baselineMap.set(el.id, el) + } + lastSyncedElementsRef.current = baselineMap + } + + // Fetch current sync version so delta sync works correctly + try { + const versionRes = await fetch('/api/sync/version', { headers: tenantHeaders() }) + const versionData = await versionRes.json() + if (versionData.success && typeof versionData.syncVersion === 'number') { + lastSyncVersionRef.current = versionData.syncVersion + lastReceivedSyncVersionRef.current = versionData.syncVersion + localStorage.setItem('excalidraw-last-sync-version', String(versionData.syncVersion)) + } + } catch {} + + // Set hash baseline so auto-sync doesn't immediately re-sync unchanged content + if (excalidrawAPI) { + const sceneElements = excalidrawAPI.getSceneElements() + lastSyncedHashRef.current = computeElementHash(sceneElements) } // Also load files (image data) @@ -313,6 +338,16 @@ function App(): JSX.Element { lastReceivedSyncVersionRef.current = data.currentSyncVersion lastSyncVersionRef.current = data.currentSyncVersion localStorage.setItem('excalidraw-last-sync-version', String(data.currentSyncVersion)) + // Update sync baseline so deletion detection works after resync + if (api) { + const activeElements = api.getSceneElements().filter(el => !el.isDeleted) + const baselineMap = new Map() + for (const el of normalizeForBackend(activeElements)) { + baselineMap.set(el.id, el) + } + lastSyncedElementsRef.current = baselineMap + lastSyncedHashRef.current = computeElementHash(api.getSceneElements()) + } console.log(`Delta resync complete: received ${data.serverChanges.length} changes, now at v${data.currentSyncVersion}`) } } catch (err) { @@ -353,6 +388,12 @@ function App(): JSX.Element { elements: convertedElements, captureUpdate: CaptureUpdateAction.NEVER }) + // Update sync baseline for deletion detection + const initBaseline = new Map() + for (const el of data.elements) { + initBaseline.set(el.id, el) + } + lastSyncedElementsRef.current = initBaseline } break @@ -702,6 +743,14 @@ function App(): JSX.Element { elements: converted, captureUpdate: CaptureUpdateAction.NEVER }) + // Update sync baseline for deletion detection + const helloBaseline = new Map() + for (const el of data.elements) { + helloBaseline.set(el.id, el) + } + lastSyncedElementsRef.current = helloBaseline + } else if (data.elements && data.elements.length === 0) { + lastSyncedElementsRef.current = new Map() } break diff --git a/src/index.ts b/src/index.ts index 0b4d5a2..e2573f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1841,10 +1841,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) } catch {} } - if (params.mode === 'replace') { - await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() }); - } - // Batch create the imported elements const elementsToCreate = importElements.map(el => ({ ...el, @@ -1854,7 +1850,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) version: 1 })); - const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate); + if (params.mode === 'replace') { + // Backup current elements before clearing to prevent data loss + const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() }); + const backupData = await backupResp.json() as ApiResponse; + const backupElements = backupData.elements || []; + + await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() }); + + try { + await batchCreateElementsOnCanvas(elementsToCreate); + } catch (createError) { + // Restore backup atomically to prevent data loss + logger.error('Import failed after clear, restoring backup:', (createError as Error).message); + if (backupElements.length > 0) { + await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, { + method: 'POST', + headers: canvasHeaders(), + body: JSON.stringify({ elements: backupElements }) + }); + } + throw new Error(`Import failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`); + } + } else { + await batchCreateElementsOnCanvas(elementsToCreate); + } return { content: [{ @@ -1926,24 +1946,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) logger.info('Duplicating elements via MCP', { count: params.elementIds.length }); - const duplicates: ServerElement[] = []; + // Build ID map first so binding references can be remapped + const idMap = new Map(); + const originals: ServerElement[] = []; for (const id of params.elementIds) { const original = await getElementFromCanvas(id); if (!original) { logger.warn(`Element ${id} not found, skipping duplicate`); continue; } + const newId = generateId(); + idMap.set(id, newId); + originals.push(original); + } + const duplicates: ServerElement[] = []; + for (const original of originals) { const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original; const duplicate: ServerElement = { ...rest, - id: generateId(), + id: idMap.get(original.id) || generateId(), x: original.x + offsetX, y: original.y + offsetY, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1 }; + + // Remap binding references to point to duplicated elements + const dup = duplicate as any; + if (dup.startElementId && idMap.has(dup.startElementId)) { + dup.startElementId = idMap.get(dup.startElementId); + } + if (dup.endElementId && idMap.has(dup.endElementId)) { + dup.endElementId = idMap.get(dup.endElementId); + } + if (dup.start?.id && idMap.has(dup.start.id)) { + dup.start = { ...dup.start, id: idMap.get(dup.start.id) }; + } + if (dup.end?.id && idMap.has(dup.end.id)) { + dup.end = { ...dup.end, id: idMap.get(dup.end.id) }; + } + if (Array.isArray(dup.boundElements)) { + dup.boundElements = dup.boundElements.map((be: any) => ({ + ...be, + id: idMap.has(be.id) ? idMap.get(be.id) : be.id + })); + } + if (dup.containerId && idMap.has(dup.containerId)) { + dup.containerId = idMap.get(dup.containerId); + } + duplicates.push(duplicate); } @@ -1998,10 +2051,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } }; + // Backup current elements before clearing to prevent data loss + const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() }); + const backupData = await backupResp.json() as ApiResponse; + const backupElements = backupData.elements || []; + await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() }); - // Restore elements - const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements); + // Restore elements from snapshot + try { + await batchCreateElementsOnCanvas(data.snapshot.elements); + } catch (createError) { + // Restore backup atomically to prevent data loss + logger.error('Snapshot restore failed after clear, restoring backup:', (createError as Error).message); + if (backupElements.length > 0) { + await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, { + method: 'POST', + headers: canvasHeaders(), + body: JSON.stringify({ elements: backupElements }) + }); + } + throw new Error(`Snapshot restore failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`); + } return { content: [{ diff --git a/tests/backend/arrow-bindings.test.ts b/tests/backend/arrow-bindings.test.ts new file mode 100644 index 0000000..2af6f45 --- /dev/null +++ b/tests/backend/arrow-bindings.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { initDb, closeDb, setElement, setActiveTenant } 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 makeRect(overrides: Partial = {}): ServerElement { + return { + id: `rect-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: 'rectangle', + x: 0, + y: 0, + width: 150, + height: 80, + version: 1, + ...overrides, + }; +} + +function makeEllipse(overrides: Partial = {}): ServerElement { + return { + id: `ell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: 'ellipse', + x: 0, + y: 0, + width: 120, + height: 120, + version: 1, + ...overrides, + }; +} + +function makeDiamond(overrides: Partial = {}): ServerElement { + return { + id: `dia-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: 'diamond', + x: 0, + y: 0, + width: 100, + height: 100, + version: 1, + ...overrides, + }; +} + +function makeArrow(id: string, startId?: string, endId?: string): any { + return { + id, + type: 'arrow', + x: 0, + y: 0, + width: 100, + height: 0, + ...(startId ? { start: { id: startId } } : {}), + ...(endId ? { end: { id: endId } } : {}), + }; +} + +beforeEach(async () => { + dbPath = path.join(os.tmpdir(), `excalidraw-arrow-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 {} + } +}); + +// ─── Arrow Binding Resolution via Batch Create ────────────── + +describe('Arrow binding resolution - rectangles', () => { + it('resolves arrow between two rectangles', async () => { + const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 }); + const r2 = makeRect({ id: 'r2', x: 300, y: 0, width: 100, height: 50 }); + const arrow = makeArrow('a1', 'r1', 'r2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r1, r2, arrow] }); + + expect(res.body.success).toBe(true); + const createdArrow = res.body.elements.find((e: any) => e.id === 'a1'); + expect(createdArrow).toBeDefined(); + // Arrow should have computed start/end points + expect(typeof createdArrow.x).toBe('number'); + expect(typeof createdArrow.y).toBe('number'); + expect(typeof createdArrow.width).toBe('number'); + expect(typeof createdArrow.height).toBe('number'); + }); + + it('arrow points are positioned between the two rectangles', async () => { + const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 }); + const r2 = makeRect({ id: 'r2', x: 400, y: 0, width: 100, height: 50 }); + const arrow = makeArrow('a1', 'r1', 'r2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r1, r2, arrow] }); + + const a = res.body.elements.find((e: any) => e.id === 'a1'); + // Arrow should have reasonable coordinates between the two shapes + // The exact positions depend on edge-point computation; just verify it's between the two shape centers + expect(a.x).toBeGreaterThanOrEqual(0); + expect(a.x + a.width).toBeLessThanOrEqual(600); + }); +}); + +describe('Arrow binding resolution - ellipses', () => { + it('resolves arrow between two ellipses', async () => { + const e1 = makeEllipse({ id: 'e1', x: 0, y: 0, width: 80, height: 80 }); + const e2 = makeEllipse({ id: 'e2', x: 300, y: 0, width: 80, height: 80 }); + const arrow = makeArrow('ae1', 'e1', 'e2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [e1, e2, arrow] }); + + expect(res.body.success).toBe(true); + const a = res.body.elements.find((e: any) => e.id === 'ae1'); + expect(a).toBeDefined(); + }); +}); + +describe('Arrow binding resolution - diamonds', () => { + it('resolves arrow between two diamonds', async () => { + const d1 = makeDiamond({ id: 'd1', x: 0, y: 0, width: 100, height: 100 }); + const d2 = makeDiamond({ id: 'd2', x: 300, y: 0, width: 100, height: 100 }); + const arrow = makeArrow('ad1', 'd1', 'd2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [d1, d2, arrow] }); + + expect(res.body.success).toBe(true); + const a = res.body.elements.find((e: any) => e.id === 'ad1'); + expect(a).toBeDefined(); + }); +}); + +describe('Arrow binding resolution - mixed shapes', () => { + it('resolves arrow from rectangle to ellipse', async () => { + const r = makeRect({ id: 'mr', x: 0, y: 0, width: 100, height: 50 }); + const e = makeEllipse({ id: 'me', x: 300, y: 0, width: 80, height: 80 }); + const arrow = makeArrow('ma1', 'mr', 'me'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r, e, arrow] }); + + expect(res.body.success).toBe(true); + }); + + it('resolves arrow from diamond to rectangle', async () => { + const d = makeDiamond({ id: 'md', x: 0, y: 0, width: 100, height: 100 }); + const r = makeRect({ id: 'mr2', x: 300, y: 0, width: 150, height: 80 }); + const arrow = makeArrow('ma2', 'md', 'mr2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [d, r, arrow] }); + + expect(res.body.success).toBe(true); + }); +}); + +describe('Arrow binding resolution - edge cases', () => { + it('arrow with only start binding', async () => { + const r = makeRect({ id: 'so', x: 0, y: 0, width: 100, height: 50 }); + const arrow = makeArrow('sa1', 'so', undefined); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r, arrow] }); + + expect(res.body.success).toBe(true); + }); + + it('arrow with only end binding', async () => { + const r = makeRect({ id: 'eo', x: 300, y: 0, width: 100, height: 50 }); + const arrow = makeArrow('ea1', undefined, 'eo'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r, arrow] }); + + expect(res.body.success).toBe(true); + }); + + it('arrow referencing non-existent element does not crash', async () => { + const arrow = makeArrow('ghost-arrow', 'nonexistent-1', 'nonexistent-2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [arrow] }); + + expect(res.body.success).toBe(true); + }); + + it('arrow between overlapping shapes (same center)', async () => { + const r1 = makeRect({ id: 'ov1', x: 100, y: 100, width: 100, height: 50 }); + const r2 = makeRect({ id: 'ov2', x: 100, y: 100, width: 100, height: 50 }); + const arrow = makeArrow('ova', 'ov1', 'ov2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r1, r2, arrow] }); + + // Should not crash even with identical centers (dx=0, dy=0) + expect(res.body.success).toBe(true); + }); + + it('arrow between vertically aligned shapes', async () => { + const r1 = makeRect({ id: 'vr1', x: 100, y: 0, width: 100, height: 50 }); + const r2 = makeRect({ id: 'vr2', x: 100, y: 300, width: 100, height: 50 }); + const arrow = makeArrow('va', 'vr1', 'vr2'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r1, r2, arrow] }); + + expect(res.body.success).toBe(true); + const a = res.body.elements.find((e: any) => e.id === 'va'); + // Arrow should connect shapes that are vertically aligned — just verify it exists and has valid dimensions + expect(typeof a.width).toBe('number'); + expect(typeof a.height).toBe('number'); + }); + + it('cross-batch arrow referencing pre-existing element', async () => { + // Create a shape first + setElement('pre-existing', makeRect({ id: 'pre-existing', x: 0, y: 0, width: 100, height: 50 })); + + // Batch create an arrow referencing the pre-existing shape + const r2 = makeRect({ id: 'batch-r', x: 300, y: 0, width: 100, height: 50 }); + const arrow = makeArrow('cross-arrow', 'pre-existing', 'batch-r'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r2, arrow] }); + + expect(res.body.success).toBe(true); + }); + + it('multiple arrows between same two shapes', async () => { + const r1 = makeRect({ id: 'multi-r1', x: 0, y: 0, width: 100, height: 50 }); + const r2 = makeRect({ id: 'multi-r2', x: 300, y: 0, width: 100, height: 50 }); + const a1 = makeArrow('multi-a1', 'multi-r1', 'multi-r2'); + const a2 = makeArrow('multi-a2', 'multi-r2', 'multi-r1'); + + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: [r1, r2, a1, a2] }); + + expect(res.body.success).toBe(true); + expect(res.body.elements).toHaveLength(4); + }); +}); diff --git a/tests/backend/mcp-tools-integration.test.ts b/tests/backend/mcp-tools-integration.test.ts new file mode 100644 index 0000000..4bfe063 --- /dev/null +++ b/tests/backend/mcp-tools-integration.test.ts @@ -0,0 +1,522 @@ +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 { + 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'); + 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'); + 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'); + + 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'); + 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'); + 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); + }); +}); diff --git a/tests/backend/security.test.ts b/tests/backend/security.test.ts new file mode 100644 index 0000000..a97496f --- /dev/null +++ b/tests/backend/security.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { initDb, closeDb, setActiveTenant } from '../../src/db.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; +let app: any; + +beforeEach(async () => { + dbPath = path.join(os.tmpdir(), `excalidraw-security-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 {} + } +}); + +// ─── Input Validation ─────────────────────────────────────── + +describe('Input validation - element creation', () => { + it('rejects element with missing type', async () => { + const res = await request(app) + .post('/api/elements') + .send({ x: 0, y: 0, width: 100, height: 50 }); + + expect(res.status).toBe(400); + }); + + it('rejects element with invalid type', async () => { + const res = await request(app) + .post('/api/elements') + .send({ type: 'malicious