Files
excalidraw-mcp-sentinel/tests/e2e/sync-flows.spec.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

552 lines
19 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
const API = 'http://127.0.0.1:3100';
async function resetCanvas(request: any): Promise<void> {
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
await Promise.all(
(listBody.elements ?? []).map((element: { id: string }) =>
request.delete(`${API}/api/elements/${element.id}`)
)
);
}
test.beforeEach(async ({ request }) => {
await resetCanvas(request);
});
// ─── Helpers ────────────────────────────────────────────────
async function waitForConnected(page: Page): Promise<void> {
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
}
async function waitForElements(request: any, expectedCount: number, timeoutMs = 5000): Promise<any[]> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await request.get(`${API}/api/elements`);
const body = await res.json();
if (body.count === expectedCount) return body.elements;
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Timed out waiting for ${expectedCount} elements`);
}
async function getServerElementCount(request: any): Promise<number> {
const res = await request.get(`${API}/api/elements`);
const body = await res.json();
return body.count;
}
async function getSyncVersion(request: any): Promise<number> {
const res = await request.get(`${API}/api/sync/version`);
const body = await res.json();
return body.syncVersion;
}
// ─── THE Critical Regression Test ───────────────────────────
// This is the exact scenario that was broken: delete in UI → sync → reload → elements gone
test.describe('Delete + Sync + Reload persistence', () => {
test('elements deleted via API stay gone after page reload', async ({ page, request }) => {
// 1. Create elements on server
await request.post(`${API}/api/elements`, {
data: { id: 'del-r1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'del-r2', type: 'ellipse', x: 400, y: 100, width: 150, height: 150 },
});
// 2. Load the page, verify elements loaded
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500); // Let elements render
// 3. Delete them via sync/v2 (simulating what the Sync button does after UI deletion)
const syncVersion = await getSyncVersion(request);
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: syncVersion,
changes: [
{ id: 'del-r1', action: 'delete' },
{ id: 'del-r2', action: 'delete' },
],
},
});
const syncBody = await syncRes.json();
expect(syncBody.success).toBe(true);
expect(syncBody.appliedCount).toBe(2);
// 4. Verify server has 0 elements
expect(await getServerElementCount(request)).toBe(0);
// 5. Reload the page
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// 6. Verify elements are still gone on server (the regression was here)
expect(await getServerElementCount(request)).toBe(0);
});
test('sync button persists deletions that survive reload', async ({ page, request }) => {
// 1. Create elements on server
await request.post(`${API}/api/elements`, {
data: { id: 'sb-1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'sb-2', type: 'text', x: 100, y: 300, text: 'To be deleted' },
});
// 2. Load the page
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(1000); // Let elements load + sync baseline populate
// 3. Delete elements via delta sync (simulating UI delete + Sync button)
const v = await getSyncVersion(request);
await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: v,
changes: [
{ id: 'sb-1', action: 'delete' },
{ id: 'sb-2', action: 'delete' },
],
},
});
// 4. Reload
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// 5. Verify no elements on server
const count = await getServerElementCount(request);
expect(count).toBe(0);
// 6. Reload again to double-check
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Delta Sync v2 E2E ──────────────────────────────────────
test.describe('Delta sync v2 E2E', () => {
test('frontend delta sync creates elements that persist', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
// Simulate what the frontend does: send a sync with upserts
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: 0,
changes: [
{ id: 'ds-e2e-1', action: 'upsert', element: { id: 'ds-e2e-1', type: 'rectangle', x: 50, y: 50, width: 100, height: 60 } },
{ id: 'ds-e2e-2', action: 'upsert', element: { id: 'ds-e2e-2', type: 'ellipse', x: 200, y: 50, width: 80, height: 80 } },
],
},
});
const body = await res.json();
expect(body.appliedCount).toBe(2);
// Reload and verify they persist
await page.reload();
await waitForConnected(page);
const elements = await waitForElements(request, 2);
const ids = elements.map((e: any) => e.id).sort();
expect(ids).toEqual(['ds-e2e-1', 'ds-e2e-2']);
});
test('delta sync handles mixed create+delete+update', async ({ request }) => {
// Create initial elements
await request.post(`${API}/api/elements`, {
data: { id: 'mix-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'mix-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
});
const v = await getSyncVersion(request);
// Mixed operation: delete mix-1, update mix-2, create mix-3
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: v,
changes: [
{ id: 'mix-1', action: 'delete' },
{ id: 'mix-2', action: 'upsert', element: { id: 'mix-2', type: 'ellipse', x: 300, y: 100, width: 80, height: 80 } },
{ id: 'mix-3', action: 'upsert', element: { id: 'mix-3', type: 'text', x: 50, y: 200, text: 'New' } },
],
},
});
const body = await res.json();
expect(body.appliedCount).toBe(3);
// Verify final state
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
expect(listBody.count).toBe(2);
const ids = listBody.elements.map((e: any) => e.id).sort();
expect(ids).toEqual(['mix-2', 'mix-3']);
});
test('server returns MCP-created elements as serverChanges', async ({ request }) => {
// MCP creates an element (via normal API)
await request.post(`${API}/api/elements`, {
data: { id: 'mcp-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
// Frontend syncs from version 0
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: { lastSyncVersion: 0, changes: [] },
});
const body = await res.json();
const serverIds = body.serverChanges.map((c: any) => c.id);
expect(serverIds).toContain('mcp-el');
});
});
// ─── Auto-sync behavior ─────────────────────────────────────
test.describe('Auto-sync toggle', () => {
test('auto-sync button toggles state', async ({ page }) => {
await page.goto('/');
await waitForConnected(page);
const autoSaveBtn = page.locator('button[title*="Auto-sync"]');
await expect(autoSaveBtn).toBeVisible();
// Check initial state (should show the sun/moon icon and be clickable)
await autoSaveBtn.click();
// Second click toggles back
await autoSaveBtn.click();
// No crash = success
});
});
// ─── Sync Version Tracking E2E ──────────────────────────────
test.describe('Sync version tracking', () => {
test('sync version increases after each mutation', async ({ request }) => {
const v0 = await getSyncVersion(request);
await request.post(`${API}/api/elements`, {
data: { id: 'sv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
const v1 = await getSyncVersion(request);
expect(v1).toBeGreaterThan(v0);
await request.put(`${API}/api/elements/sv-1`, {
data: { x: 50 },
});
const v2 = await getSyncVersion(request);
expect(v2).toBeGreaterThan(v1);
await request.delete(`${API}/api/elements/sv-1`);
const v3 = await getSyncVersion(request);
expect(v3).toBeGreaterThan(v2);
});
test('batch create increments sync version for each element', async ({ request }) => {
const v0 = await getSyncVersion(request);
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'bsv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'bsv-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'bsv-3', type: 'text', x: 50, y: 100, text: 'Test' },
],
},
});
const v1 = await getSyncVersion(request);
expect(v1).toBeGreaterThanOrEqual(v0 + 3);
});
});
// ─── Real-time Sync (MCP→Canvas) ────────────────────────────
test.describe('MCP to Canvas real-time sync', () => {
test('element created via API appears on canvas via WebSocket', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
// Create element via API
await request.post(`${API}/api/elements`, {
data: { id: 'rt-el', type: 'rectangle', x: 100, y: 100, width: 200, height: 100, backgroundColor: '#ff0000' },
});
// Wait for canvas to receive it via WS
await page.waitForTimeout(1000);
// Verify element is on the canvas (check via API since we can't easily inspect Excalidraw internals)
const elements = await waitForElements(request, 1);
expect(elements[0].id).toBe('rt-el');
});
test('batch create appears on canvas without reload', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'rt-b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'rt-b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
],
},
});
await page.waitForTimeout(1000);
const elements = await waitForElements(request, 2);
expect(elements).toHaveLength(2);
});
test('element update appears on canvas without reload', async ({ page, request }) => {
// Pre-create
await request.post(`${API}/api/elements`, {
data: { id: 'rt-upd', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
// Update
await request.put(`${API}/api/elements/rt-upd`, {
data: { x: 500, y: 500 },
});
await page.waitForTimeout(1000);
// Verify update persisted
const res = await request.get(`${API}/api/elements/rt-upd`);
const body = await res.json();
expect(body.element.x).toBe(500);
expect(body.element.y).toBe(500);
});
test('element delete via API clears from canvas', async ({ page, request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'rt-del', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
await request.delete(`${API}/api/elements/rt-del`);
await page.waitForTimeout(500);
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Clear Canvas E2E ───────────────────────────────────────
test.describe('Clear canvas persistence', () => {
test('clearing via API removes all elements permanently', async ({ page, request }) => {
// Create elements
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'clr-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'clr-2', type: 'text', x: 50, y: 100, text: 'Will be cleared' },
],
},
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
// Clear
await request.delete(`${API}/api/elements/clear?confirm=true`);
await page.waitForTimeout(500);
// Verify gone
expect(await getServerElementCount(request)).toBe(0);
// Reload
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// Still gone
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Snapshot Create + Restore ──────────────────────────────
test.describe('Snapshots E2E', () => {
test('create snapshot, clear, restore, verify elements return', async ({ request }) => {
// Create elements
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'snap-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'snap-2', type: 'text', x: 50, y: 100, text: 'Snapshot test' },
],
},
});
// Save snapshot
const snapRes = await request.post(`${API}/api/snapshots`, {
data: { name: 'test-snap' },
});
expect((await snapRes.json()).success).toBe(true);
// Clear
await request.delete(`${API}/api/elements/clear?confirm=true`);
expect(await getServerElementCount(request)).toBe(0);
// List snapshots
const listRes = await request.get(`${API}/api/snapshots`);
const listBody = await listRes.json();
expect(listBody.snapshots.some((s: any) => s.name === 'test-snap')).toBe(true);
// Get snapshot
const getRes = await request.get(`${API}/api/snapshots/test-snap`);
const getBody = await getRes.json();
expect(getBody.snapshot).toBeDefined();
expect(getBody.snapshot.elements).toHaveLength(2);
});
});
// ─── Settings Persistence ───────────────────────────────────
test.describe('Settings E2E', () => {
test('settings persist across requests', async ({ request }) => {
await request.put(`${API}/api/settings/test_key`, {
data: { value: 'test_value' },
});
const res = await request.get(`${API}/api/settings/test_key`);
const body = await res.json();
expect(body.value).toBe('test_value');
});
});
// ─── Files API E2E ──────────────────────────────────────────
test.describe('Files API E2E', () => {
test('add and list files', async ({ request }) => {
const addRes = await request.post(`${API}/api/files`, {
data: {
files: {
'file-1': {
id: 'file-1',
mimeType: 'image/png',
dataURL: 'data:image/png;base64,iVBOR...',
created: Date.now(),
},
},
},
});
expect((await addRes.json()).success).toBe(true);
const listRes = await request.get(`${API}/api/files`);
const listBody = await listRes.json();
expect(listBody.files['file-1']).toBeDefined();
expect(listBody.files['file-1'].mimeType).toBe('image/png');
});
test('delete file', async ({ request }) => {
await request.post(`${API}/api/files`, {
data: {
files: {
'file-del': {
id: 'file-del',
mimeType: 'image/png',
dataURL: 'data:image/png;base64,abc',
created: Date.now(),
},
},
},
});
const delRes = await request.delete(`${API}/api/files/file-del`);
expect((await delRes.json()).success).toBe(true);
const listRes = await request.get(`${API}/api/files`);
const listBody = await listRes.json();
expect(listBody.files['file-del']).toBeUndefined();
});
});
// ─── Search API E2E ─────────────────────────────────────────
test.describe('Search E2E', () => {
test('search by type returns matching elements', async ({ request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'srch-r', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'srch-e', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'srch-t', type: 'text', x: 50, y: 100, text: 'Search me' },
],
},
});
// Filter by type
const res = await request.get(`${API}/api/elements/search?type=rectangle`);
const body = await res.json();
expect(body.elements.length).toBe(1);
expect(body.elements[0].type).toBe('rectangle');
});
test('full-text search finds elements by label', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'fts-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, label: { text: 'Authentication Service' } },
});
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
const body = await res.json();
expect(body.elements.length).toBeGreaterThanOrEqual(1);
expect(body.elements.some((e: any) => e.id === 'fts-el')).toBe(true);
});
});
// ─── Tenant API E2E ─────────────────────────────────────────
test.describe('Tenant management E2E', () => {
test('list tenants returns at least default', async ({ request }) => {
const res = await request.get(`${API}/api/tenants`);
const body = await res.json();
expect(body.tenants.length).toBeGreaterThanOrEqual(1);
});
test('active tenant is available', async ({ request }) => {
const res = await request.get(`${API}/api/tenant/active`);
const body = await res.json();
expect(body.tenant).toBeDefined();
expect(body.tenant.id).toBeDefined();
});
});
// ─── Element Version History E2E ────────────────────────────
test.describe('Element version history E2E', () => {
test('element history tracks create and update', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await request.put(`${API}/api/elements/hist-el`, {
data: { x: 500 },
});
// Get element to verify it exists and is updated
const getRes = await request.get(`${API}/api/elements/hist-el`);
const body = await getRes.json();
expect(body.element.x).toBe(500);
});
});