Files
excalidraw-mcp-sentinel/tests/backend/ws-auth.test.ts
T
newblaccandClaude Opus 4.6 15a5cfcc61 chore: add security tests and SECURITY.md (previously untracked)
- 9 backend security test files (auth, headers, rate-limit, middleware
  order, smoke, validation, WS auth, integration bootstrap)
- 1 e2e test (clear-preference)
- SECURITY.md policy doc

These files powered the 369-test suite but were never committed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:03:50 +02:00

251 lines
8.5 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import {
initDb,
closeDb,
clearElements,
ensureTenant,
getDefaultProjectForTenant,
setActiveTenant,
setElement,
} from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import WebSocket from 'ws';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let port: number;
let startCanvasServer: () => Promise<void>;
let stopCanvasServer: () => Promise<void>;
function connectAndCollect(waitMs = 300): Promise<{ ws: WebSocket; messages: any[] }> {
return new Promise((resolve, reject) => {
const messages: any[] = [];
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('message', (raw) => messages.push(JSON.parse(raw.toString())));
ws.on('open', () => setTimeout(() => resolve({ ws, messages }), waitMs));
ws.on('error', reject);
});
}
function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs);
const handler = (data: WebSocket.RawData) => {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timer);
ws.off('message', handler);
resolve(msg);
}
};
ws.on('message', handler);
});
}
function waitForClose(ws: WebSocket, timeoutMs = 7000): Promise<{ code: number; reason: string }> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout waiting for close')), timeoutMs);
ws.on('close', (code, reason) => {
clearTimeout(timer);
resolve({ code, reason: reason.toString() });
});
});
}
function collectMessagesFor(ws: WebSocket, durationMs: number): Promise<any[]> {
return new Promise((resolve) => {
const messages: any[] = [];
const handler = (data: WebSocket.RawData) => messages.push(JSON.parse(data.toString()));
ws.on('message', handler);
setTimeout(() => {
ws.off('message', handler);
resolve(messages);
}, durationMs);
});
}
beforeAll(async () => {
port = 3400 + Math.floor(Math.random() * 100);
process.env.CANVAS_PORT = String(port);
process.env.HOST = 'localhost';
dbPath = path.join(os.tmpdir(), `excalidraw-ws-auth-test-${Date.now()}.db`);
initDb(dbPath);
const mod = await import('../../src/server.js');
startCanvasServer = mod.startCanvasServer;
stopCanvasServer = mod.stopCanvasServer;
await startCanvasServer();
});
afterAll(async () => {
delete process.env.EXCALIDRAW_API_KEY;
await stopCanvasServer();
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
beforeEach(() => {
delete process.env.EXCALIDRAW_API_KEY;
setActiveTenant('default');
clearElements();
});
describe('WebSocket auth gate', () => {
it('auth enabled: WS connection receives auth_required and no element data before hello', async () => {
process.env.EXCALIDRAW_API_KEY = 'test-secret';
const { ws, messages } = await connectAndCollect();
const types = messages.map(m => m.type);
expect(types).toContain('auth_required');
expect(types).not.toContain('tenant_switched');
expect(types).not.toContain('initial_elements');
expect(types).not.toContain('files_added');
expect(types).not.toContain('sync_status');
ws.terminate();
});
it('auth enabled: WS closes with 4001 if no valid hello arrives within 5s', async () => {
process.env.EXCALIDRAW_API_KEY = 'test-secret';
const { ws, messages } = await connectAndCollect();
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
const authFailedPromise = waitForMessageOfType(ws, 'auth_failed', 7000);
const closePromise = waitForClose(ws, 7000);
const [authFailed, close] = await Promise.all([authFailedPromise, closePromise]);
expect(authFailed.reason).toBe('timeout');
expect(close.code).toBe(4001);
});
it('auth enabled: hello with valid apiKey and no tenantId bootstraps the active tenant', async () => {
process.env.EXCALIDRAW_API_KEY = 'test-secret';
ensureTenant('boot-tenant', 'Boot Tenant', 'workspace/boot-tenant');
setActiveTenant('boot-tenant');
const { ws, messages } = await connectAndCollect();
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 5000);
ws.send(JSON.stringify({ type: 'hello', apiKey: 'test-secret' }));
const ack = await ackPromise;
expect(ack.tenantId).toBe('boot-tenant');
expect(ack.tenant.id).toBe('boot-tenant');
expect(ack.projectId).toBe(getDefaultProjectForTenant('boot-tenant'));
ws.close();
});
it('auth enabled: hello with wrong apiKey sends auth_failed and closes 4001', async () => {
process.env.EXCALIDRAW_API_KEY = 'test-secret';
const { ws, messages } = await connectAndCollect();
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
const authFailedPromise = waitForMessageOfType(ws, 'auth_failed', 5000);
const closePromise = waitForClose(ws, 5000);
ws.send(JSON.stringify({ type: 'hello', apiKey: 'wrong-key' }));
const [authFailed, close] = await Promise.all([authFailedPromise, closePromise]);
expect(authFailed.reason).toBe('invalid_key');
expect(close.code).toBe(4001);
});
it('auth enabled: hello with valid apiKey and unknown tenantId sends error and no elements', async () => {
process.env.EXCALIDRAW_API_KEY = 'test-secret';
const { ws, messages } = await connectAndCollect();
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
const errorPromise = waitForMessageOfType(ws, 'error', 5000);
ws.send(JSON.stringify({ type: 'hello', apiKey: 'test-secret', tenantId: 'missing-tenant' }));
const error = await errorPromise;
expect(error.message).toBe('Unknown tenant');
const trailingMessages = await collectMessagesFor(ws, 300);
expect(trailingMessages.some(msg => msg.type === 'hello_ack')).toBe(false);
ws.close();
});
it('auth enabled: invalid projectId falls back to the tenant default project', async () => {
process.env.EXCALIDRAW_API_KEY = 'test-secret';
ensureTenant('scope-a', 'Scope A', 'workspace/scope-a');
ensureTenant('scope-b', 'Scope B', 'workspace/scope-b');
const defaultProjectId = getDefaultProjectForTenant('scope-a');
const otherProjectId = getDefaultProjectForTenant('scope-b');
setElement('scope-a-element', {
id: 'scope-a-element',
type: 'rectangle',
x: 10,
y: 10,
width: 50,
height: 50,
version: 1,
} as ServerElement, defaultProjectId);
setElement('scope-b-element', {
id: 'scope-b-element',
type: 'ellipse',
x: 20,
y: 20,
width: 60,
height: 60,
version: 1,
} as ServerElement, otherProjectId);
const { ws, messages } = await connectAndCollect();
expect(messages.some(m => m.type === 'auth_required')).toBe(true);
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 5000);
ws.send(JSON.stringify({
type: 'hello',
apiKey: 'test-secret',
tenantId: 'scope-a',
projectId: otherProjectId,
}));
const ack = await ackPromise;
expect(ack.tenantId).toBe('scope-a');
expect(ack.projectId).toBe(defaultProjectId);
expect(ack.elements.map((element: any) => element.id)).toContain('scope-a-element');
expect(ack.elements.map((element: any) => element.id)).not.toContain('scope-b-element');
ws.close();
});
it('auth disabled: WS connection receives tenant_switched and initial_elements immediately', async () => {
delete process.env.EXCALIDRAW_API_KEY;
const { ws, messages } = await connectAndCollect();
const types = messages.map(m => m.type);
expect(types).toContain('tenant_switched');
expect(types).toContain('initial_elements');
expect(types).toContain('sync_status');
expect(types).not.toContain('auth_required');
ws.close();
});
it('auth disabled: hello without apiKey still works and receives hello_ack', async () => {
delete process.env.EXCALIDRAW_API_KEY;
const { ws } = await connectAndCollect();
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 5000);
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
const ack = await ackPromise;
expect(ack.tenantId).toBe('default');
expect(Array.isArray(ack.elements)).toBe(true);
ws.close();
});
});