test: add phase security/smoke/e2e coverage and tighten MCP contract handling

This commit is contained in:
newblacc
2026-03-30 15:52:24 +02:00
parent a605a15282
commit f1db126566
22 changed files with 1986 additions and 136 deletions
+396
View File
@@ -0,0 +1,396 @@
/**
* Unit tests for src/db.ts
*
* Covers: migrations, tenant isolation, FTS search, snapshots,
* element_versions tracking, generateId uniqueness, global state race.
* All tests use a real SQLite database in a tmpdir.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
initDb,
closeDb,
ensureTenant,
setActiveTenant,
getActiveTenantId,
getActiveProjectId,
setElement,
getElement,
getAllElements,
deleteElement,
searchElements,
saveSnapshot,
getSnapshot,
getElementHistory,
createProject,
getDefaultProjectForTenant,
getCurrentSyncVersion,
incrementSyncVersion,
} from '../../src/db.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
function tmpDb(label: string): string {
return path.join(
os.tmpdir(),
`excalidraw-db-unit-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
);
}
function cleanupDb(dbPath: string): void {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch { /* ignore */ }
}
}
function makeEl(id: string, overrides: Record<string, any> = {}) {
return { id, type: 'rectangle', x: 0, y: 0, width: 100, height: 50, ...overrides };
}
// ── WAL mode ──────────────────────────────────────────────────────────────────
describe('SQLite configuration', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('config');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('enables WAL journal mode', () => {
// After initDb the WAL file should be created alongside the DB
// (or journal_mode pragma returns 'wal').
// We verify indirectly: the -wal sidecar file exists after a write.
setElement('el-wal', makeEl('el-wal'));
const walPath = dbPath + '-wal';
// WAL file may or may not exist depending on checkpoint state, but
// the DB must at least have been created without error.
expect(fs.existsSync(dbPath)).toBe(true);
});
});
// ── Migrations ────────────────────────────────────────────────────────────────
describe('Migrations', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('migrations');
});
afterEach(() => cleanupDb(dbPath));
it('runs successfully on a fresh database', () => {
expect(() => {
initDb(dbPath);
setActiveTenant('default');
}).not.toThrow();
});
it('is idempotent — calling initDb twice with the same path does not error', () => {
initDb(dbPath);
setActiveTenant('default');
// initDb guards with `if (db) return`, so calling again is a no-op
expect(() => initDb(dbPath)).not.toThrow();
});
});
// ── Element CRUD & tenant isolation ──────────────────────────────────────────
describe('Tenant isolation', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('isolation');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('elements created in tenant A are not visible in tenant B', () => {
// Create tenant A + project, write an element
ensureTenant('tenant-a', 'Tenant A', '/ws/a');
setActiveTenant('tenant-a');
const projA = getDefaultProjectForTenant('tenant-a');
setElement('el-a', makeEl('el-a'), projA);
// Create tenant B + project, write a different element
ensureTenant('tenant-b', 'Tenant B', '/ws/b');
setActiveTenant('tenant-b');
const projB = getDefaultProjectForTenant('tenant-b');
setElement('el-b', makeEl('el-b'), projB);
// Tenant A's project sees only el-a
const elemsA = getAllElements(projA);
expect(elemsA.map(e => e.id)).toContain('el-a');
expect(elemsA.map(e => e.id)).not.toContain('el-b');
// Tenant B's project sees only el-b
const elemsB = getAllElements(projB);
expect(elemsB.map(e => e.id)).toContain('el-b');
expect(elemsB.map(e => e.id)).not.toContain('el-a');
});
it('getElement with explicit projectId enforces project scope', () => {
ensureTenant('tenant-c', 'Tenant C', '/ws/c');
const projC = getDefaultProjectForTenant('tenant-c');
setElement('el-c', makeEl('el-c'), projC);
// The default project should NOT see el-c
const found = getElement('el-c', 'default');
expect(found).toBeUndefined();
// The correct project SHOULD see el-c
const foundCorrect = getElement('el-c', projC);
expect(foundCorrect).toBeDefined();
expect(foundCorrect!.id).toBe('el-c');
});
// DESIGN NOTE: setActiveTenant() mutates module-level `activeTenantId` and
// `activeProjectId`. Any code path that calls db functions WITHOUT an explicit
// projectId override uses the current global value. If two logical "sessions"
// call setActiveTenant() in an interleaved order, the later call wins.
// The test below demonstrates this using explicit projectId overrides (the safe
// API), contrasted with the module-global fallback.
it('DESIGN GAP: global activeTenantId is shared across all callers without explicit projectId', () => {
ensureTenant('tenant-x', 'X', '/ws/x');
ensureTenant('tenant-y', 'Y', '/ws/y');
const projX = getDefaultProjectForTenant('tenant-x');
const projY = getDefaultProjectForTenant('tenant-y');
// Session 1 sets active tenant to X and writes an element via global state
setActiveTenant('tenant-x');
expect(getActiveTenantId()).toBe('tenant-x');
// Simulate session 2 switching tenant before session 1 does its DB work
setActiveTenant('tenant-y');
// Now session 1's db call (no explicit projectId) will use Y's project
setElement('el-contaminated', makeEl('el-contaminated')); // uses activeProjectId = projY
// The element landed in Y's project, not X's
expect(getElement('el-contaminated', projY)).toBeDefined();
expect(getElement('el-contaminated', projX)).toBeUndefined();
});
});
// ── FTS Search ────────────────────────────────────────────────────────────────
describe('FTS search', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('fts');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('finds elements by label text', () => {
setElement('el-fts1', makeEl('el-fts1', { label: { text: 'Excalidraw Canvas' } }));
setElement('el-fts2', makeEl('el-fts2', { label: { text: 'Something Else' } }));
const results = searchElements('Excalidraw', 'default');
expect(results.map(e => e.id)).toContain('el-fts1');
expect(results.map(e => e.id)).not.toContain('el-fts2');
});
it('finds elements by type', () => {
setElement('el-rect', { id: 'el-rect', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
setElement('el-dia', { id: 'el-dia', type: 'diamond', x: 0, y: 0, width: 50, height: 50 });
const results = searchElements('diamond', 'default');
expect(results.map(e => e.id)).toContain('el-dia');
expect(results.map(e => e.id)).not.toContain('el-rect');
});
it('does not return deleted elements', () => {
setElement('el-del', makeEl('el-del', { label: { text: 'FindMe' } }));
deleteElement('el-del', 'default');
const results = searchElements('FindMe', 'default');
expect(results.map(e => e.id)).not.toContain('el-del');
});
});
// ── Soft delete ───────────────────────────────────────────────────────────────
describe('Soft delete', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('softdelete');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('deleted element is not returned by getAllElements', () => {
setElement('el-to-delete', makeEl('el-to-delete'));
deleteElement('el-to-delete', 'default');
const all = getAllElements('default');
expect(all.map(e => e.id)).not.toContain('el-to-delete');
});
it('deleted element is not returned by getElement', () => {
setElement('el-gone', makeEl('el-gone'));
deleteElement('el-gone', 'default');
expect(getElement('el-gone', 'default')).toBeUndefined();
});
it('re-inserting a deleted element revives it', () => {
setElement('el-revive', makeEl('el-revive'));
deleteElement('el-revive', 'default');
setElement('el-revive', makeEl('el-revive', { x: 99 }));
const el = getElement('el-revive', 'default');
expect(el).toBeDefined();
expect(el!.x).toBe(99);
});
});
// ── element_versions ─────────────────────────────────────────────────────────
describe('element_versions history', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('versions');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('records a create operation', () => {
setElement('el-hist', makeEl('el-hist'));
const history = getElementHistory('el-hist', 50, 'default');
expect(history.length).toBeGreaterThanOrEqual(1);
expect(history.some(h => h.operation === 'create')).toBe(true);
});
it('records an update operation after second setElement', () => {
setElement('el-hist2', makeEl('el-hist2'));
setElement('el-hist2', makeEl('el-hist2', { x: 42 }));
const history = getElementHistory('el-hist2', 50, 'default');
expect(history.some(h => h.operation === 'update')).toBe(true);
});
it('records a delete operation', () => {
setElement('el-hist3', makeEl('el-hist3'));
deleteElement('el-hist3', 'default');
const history = getElementHistory('el-hist3', 50, 'default');
expect(history.some(h => h.operation === 'delete')).toBe(true);
});
});
// ── Snapshot round-trip ───────────────────────────────────────────────────────
describe('Snapshot save / restore', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('snapshot');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('saves and retrieves a named snapshot', () => {
const elements = [makeEl('snap-el-1'), makeEl('snap-el-2')];
saveSnapshot('my-snap', elements, 'default');
const snap = getSnapshot('my-snap', 'default');
expect(snap).toBeDefined();
expect(snap!.name).toBe('my-snap');
expect(snap!.elements).toHaveLength(2);
expect(snap!.elements.map((e: any) => e.id)).toContain('snap-el-1');
});
it('snapshot content is independent of subsequent mutations', () => {
setElement('snap-live', makeEl('snap-live', { x: 10 }));
saveSnapshot('before-move', [makeEl('snap-live', { x: 10 })], 'default');
// Mutate the live element
setElement('snap-live', makeEl('snap-live', { x: 999 }));
// Snapshot still has the original coordinates
const snap = getSnapshot('before-move', 'default');
expect(snap!.elements[0].x).toBe(10);
});
it('returns undefined for non-existent snapshot name', () => {
expect(getSnapshot('does-not-exist', 'default')).toBeUndefined();
});
});
// ── generateId uniqueness ─────────────────────────────────────────────────────
describe('createProject — generateId uniqueness', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('genid');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('generates unique project IDs across 200 rapid sequential calls', () => {
const ids = new Set<string>();
for (let i = 0; i < 200; i++) {
const project = createProject(`proj-${i}`);
ids.add(project.id);
}
// All IDs must be unique
expect(ids.size).toBe(200);
});
});
// ── Sync version ──────────────────────────────────────────────────────────────
describe('sync version monotonicity', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tmpDb('syncver');
initDb(dbPath);
setActiveTenant('default');
});
afterEach(() => cleanupDb(dbPath));
it('sync version increases monotonically across setElement calls', () => {
const v0 = getCurrentSyncVersion('default');
setElement('sv-el1', makeEl('sv-el1'));
const v1 = getCurrentSyncVersion('default');
setElement('sv-el2', makeEl('sv-el2'));
const v2 = getCurrentSyncVersion('default');
expect(v1).toBeGreaterThan(v0);
expect(v2).toBeGreaterThan(v1);
});
it('sync version is isolated per project (explicit projectId)', () => {
ensureTenant('sv-tenant', 'SV Tenant', '/ws/sv');
const projSv = getDefaultProjectForTenant('sv-tenant');
const defaultV0 = getCurrentSyncVersion('default');
const svV0 = getCurrentSyncVersion(projSv);
setElement('sv-isolated', makeEl('sv-isolated'), projSv);
// Only the sv project's version should increment
expect(getCurrentSyncVersion(projSv)).toBeGreaterThan(svV0);
expect(getCurrentSyncVersion('default')).toBe(defaultV0);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { server, tools } from '../../src/index.js';
let client: Client;
beforeAll(async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
client = new Client({ name: 'mcp-contract-test-client', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
});
afterAll(async () => {
await client.close();
await server.close();
});
describe('MCP contract', () => {
it('tools/list returns all declared tools', async () => {
const listed = await client.listTools();
expect(Array.isArray(listed.tools)).toBe(true);
expect(listed.tools.length).toBe(32);
expect(listed.tools.length).toBe(tools.length);
});
it('tools/call unknown tool returns MethodNotFound (-32601)', async () => {
await expect(
client.callTool({
name: '__unknown_tool__',
arguments: {},
})
).rejects.toMatchObject({
code: ErrorCode.MethodNotFound,
});
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* Tests for security gaps on MCP-adjacent paths.
*
* CONTEXT: Express middleware (sanitizeBody, apiKeyAuth, rate limiting) only
* runs on HTTP requests. MCP tool calls arrive over stdio and call db functions
* directly — bypassing all Express middleware.
*
* These tests:
* 1. Confirm sanitizeBody WORKS on REST paths (baseline proof it's applied).
* 2. Document the adversarial JSON parsing scenarios that the MCP import_scene
* handler faces without any Express-layer protection.
* 3. Test path traversal blocking on export endpoints (shared logic with MCP).
*/
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-mcp-sanit-${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 { /* ignore */ }
}
});
// ── Prototype pollution guard — REST layer ────────────────────────────────────
describe('sanitizeBody middleware — REST path coverage', () => {
it('rejects POST body containing __proto__ key → 400', async () => {
const res = await request(app)
.post('/api/elements')
.set('Content-Type', 'application/json')
.send('{"__proto__": {"isAdmin": true}, "type": "rectangle", "x": 0, "y": 0}');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/disallowed keys/i);
});
it('rejects POST body containing constructor key → 400', async () => {
const res = await request(app)
.post('/api/elements')
.set('Content-Type', 'application/json')
.send('{"constructor": {"name": "evil"}, "type": "rectangle", "x": 0, "y": 0}');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/disallowed keys/i);
});
it('rejects POST body containing nested __proto__ → 400', async () => {
const res = await request(app)
.post('/api/elements')
.set('Content-Type', 'application/json')
.send('{"element": {"__proto__": {"evil": true}}, "type": "rectangle", "x": 0, "y": 0}');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/disallowed keys/i);
});
it('accepts clean POST body → not 400', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
expect(res.status).not.toBe(400);
});
});
// ── MCP import_scene adversarial JSON — documented gap ───────────────────────
//
// MCP tool calls reach `import_scene` via stdio → index.ts.
// The handler does: `sceneData = JSON.parse(params.data)` with no sanitization.
//
// SAFETY NOTE: In modern Node.js (V8 ≥ 8.x), JSON.parse does NOT pollute
// Object.prototype when encountering `{"__proto__": ...}` — it creates a plain
// key named "__proto__" on the result object without calling [[Set]] on the
// prototype chain. However, downstream code that uses Object.assign() or
// spread {...sceneData} can re-trigger pollution if the key is spread into
// an object whose prototype is Object.prototype.
//
// The tests below are NOT executable without a running MCP stdio process.
// They are represented as unit assertions on the JSON.parse behaviour itself
// to document the exact risk surface.
describe('MCP import_scene — JSON.parse prototype behaviour (gap documentation)', () => {
it('JSON.parse with __proto__ key does NOT pollute Object.prototype in modern Node', () => {
// This is the safety net we rely on. If this test ever fails, the MCP path
// is directly exploitable for prototype pollution.
const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}');
// The key exists as a plain own property, not as a prototype mutation
expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true);
expect((Object.prototype as any).isAdmin).toBeUndefined();
});
it('Object.assign with a JSON-parsed __proto__ key mutates the spread target prototype chain', () => {
// CONFIRMED REAL BEHAVIOUR: Object.assign({}, parsed) where parsed has a
// "__proto__" own key (from JSON.parse) triggers the __proto__ setter on
// Object.prototype, which changes the *target* object's prototype to the
// value. This means `cloned.injected` resolves via prototype lookup.
//
// This does NOT pollute Object.prototype itself — only the cloned object's
// prototype chain. But any code in index.ts that does `{ ...sceneData }` or
// `Object.assign({}, sceneData)` after JSON.parse on MCP input is affected.
const parsed = JSON.parse('{"__proto__": {"injected": true}}') as any;
// Verify parsed has __proto__ as an own property (not prototype pollution)
expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true);
expect((Object.prototype as any).injected).toBeUndefined(); // Object.prototype is clean
// Spreading/assigning DOES change the target's prototype:
const cloned = Object.assign({}, parsed);
expect((cloned as any).injected).toBe(true); // inherited from mutated prototype
// Object.prototype is still clean after the spread
expect((Object.prototype as any).injected).toBeUndefined();
});
it('deeply nested JSON (depth 1000) does not cause stack overflow during JSON.parse', () => {
// MCP import_scene does JSON.parse on user-supplied data with no depth limit.
// Node.js JSON.parse handles deep nesting iteratively — verify it does not
// blow the call stack at practical depths.
const depth = 1000;
const nested = '['.repeat(depth) + '1' + ']'.repeat(depth);
expect(() => JSON.parse(nested)).not.toThrow();
});
it('HYPOTHESIS: extremely deep nesting (depth 100_000) may throw in some runtimes', () => {
// Document the practical limit. If this throws a RangeError (stack overflow),
// the MCP import_scene handler is vulnerable to DoS via deeply nested payloads.
const depth = 100_000;
const nested = '['.repeat(depth) + '1' + ']'.repeat(depth);
// We only assert "does not silently succeed with wrong data" — either it
// parses correctly or throws a catchable error (not a process crash).
let threw = false;
try {
JSON.parse(nested);
} catch {
threw = true;
}
// Either outcome is acceptable — the key assertion is that the process survives
expect(true).toBe(true);
});
});
// ── Path traversal — export endpoint (shared sanitizeFilePath logic) ──────────
describe('Path traversal on export endpoints', () => {
it('POST /api/export/image with path traversal in filePath → error (not 200)', async () => {
const res = await request(app)
.post('/api/export/image')
.send({
filePath: '../../../../etc/passwd',
format: 'png'
});
// Should not be 200 — either 400 (validation) or 500 (server error before write)
expect(res.status).not.toBe(200);
});
it('POST /api/export/image with absolute path outside cwd → error (not 200)', async () => {
const outsidePath = '/tmp/traversal-test-excalidraw.png';
const res = await request(app)
.post('/api/export/image')
.send({
filePath: outsidePath,
format: 'png'
});
expect(res.status).not.toBe(200);
});
});
// ── Null-byte injection ───────────────────────────────────────────────────────
describe('Null byte and encoding edge cases', () => {
it('POST /api/elements with null byte in type field does not crash server', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle\x00', x: 0, y: 0, width: 100, height: 50 });
// Must return a 4xx — not 200 and not an unhandled 500
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
it('POST /api/elements/batch with oversized element text does not hang', async () => {
// Verify server responds within reasonable time even with a large text field
// (this is a regression guard — a 413 or 400 is both acceptable)
const res = await request(app)
.post('/api/elements')
.set('Content-Type', 'application/json')
.send(JSON.stringify({
type: 'text',
x: 0, y: 0, width: 100, height: 50,
text: 'A'.repeat(200 * 1024) // 200 KB — over the 100 KB limit
}));
expect(res.status).toBe(413);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Unit tests for src/security.ts
*
* Covers: validateApiKey, sanitizeSearchQuery, sanitizeBody behaviour.
* No server or DB required — pure function tests.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
validateApiKey,
sanitizeSearchQuery,
InvalidSearchQueryError,
isAuthEnabled,
} from '../../src/security.js';
// ── validateApiKey ────────────────────────────────────────────────────────────
describe('validateApiKey — auth disabled', () => {
beforeEach(() => { delete process.env.EXCALIDRAW_API_KEY; });
it('returns true for any value when no API key env var is set', () => {
expect(validateApiKey('anything')).toBe(true);
expect(validateApiKey(undefined)).toBe(true);
expect(validateApiKey('')).toBe(true);
});
it('isAuthEnabled returns false when env var is unset', () => {
expect(isAuthEnabled()).toBe(false);
});
});
describe('validateApiKey — auth enabled', () => {
const CORRECT_KEY = 'super-secret-key-32chars!!!!!!!!';
beforeEach(() => { process.env.EXCALIDRAW_API_KEY = CORRECT_KEY; });
afterEach(() => { delete process.env.EXCALIDRAW_API_KEY; });
it('returns true for exact match', () => {
expect(validateApiKey(CORRECT_KEY)).toBe(true);
});
it('returns false for undefined', () => {
expect(validateApiKey(undefined)).toBe(false);
});
it('returns false for empty string', () => {
expect(validateApiKey('')).toBe(false);
});
it('returns false for array (non-string type guard)', () => {
expect(validateApiKey(['correct'] as any)).toBe(false);
});
it('returns false for a wrong key of the SAME length — timingSafeEqual path', () => {
// Same length forces the timingSafeEqual code path (not the early-exit).
// timingSafeEqual must not throw when buffers are the same length.
const sameLen = 'X'.repeat(CORRECT_KEY.length);
expect(() => validateApiKey(sameLen)).not.toThrow();
expect(validateApiKey(sameLen)).toBe(false);
});
it('returns false for correct key with one extra char (different length)', () => {
// DESIGN NOTE: the current implementation returns false early when lengths
// differ, without calling timingSafeEqual. This means an attacker probing
// keys of length 1..N can infer the correct key length via response-time
// differences. Documented here as a known design decision.
expect(validateApiKey(CORRECT_KEY + 'x')).toBe(false);
});
it('returns false for correct key with one char missing', () => {
expect(validateApiKey(CORRECT_KEY.slice(0, -1))).toBe(false);
});
it('returns false for key that differs only in one character', () => {
// Replace last char with something definitely different from the original
const lastChar = CORRECT_KEY[CORRECT_KEY.length - 1]!;
const differentChar = lastChar === 'Z' ? 'A' : 'Z';
const almostRight = CORRECT_KEY.slice(0, -1) + differentChar;
expect(validateApiKey(almostRight)).toBe(false);
});
it('isAuthEnabled returns true when env var is set', () => {
expect(isAuthEnabled()).toBe(true);
});
});
// ── sanitizeSearchQuery ───────────────────────────────────────────────────────
describe('sanitizeSearchQuery — valid inputs', () => {
it('trims whitespace and returns clean query', () => {
expect(sanitizeSearchQuery(' hello world ')).toBe('hello world');
});
it('returns empty string for whitespace-only input', () => {
expect(sanitizeSearchQuery(' ')).toBe('');
});
it('allows plain alphanumeric query', () => {
expect(sanitizeSearchQuery('rectangle')).toBe('rectangle');
});
it('allows hyphenated terms', () => {
expect(sanitizeSearchQuery('my-diagram')).toBe('my-diagram');
});
it('allows numbers', () => {
expect(sanitizeSearchQuery('123')).toBe('123');
});
});
describe('sanitizeSearchQuery — FTS operator injection', () => {
it('throws on double-quote character', () => {
expect(() => sanitizeSearchQuery('"quoted phrase"')).toThrow(InvalidSearchQueryError);
});
it('throws on AND operator (uppercase)', () => {
expect(() => sanitizeSearchQuery('foo AND bar')).toThrow(InvalidSearchQueryError);
});
it('throws on AND operator (lowercase)', () => {
expect(() => sanitizeSearchQuery('foo and bar')).toThrow(InvalidSearchQueryError);
});
it('throws on OR operator', () => {
expect(() => sanitizeSearchQuery('foo OR bar')).toThrow(InvalidSearchQueryError);
});
it('throws on NOT operator', () => {
expect(() => sanitizeSearchQuery('NOT secret')).toThrow(InvalidSearchQueryError);
});
it('throws on NEAR operator', () => {
expect(() => sanitizeSearchQuery('foo NEAR bar')).toThrow(InvalidSearchQueryError);
});
it('throws on NEAR/N distance syntax', () => {
expect(() => sanitizeSearchQuery('foo NEAR/5 bar')).toThrow(InvalidSearchQueryError);
});
it('throws on glob wildcard *', () => {
expect(() => sanitizeSearchQuery('pass*')).toThrow(InvalidSearchQueryError);
});
it('throws on parentheses (grouping)', () => {
expect(() => sanitizeSearchQuery('(foo bar)')).toThrow(InvalidSearchQueryError);
});
it('throws on curly braces', () => {
expect(() => sanitizeSearchQuery('{foo}')).toThrow(InvalidSearchQueryError);
});
it('throws on caret prefix-weight operator', () => {
expect(() => sanitizeSearchQuery('^important')).toThrow(InvalidSearchQueryError);
});
it('throws on colon column-filter syntax (FTS5 column filter)', () => {
// "label_text:secret" would scope the search to a single FTS column.
// Fixed: colon is now a blocked character.
expect(() => sanitizeSearchQuery('label_text:secret')).toThrow(InvalidSearchQueryError);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import WebSocket from 'ws';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { closeDb, initDb } from '../../src/db.js';
let port: number;
let dbPath: string;
let startCanvasServer: (() => Promise<void>) | undefined;
let stopCanvasServer: (() => Promise<void>) | undefined;
function waitForOpen(ws: WebSocket, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs);
ws.once('open', () => {
clearTimeout(timer);
resolve();
});
ws.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
beforeAll(async () => {
port = 3600 + Math.floor(Math.random() * 200);
dbPath = path.join(os.tmpdir(), `excalidraw-smoke-ws-${Date.now()}.db`);
process.env.CANVAS_PORT = String(port);
process.env.HOST = 'localhost';
delete process.env.EXCALIDRAW_API_KEY;
process.env.EXCALIDRAW_DB_PATH = dbPath;
initDb(dbPath);
const serverMod = await import('../../src/server.js');
startCanvasServer = serverMod.startCanvasServer;
stopCanvasServer = serverMod.stopCanvasServer;
await startCanvasServer();
});
afterAll(async () => {
if (stopCanvasServer) {
await stopCanvasServer();
}
closeDb();
delete process.env.CANVAS_PORT;
delete process.env.HOST;
delete process.env.EXCALIDRAW_DB_PATH;
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
describe('Smoke WS + persistence checks', () => {
it('creates SQLite database file', () => {
expect(fs.existsSync(dbPath)).toBe(true);
});
it('accepts WebSocket connection and reports websocket_clients in /health', async () => {
const ws = new WebSocket(`ws://localhost:${port}`);
await waitForOpen(ws);
const healthRes = await fetch(`http://localhost:${port}/health`);
expect(healthRes.ok).toBe(true);
const healthBody = await healthRes.json() as { websocket_clients: number; status: string };
expect(healthBody.status).toBe('healthy');
expect(healthBody.websocket_clients).toBeGreaterThanOrEqual(1);
ws.close();
});
});
+20
View File
@@ -9,8 +9,12 @@ let dbPath: string;
let app: any;
const frontendDir = path.join(process.cwd(), 'dist/frontend');
const frontendHtmlPath = path.join(frontendDir, 'index.html');
const frontendAssetsDir = path.join(frontendDir, 'assets');
const frontendSmokeAssetPath = path.join(frontendAssetsDir, 'smoke.js');
let originalFrontendHtml: string | null = null;
let hadFrontendHtml = false;
let hadSmokeAsset = false;
let originalSmokeAsset: string | null = null;
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-smoke-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
@@ -18,8 +22,12 @@ beforeEach(async () => {
setActiveTenant('default');
hadFrontendHtml = fs.existsSync(frontendHtmlPath);
originalFrontendHtml = hadFrontendHtml ? fs.readFileSync(frontendHtmlPath, 'utf8') : null;
hadSmokeAsset = fs.existsSync(frontendSmokeAssetPath);
originalSmokeAsset = hadSmokeAsset ? fs.readFileSync(frontendSmokeAssetPath, 'utf8') : null;
fs.mkdirSync(frontendDir, { recursive: true });
fs.mkdirSync(frontendAssetsDir, { recursive: true });
fs.writeFileSync(frontendHtmlPath, '<!doctype html><html><head><title>Smoke</title></head><body><div id="root"></div></body></html>');
fs.writeFileSync(frontendSmokeAssetPath, 'console.log("smoke asset");');
const mod = await import('../../src/server.js');
app = mod.default;
});
@@ -32,6 +40,11 @@ afterEach(() => {
} else {
try { fs.unlinkSync(frontendHtmlPath); } catch {}
}
if (hadSmokeAsset && originalSmokeAsset !== null) {
fs.writeFileSync(frontendSmokeAssetPath, originalSmokeAsset);
} else {
try { fs.unlinkSync(frontendSmokeAssetPath); } catch {}
}
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
@@ -48,6 +61,13 @@ describe('Smoke checks', () => {
expect(rootRes.text).toContain('<div id="root"></div>');
});
it('serves frontend assets from /assets', async () => {
const assetRes = await request(app).get('/assets/smoke.js');
expect(assetRes.status).toBe(200);
expect(assetRes.text).toContain('smoke asset');
expect(assetRes.headers['content-type']).toContain('javascript');
});
it('supports a keyed create-list-delete smoke flow', async () => {
process.env.EXCALIDRAW_API_KEY = 'smoke-secret';
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import {
closeDb,
ensureTenant,
initDb,
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-tenant-authz-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
);
initDb(dbPath);
setActiveTenant('default');
process.env.EXCALIDRAW_API_KEY = 'tenant-secret';
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
delete process.env.EXCALIDRAW_API_KEY;
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
describe('Tenant scoping behavior with API key auth', () => {
it('any valid API key caller can scope into any existing tenant via X-Tenant-Id', async () => {
ensureTenant('tenant-a', 'Tenant A', '/a');
ensureTenant('tenant-b', 'Tenant B', '/b');
await request(app)
.post('/api/elements')
.set('X-API-Key', 'tenant-secret')
.set('X-Tenant-Id', 'tenant-a')
.send({ id: 'a-only', type: 'rectangle', x: 0, y: 0, width: 40, height: 30 });
await request(app)
.post('/api/elements')
.set('X-API-Key', 'tenant-secret')
.set('X-Tenant-Id', 'tenant-b')
.send({ id: 'b-only', type: 'ellipse', x: 0, y: 0, width: 40, height: 30 });
const aRes = await request(app)
.get('/api/elements')
.set('X-API-Key', 'tenant-secret')
.set('X-Tenant-Id', 'tenant-a');
const bRes = await request(app)
.get('/api/elements')
.set('X-API-Key', 'tenant-secret')
.set('X-Tenant-Id', 'tenant-b');
expect(aRes.status).toBe(200);
expect(bRes.status).toBe(200);
expect(aRes.body.elements.map((el: any) => el.id)).toContain('a-only');
expect(aRes.body.elements.map((el: any) => el.id)).not.toContain('b-only');
expect(bRes.body.elements.map((el: any) => el.id)).toContain('b-only');
expect(bRes.body.elements.map((el: any) => el.id)).not.toContain('a-only');
});
it('missing X-Tenant-Id falls back to active tenant context', async () => {
ensureTenant('tenant-fallback', 'Tenant Fallback', '/fallback');
const switchRes = await request(app)
.put('/api/tenant/active')
.set('X-API-Key', 'tenant-secret')
.send({ tenantId: 'tenant-fallback' });
expect(switchRes.status).toBe(200);
await request(app)
.post('/api/elements')
.set('X-API-Key', 'tenant-secret')
.send({ id: 'fallback-el', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 });
const listRes = await request(app)
.get('/api/elements')
.set('X-API-Key', 'tenant-secret');
expect(listRes.status).toBe(200);
expect(listRes.body.elements.map((el: any) => el.id)).toContain('fallback-el');
});
it('unknown X-Tenant-Id is rejected by server behavior (document current trust boundary)', async () => {
const res = await request(app)
.get('/api/elements')
.set('X-API-Key', 'tenant-secret')
.set('X-Tenant-Id', 'does-not-exist');
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { test, expect, type Page } from '@playwright/test';
const API = 'http://127.0.0.1:3100';
async function resetCanvas(request: any): Promise<void> {
await request.delete(`${API}/api/elements/clear?confirm=true`);
}
async function waitForConnected(page: Page): Promise<void> {
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
}
test.beforeEach(async ({ request }) => {
await resetCanvas(request);
});
test.describe('Phase 2 regressions', () => {
test('position stability survives reloads for pre-seeded elements', async ({ page, request }) => {
await request.post(`${API}/api/elements`, {
data: {
id: 'pos-stable-1',
type: 'rectangle',
x: 220,
y: 140,
width: 260,
height: 110,
label: { text: 'Stable Label' },
},
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(800);
const initialRes = await request.get(`${API}/api/elements/pos-stable-1`);
expect(initialRes.ok()).toBe(true);
const initial = (await initialRes.json()).element as {
x: number;
y: number;
width: number;
height: number;
label?: { text?: string };
};
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(800);
const afterReloadRes = await request.get(`${API}/api/elements/pos-stable-1`);
expect(afterReloadRes.ok()).toBe(true);
const afterReload = (await afterReloadRes.json()).element as {
x: number;
y: number;
width: number;
height: number;
label?: { text?: string };
};
expect(afterReload.x).toBe(initial.x);
expect(afterReload.y).toBe(initial.y);
expect(afterReload.width).toBe(initial.width);
expect(afterReload.height).toBe(initial.height);
expect(afterReload.label?.text).toBe('Stable Label');
});
test('new container arrival auto-injects title and subtitle text', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
const createRes = await request.post(`${API}/api/elements`, {
data: {
id: 'auto-title-seed',
type: 'rectangle',
x: 220,
y: 140,
width: 260,
height: 110,
},
});
expect(createRes.ok()).toBe(true);
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /^Sync$/ }).click();
await expect.poll(async () => {
const listRes = await request.get(`${API}/api/elements`);
if (!listRes.ok()) return false;
const listBody = await listRes.json() as { elements: any[] };
const titleText = listBody.elements.find((el) => el.type === 'text' && el.text === 'Title');
const subtitleText = listBody.elements.find((el) => el.type === 'text' && el.text === 'Text here');
return Boolean(titleText && subtitleText);
}, { timeout: 7000 }).toBe(true);
});
test('two connected tabs receive cross-tab sync events', async ({ page, context }) => {
const page2 = await context.newPage();
await page2.addInitScript(() => {
const NativeWS = window.WebSocket;
(window as any).__wsSeenTypes = [] as string[];
const Wrapped = function(this: any, url: string | URL, protocols?: string | string[]) {
const ws = protocols !== undefined ? new NativeWS(url, protocols) : new NativeWS(url);
ws.addEventListener('message', (event) => {
try {
const raw = typeof event.data === 'string' ? event.data : '';
const parsed = JSON.parse(raw);
if (parsed?.type) {
(window as any).__wsSeenTypes.push(parsed.type);
}
} catch {}
});
return ws;
} as any;
Wrapped.prototype = NativeWS.prototype;
Object.assign(Wrapped, NativeWS);
window.WebSocket = Wrapped;
});
await page.goto('/');
await page2.goto('/');
await waitForConnected(page);
await waitForConnected(page2);
const createRes = await page.evaluate(async () => {
const res = await fetch('/api/elements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'two-tab-sync-1',
type: 'rectangle',
x: 30,
y: 40,
width: 120,
height: 70,
}),
});
return { ok: res.ok, status: res.status };
});
expect(createRes.ok).toBe(true);
await expect.poll(async () => {
return await page2.evaluate(() =>
Array.isArray((window as any).__wsSeenTypes) &&
(window as any).__wsSeenTypes.includes('element_created')
);
}, { timeout: 6000 }).toBe(true);
await page2.close();
});
});
+17 -1
View File
@@ -33,7 +33,8 @@ describe('cleanElementForExcalidraw', () => {
expect(cleaned).not.toHaveProperty('createdAt');
expect(cleaned).not.toHaveProperty('updatedAt');
expect(cleaned).not.toHaveProperty('version');
// version is kept — it is the Excalidraw element version, not a DB field
expect(cleaned).toHaveProperty('version', 3);
expect(cleaned).not.toHaveProperty('syncedAt');
expect(cleaned).not.toHaveProperty('source');
expect(cleaned).not.toHaveProperty('syncTimestamp');
@@ -219,6 +220,21 @@ describe('computeElementHash', () => {
const hash = computeElementHash([{ id: 'x', version: 1 }]);
expect(hash.startsWith('1')).toBe(true);
});
it('is order-stable for same id/version set', () => {
const a = [
{ id: 'a', version: 1 },
{ id: 'b', version: 3 },
{ id: 'c', version: 2 },
];
const b = [
{ id: 'c', version: 2 },
{ id: 'a', version: 1 },
{ id: 'b', version: 3 },
];
expect(computeElementHash(a)).toBe(computeElementHash(b));
});
});
// ─── isImageElement ─────────────────────────────────────────
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from 'vitest';
import {
expandLabelsToNative,
prepareElementsForScene,
} from '../../frontend/src/utils/scenePreparation.js';
import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js';
describe('expandLabelsToNative', () => {
it('creates a native bound text element at container center', () => {
const input = [{
id: 'box-1',
type: 'rectangle',
x: 100,
y: 200,
width: 300,
height: 120,
label: { text: 'Title' },
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
}];
const out = expandLabelsToNative(input as any[]);
expect(out).toHaveLength(2);
const container = out.find((el) => el.id === 'box-1') as any;
const text = out.find((el) => el.id === 'box-1_label') as any;
expect(container.boundElements).toEqual([
{ id: 'arrow-1', type: 'arrow' },
{ id: 'box-1_label', type: 'text' },
]);
expect(text.containerId).toBe('box-1');
expect(text.text).toBe('Title');
expect(text.x).toBe(230);
expect(text.y).toBe(250);
});
it('passes through elements with no label.text unchanged', () => {
const a = { id: 'a', type: 'rectangle', x: 0, y: 0, width: 100, height: 40 };
const b = { id: 'b', type: 'text', x: 10, y: 10, text: 'Hello' };
const out = expandLabelsToNative([a, b] as any[]);
expect(out).toEqual([a, b]);
});
});
describe('prepareElementsForScene', () => {
it('routes native browser-synced elements without conversion', () => {
const native = {
id: 'native-1',
type: 'rectangle',
x: 0,
y: 0,
width: 100,
height: 50,
seed: 123,
versionNonce: 456,
version: 1,
} as any as ServerElement;
const converter = vi.fn((elements: readonly any[]) =>
elements.map((el) => ({ ...el, converted: true }))
);
const out = prepareElementsForScene([native], converter as any);
expect(converter).not.toHaveBeenCalled();
expect(out).toHaveLength(1);
expect((out[0] as any).id).toBe('native-1');
expect((out[0] as any).converted).toBeUndefined();
});
it('routes MCP stubs through converter', () => {
const stub = {
id: 'stub-1',
type: 'rectangle',
x: 10,
y: 20,
width: 80,
height: 40,
label: { text: 'Stub' },
version: 1,
} as ServerElement;
const converter = vi.fn((elements: readonly any[]) =>
elements.map((el) => ({ ...el, converted: true }))
);
const out = prepareElementsForScene([stub], converter as any);
expect(converter).toHaveBeenCalledTimes(1);
expect(out.some((el) => (el as any).id === 'stub-1' && (el as any).converted)).toBe(true);
});
});
+3 -2
View File
@@ -12,7 +12,7 @@ import {
// ─── cleanElementForExcalidraw comprehensive ────────────────
describe('cleanElementForExcalidraw - comprehensive', () => {
it('strips all server-only metadata fields', () => {
it('strips server-only metadata fields but preserves Excalidraw version', () => {
const serverEl = {
id: 'el-1',
type: 'rectangle',
@@ -31,7 +31,8 @@ describe('cleanElementForExcalidraw - comprehensive', () => {
const cleaned = cleanElementForExcalidraw(serverEl);
expect(cleaned).not.toHaveProperty('createdAt');
expect(cleaned).not.toHaveProperty('updatedAt');
expect(cleaned).not.toHaveProperty('version');
// version is kept — it is the Excalidraw element version, not a DB field
expect(cleaned).toHaveProperty('version', 1);
expect(cleaned).not.toHaveProperty('syncedAt');
expect(cleaned).not.toHaveProperty('source');
expect(cleaned).not.toHaveProperty('syncTimestamp');