1035 lines
32 KiB
TypeScript
1035 lines
32 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import { WebSocket } from 'ws';
|
|
import { resolve } from 'path';
|
|
import { createServer } from 'net';
|
|
import { GatewayServer } from './server.js';
|
|
import type { GatewayServerConfig } from './server.js';
|
|
import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js';
|
|
import { ErrorCode } from './protocol.js';
|
|
|
|
async function canListenOnLocalhost(): Promise<boolean> {
|
|
return new Promise((resolvePromise) => {
|
|
const s = createServer();
|
|
s.once('error', () => resolvePromise(false));
|
|
s.listen(0, '127.0.0.1', () => {
|
|
s.close(() => resolvePromise(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
let LISTEN_ALLOWED = true;
|
|
|
|
// Minimal mocks for dependencies
|
|
const mockSession = {
|
|
id: 'test',
|
|
addMessage: vi.fn(),
|
|
getHistory: vi.fn(() => []),
|
|
clear: vi.fn(),
|
|
setHistory: vi.fn(),
|
|
replaceHistory: vi.fn(),
|
|
};
|
|
|
|
const mockSessionManager = {
|
|
getSession: vi.fn(() => mockSession),
|
|
getSessionConfig: vi.fn(() => undefined),
|
|
listSessions: vi.fn(() => ['ws:test']),
|
|
transferSession: vi.fn(),
|
|
closeSession: vi.fn(),
|
|
};
|
|
|
|
const mockModelClient = {
|
|
chat: vi.fn(async () => ({
|
|
content: 'Hello from Flynn!',
|
|
stopReason: 'end_turn',
|
|
usage: { inputTokens: 10, outputTokens: 5 },
|
|
})),
|
|
setTier: vi.fn(() => true),
|
|
getTier: vi.fn(() => 'default' as const),
|
|
getLabel: vi.fn((tier: string) => tier),
|
|
setTierStrict: vi.fn(),
|
|
addTierChangeListener: vi.fn(),
|
|
removeTierChangeListener: vi.fn(),
|
|
};
|
|
|
|
const mockToolRegistry = {
|
|
register: vi.fn(),
|
|
get: vi.fn((name: string) => (name === 'shell.exec' ? { name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } } : undefined)),
|
|
list: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]),
|
|
filteredList: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]),
|
|
toAnthropicFormat: vi.fn(() => []),
|
|
toOpenAIFormat: vi.fn(() => []),
|
|
filteredToAnthropicFormat: vi.fn(() => []),
|
|
filteredToOpenAIFormat: vi.fn(() => []),
|
|
};
|
|
|
|
const mockToolExecutor = {
|
|
execute: vi.fn(async () => ({ success: true, output: 'executed' })),
|
|
};
|
|
|
|
const TEST_PORT = 18899;
|
|
|
|
let server: GatewayServer;
|
|
|
|
function createClient(): Promise<WebSocket> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}`);
|
|
ws.on('open', () => resolve(ws));
|
|
ws.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function sendAndReceive(ws: WebSocket, msg: object): Promise<GatewayResponse | GatewayError | GatewayEvent> {
|
|
return new Promise((resolve) => {
|
|
ws.once('message', (data) => {
|
|
resolve(JSON.parse(data.toString()));
|
|
});
|
|
ws.send(JSON.stringify(msg));
|
|
});
|
|
}
|
|
|
|
function sendAndReceiveAll(ws: WebSocket, msg: object, count: number): Promise<Array<GatewayResponse | GatewayError | GatewayEvent>> {
|
|
return new Promise((resolve) => {
|
|
const messages: Array<GatewayResponse | GatewayError | GatewayEvent> = [];
|
|
const handler = (data: Buffer) => {
|
|
messages.push(JSON.parse(data.toString()));
|
|
if (messages.length >= count) {
|
|
ws.off('message', handler);
|
|
resolve(messages);
|
|
}
|
|
};
|
|
ws.on('message', handler);
|
|
ws.send(JSON.stringify(msg));
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
LISTEN_ALLOWED = await canListenOnLocalhost();
|
|
});
|
|
|
|
describe('GatewayServer integration', () => {
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
server = new GatewayServer({
|
|
port: TEST_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
version: '0.1.0-test',
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
});
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await server.stop();
|
|
});
|
|
|
|
it('responds to system.health', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createClient();
|
|
try {
|
|
const result = await sendAndReceive(ws, { id: 1, method: 'system.health' });
|
|
const response = result as GatewayResponse;
|
|
expect(response.id).toBe(1);
|
|
const r = response.result as Record<string, unknown>;
|
|
expect(r.status).toBe('ok');
|
|
expect(r.version).toBe('0.1.0-test');
|
|
expect(typeof r.uptime).toBe('number');
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
it('returns MethodNotFound for unknown method', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createClient();
|
|
try {
|
|
const result = await sendAndReceive(ws, { id: 2, method: 'unknown.method' });
|
|
const error = result as GatewayError;
|
|
expect(error.error.code).toBe(ErrorCode.MethodNotFound);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
it('returns ParseError for invalid JSON', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createClient();
|
|
try {
|
|
const result = await new Promise<GatewayError>((resolve) => {
|
|
ws.once('message', (data) => resolve(JSON.parse(data.toString())));
|
|
ws.send('not valid json');
|
|
});
|
|
expect(result.error.code).toBe(ErrorCode.ParseError);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
it('lists tools via tools.list', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createClient();
|
|
try {
|
|
const result = await sendAndReceive(ws, { id: 3, method: 'tools.list' });
|
|
const response = result as GatewayResponse;
|
|
const r = response.result as { tools: Array<{ name: string }> };
|
|
expect(r.tools.length).toBeGreaterThan(0);
|
|
expect(r.tools[0].name).toBe('shell.exec');
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
it('sends agent message and receives done event', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createClient();
|
|
try {
|
|
// agent.send streams events — we expect a 'done' event
|
|
const messages = await sendAndReceiveAll(ws, { id: 4, method: 'agent.send', params: { message: 'hi' } }, 1);
|
|
const doneEvent = messages[0] as GatewayEvent;
|
|
expect(doneEvent.id).toBe(4);
|
|
expect(doneEvent.event).toBe('done');
|
|
expect((doneEvent.data as { content?: string }).content).toBe('Hello from Flynn!');
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
it('tracks connections correctly', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws1 = await createClient();
|
|
const ws2 = await createClient();
|
|
try {
|
|
const result = await sendAndReceive(ws1, { id: 5, method: 'system.health' });
|
|
const r = (result as GatewayResponse).result as Record<string, unknown>;
|
|
expect(r.connections).toBe(2);
|
|
} finally {
|
|
ws1.close();
|
|
ws2.close();
|
|
}
|
|
});
|
|
|
|
it('lists registered methods', () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const methods = server.getMethods();
|
|
expect(methods).toContain('system.health');
|
|
expect(methods).toContain('agent.send');
|
|
expect(methods).toContain('agent.cancel');
|
|
expect(methods).toContain('sessions.list');
|
|
expect(methods).toContain('sessions.history');
|
|
expect(methods).toContain('sessions.create');
|
|
expect(methods).toContain('tools.list');
|
|
expect(methods).toContain('tools.invoke');
|
|
expect(methods).toContain('canvas.put');
|
|
expect(methods).toContain('canvas.list');
|
|
expect(methods).toContain('system.nodes');
|
|
expect(methods).toContain('node.status.set');
|
|
expect(methods).toContain('node.push_token.set');
|
|
});
|
|
|
|
it('supports canvas artifact lifecycle via gateway RPC', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createClient();
|
|
try {
|
|
const put = await sendAndReceive(ws, {
|
|
id: 31,
|
|
method: 'canvas.put',
|
|
params: {
|
|
sessionId: 'ws:test-canvas',
|
|
artifactId: 'a1',
|
|
type: 'note',
|
|
content: { text: 'draft' },
|
|
},
|
|
});
|
|
expect((put as GatewayResponse).id).toBe(31);
|
|
|
|
const list = await sendAndReceive(ws, {
|
|
id: 32,
|
|
method: 'canvas.list',
|
|
params: { sessionId: 'ws:test-canvas' },
|
|
});
|
|
const artifacts = ((list as GatewayResponse).result as { artifacts: Array<{ id: string }> }).artifacts;
|
|
expect(artifacts).toHaveLength(1);
|
|
expect(artifacts[0]?.id).toBe('a1');
|
|
|
|
const clear = await sendAndReceive(ws, {
|
|
id: 33,
|
|
method: 'canvas.clear',
|
|
params: { sessionId: 'ws:test-canvas' },
|
|
});
|
|
expect(((clear as GatewayResponse).result as { cleared: number }).cleared).toBe(1);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
// ── HTTP static file serving tests ────────────────────────────
|
|
|
|
it('serves index.html on HTTP GET /', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toBe('text/html');
|
|
expect(res.headers.get('cache-control')).toBe('no-cache');
|
|
const body = await res.text();
|
|
expect(body).toContain('Flynn');
|
|
});
|
|
|
|
it('serves style.css on HTTP GET /style.css', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toBe('text/css');
|
|
});
|
|
|
|
it('serves sw.js with no-store cache policy', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/sw.js`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toBe('application/javascript');
|
|
expect(res.headers.get('cache-control')).toBe('no-store');
|
|
});
|
|
|
|
it('returns 404 for unknown HTTP path', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/nonexistent`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('returns 404 for path traversal attempt', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${TEST_PORT}/../../../etc/passwd`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('GatewayServer lock mode', () => {
|
|
const LOCK_PORT = 18897;
|
|
let lockServer: GatewayServer;
|
|
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
lockServer = new GatewayServer({
|
|
port: LOCK_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
lock: true,
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
});
|
|
await lockServer.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await lockServer.stop();
|
|
});
|
|
|
|
function createLockClient(): Promise<WebSocket> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${LOCK_PORT}`);
|
|
ws.on('open', () => resolve(ws));
|
|
ws.on('error', reject);
|
|
});
|
|
}
|
|
|
|
it('allows the first client to connect', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createLockClient();
|
|
try {
|
|
const result = await sendAndReceive(ws, { id: 1, method: 'system.health' });
|
|
const response = result as GatewayResponse;
|
|
expect((response.result as { status?: string }).status).toBe('ok');
|
|
} finally {
|
|
ws.close();
|
|
// Wait for the close to propagate so connectionMap is empty
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
});
|
|
|
|
it('rejects second client with code 4003 when locked', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws1 = await createLockClient();
|
|
try {
|
|
// Second client should be rejected
|
|
const closePromise = new Promise<{ code: number; reason: string }>((resolve) => {
|
|
const ws2 = new WebSocket(`ws://127.0.0.1:${LOCK_PORT}`);
|
|
ws2.on('close', (code, reason) => {
|
|
resolve({ code, reason: reason.toString() });
|
|
});
|
|
});
|
|
|
|
const { code, reason } = await closePromise;
|
|
expect(code).toBe(4003);
|
|
expect(reason).toContain('locked');
|
|
} finally {
|
|
ws1.close();
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
});
|
|
|
|
it('allows a new client after the previous one disconnects', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws1 = await createLockClient();
|
|
ws1.close();
|
|
// Wait for the close to propagate
|
|
await new Promise(r => setTimeout(r, 100));
|
|
|
|
const ws2 = await createLockClient();
|
|
try {
|
|
const result = await sendAndReceive(ws2, { id: 2, method: 'system.health' });
|
|
const response = result as GatewayResponse;
|
|
expect((response.result as { status?: string }).status).toBe('ok');
|
|
} finally {
|
|
ws2.close();
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
});
|
|
|
|
it('system.lock handler returns lock status', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const ws = await createLockClient();
|
|
try {
|
|
const result = await sendAndReceive(ws, { id: 3, method: 'system.lock' });
|
|
const response = result as GatewayResponse;
|
|
const r = response.result as { locked: boolean; activeClients: number; maxClients: number | null };
|
|
expect(r.locked).toBe(true);
|
|
expect(r.activeClients).toBe(1);
|
|
expect(r.maxClients).toBe(1);
|
|
} finally {
|
|
ws.close();
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GatewayServer HTTP auth', () => {
|
|
const AUTH_PORT = 18898;
|
|
let authServer: GatewayServer;
|
|
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
authServer = new GatewayServer({
|
|
port: AUTH_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
auth: { token: 'test-secret' },
|
|
authHttp: true,
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
});
|
|
await authServer.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await authServer.stop();
|
|
});
|
|
|
|
it('returns 401 for HTTP request without token', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`);
|
|
expect(res.status).toBe(401);
|
|
expect(res.headers.get('www-authenticate')).toBe('Bearer');
|
|
});
|
|
|
|
it('serves content with valid Bearer token', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`, {
|
|
headers: { Authorization: 'Bearer test-secret' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toBe('text/html');
|
|
});
|
|
});
|
|
|
|
describe('GatewayServer request body limits', () => {
|
|
const BODY_PORT = 18896;
|
|
let bodyLimitServer: GatewayServer;
|
|
const gmailHandler = {
|
|
handlePushNotification: vi.fn(async () => {}),
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
bodyLimitServer = new GatewayServer({
|
|
port: BODY_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
gmailHandler: gmailHandler as unknown as GatewayServerConfig['gmailHandler'],
|
|
maxRequestBodyBytes: 64,
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
});
|
|
await bodyLimitServer.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await bodyLimitServer.stop();
|
|
});
|
|
|
|
it('accepts gmail push body under limit', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
gmailHandler.handlePushNotification.mockClear();
|
|
|
|
const body = JSON.stringify({ message: { data: 'abc' } });
|
|
const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, {
|
|
method: 'POST',
|
|
body,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(gmailHandler.handlePushNotification).toHaveBeenCalledWith('abc');
|
|
});
|
|
|
|
it('rejects gmail push body over limit with 413', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
gmailHandler.handlePushNotification.mockClear();
|
|
|
|
const body = JSON.stringify({ message: { data: 'x'.repeat(2048) } });
|
|
const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, {
|
|
method: 'POST',
|
|
body,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(res.status).toBe(413);
|
|
expect(gmailHandler.handlePushNotification).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('GatewayServer WebChat push endpoints', () => {
|
|
const PUSH_PORT = 18894;
|
|
let pushServer: GatewayServer;
|
|
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
pushServer = new GatewayServer({
|
|
port: PUSH_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
auth: { token: 'push-secret' },
|
|
authHttp: true,
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
webchatPush: {
|
|
enabled: true,
|
|
vapidPublicKey: 'BO_test_public_key',
|
|
maxSubscriptions: 2,
|
|
},
|
|
});
|
|
await pushServer.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await pushServer.stop();
|
|
});
|
|
|
|
it('returns push public key metadata when authenticated', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/public-key`, {
|
|
headers: { Authorization: 'Bearer push-secret' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { enabled: boolean; vapidPublicKey: string | null };
|
|
expect(body.enabled).toBe(true);
|
|
expect(body.vapidPublicKey).toBe('BO_test_public_key');
|
|
});
|
|
|
|
it('stores and deletes webchat push subscriptions', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const headers = { Authorization: 'Bearer push-secret', 'Content-Type': 'application/json' };
|
|
const payload = {
|
|
endpoint: 'https://example.invalid/sub/1',
|
|
keys: {
|
|
p256dh: 'p256dh-sample',
|
|
auth: 'auth-sample',
|
|
},
|
|
userAgent: 'vitest',
|
|
};
|
|
|
|
const putRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
expect(putRes.status).toBe(200);
|
|
const putBody = await putRes.json() as { stored: boolean; count: number };
|
|
expect(putBody.stored).toBe(true);
|
|
expect(putBody.count).toBe(1);
|
|
|
|
const listRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, {
|
|
headers: { Authorization: 'Bearer push-secret' },
|
|
});
|
|
expect(listRes.status).toBe(200);
|
|
const listBody = await listRes.json() as { count: number; maxSubscriptions: number };
|
|
expect(listBody.count).toBe(1);
|
|
expect(listBody.maxSubscriptions).toBe(2);
|
|
|
|
const delRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, {
|
|
method: 'DELETE',
|
|
headers,
|
|
body: JSON.stringify({ endpoint: payload.endpoint }),
|
|
});
|
|
expect(delRes.status).toBe(200);
|
|
const delBody = await delRes.json() as { removed: boolean; count: number };
|
|
expect(delBody.removed).toBe(true);
|
|
expect(delBody.count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('GatewayServer WebSocket ingress rate limiting', () => {
|
|
const RATE_PORT = 18895;
|
|
let rateServer: GatewayServer;
|
|
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
rateServer = new GatewayServer({
|
|
port: RATE_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
wsRateLimit: {
|
|
enabled: true,
|
|
capacity: 2,
|
|
refillPerSec: 1,
|
|
maxViolations: 3,
|
|
violationWindowMs: 10_000,
|
|
},
|
|
});
|
|
await rateServer.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await rateServer.stop();
|
|
});
|
|
|
|
it('throttles bursts and closes repeated offenders', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${RATE_PORT}`);
|
|
c.on('open', () => resolve(c));
|
|
c.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const first = await sendAndReceive(ws, { id: 1, method: 'system.health' });
|
|
expect((first as GatewayResponse).id).toBe(1);
|
|
|
|
const second = await sendAndReceive(ws, { id: 2, method: 'system.health' });
|
|
expect((second as GatewayResponse).id).toBe(2);
|
|
|
|
const third = await sendAndReceive(ws, { id: 3, method: 'system.health' });
|
|
const rateErr = third as GatewayError;
|
|
expect(rateErr.error.code).toBe(ErrorCode.InternalError);
|
|
expect(rateErr.error.message).toContain('Rate limit exceeded');
|
|
|
|
// Trigger additional violations; server should close on max violation threshold.
|
|
ws.send(JSON.stringify({ id: 4, method: 'system.health' }));
|
|
ws.send(JSON.stringify({ id: 5, method: 'system.health' }));
|
|
|
|
const close = await new Promise<{ code: number; reason: string }>((resolve) => {
|
|
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
|
|
});
|
|
expect(close.code).toBe(4008);
|
|
expect(close.reason).toContain('Rate limit exceeded');
|
|
} finally {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GatewayServer node registration and capability negotiation', () => {
|
|
const NODE_PORT = 18894;
|
|
let nodeServer: GatewayServer;
|
|
|
|
beforeAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
nodeServer = new GatewayServer({
|
|
port: NODE_PORT,
|
|
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
|
|
modelClient: mockModelClient,
|
|
systemPrompt: 'Test prompt',
|
|
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
|
|
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
|
|
uiDir: resolve(import.meta.dirname, 'ui'),
|
|
nodes: {
|
|
enabled: true,
|
|
allowedRoles: ['companion'],
|
|
featureGates: { 'ui.canvas': true },
|
|
locationEnabled: true,
|
|
pushEnabled: true,
|
|
},
|
|
});
|
|
await nodeServer.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
await nodeServer.stop();
|
|
});
|
|
|
|
it('enforces role allow/deny and node registration lifecycle', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
|
|
c.on('open', () => resolve(c));
|
|
c.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const beforeRegister = await sendAndReceive(ws, { id: 1, method: 'node.capabilities.get', params: {} });
|
|
expect((beforeRegister as GatewayError).error.code).toBe(ErrorCode.AuthFailed);
|
|
|
|
const badRole = await sendAndReceive(ws, {
|
|
id: 2,
|
|
method: 'node.register',
|
|
params: {
|
|
nodeId: 'node-bad',
|
|
role: 'observer',
|
|
protocolVersion: 1,
|
|
capabilities: ['ui.canvas'],
|
|
},
|
|
});
|
|
expect((badRole as GatewayError).error.code).toBe(ErrorCode.AuthFailed);
|
|
|
|
const registered = await sendAndReceive(ws, {
|
|
id: 3,
|
|
method: 'node.register',
|
|
params: {
|
|
nodeId: 'node-good',
|
|
role: 'companion',
|
|
protocolVersion: 1,
|
|
capabilities: ['ui.canvas'],
|
|
},
|
|
});
|
|
expect((registered as GatewayResponse).id).toBe(3);
|
|
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
|
|
|
|
const capabilities = await sendAndReceive(ws, { id: 4, method: 'node.capabilities.get', params: {} });
|
|
expect((capabilities as GatewayResponse).id).toBe(4);
|
|
expect(((capabilities as GatewayResponse).result as { node: { role: string } }).node.role).toBe('companion');
|
|
} finally {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('supports node location set/get after registration', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
|
|
c.on('open', () => resolve(c));
|
|
c.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const registered = await sendAndReceive(ws, {
|
|
id: 10,
|
|
method: 'node.register',
|
|
params: {
|
|
nodeId: 'node-loc',
|
|
role: 'companion',
|
|
protocolVersion: 1,
|
|
capabilities: ['location'],
|
|
},
|
|
});
|
|
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
|
|
|
|
const setResult = await sendAndReceive(ws, {
|
|
id: 11,
|
|
method: 'node.location.set',
|
|
params: {
|
|
latitude: 51.5074,
|
|
longitude: -0.1278,
|
|
source: 'gps',
|
|
},
|
|
});
|
|
expect(((setResult as GatewayResponse).result as { updated: boolean }).updated).toBe(true);
|
|
|
|
const getResult = await sendAndReceive(ws, {
|
|
id: 12,
|
|
method: 'node.location.get',
|
|
params: {},
|
|
});
|
|
const location = ((getResult as GatewayResponse).result as {
|
|
location: { latitude: number; longitude: number };
|
|
}).location;
|
|
expect(location.latitude).toBe(51.5074);
|
|
expect(location.longitude).toBe(-0.1278);
|
|
} finally {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('supports node.status.set and exposes registered nodes via system.nodes', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
|
|
c.on('open', () => resolve(c));
|
|
c.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const registered = await sendAndReceive(ws, {
|
|
id: 20,
|
|
method: 'node.register',
|
|
params: {
|
|
nodeId: 'node-mac',
|
|
role: 'companion',
|
|
protocolVersion: 1,
|
|
capabilities: ['ui.canvas'],
|
|
},
|
|
});
|
|
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
|
|
|
|
const status = await sendAndReceive(ws, {
|
|
id: 21,
|
|
method: 'node.status.set',
|
|
params: {
|
|
platform: 'macos',
|
|
appVersion: '0.3.1',
|
|
deviceName: 'MacBook Pro',
|
|
batteryPct: 64,
|
|
powerSource: 'battery',
|
|
},
|
|
});
|
|
expect(((status as GatewayResponse).result as { updated: boolean }).updated).toBe(true);
|
|
|
|
const nodes = await sendAndReceive(ws, {
|
|
id: 22,
|
|
method: 'system.nodes',
|
|
params: { role: 'companion', platform: 'macos', limit: 10 },
|
|
});
|
|
const list = ((nodes as GatewayResponse).result as {
|
|
nodes: Array<{ nodeId: string; status?: { platform: string; appVersion?: string } }>;
|
|
}).nodes;
|
|
expect(list.length).toBeGreaterThanOrEqual(1);
|
|
expect(list.some((entry) => entry.nodeId === 'node-mac')).toBe(true);
|
|
expect(list.find((entry) => entry.nodeId === 'node-mac')?.status?.platform).toBe('macos');
|
|
} finally {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('supports node.push_token.set and exposes masked push summary via system.nodes', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
|
|
c.on('open', () => resolve(c));
|
|
c.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const registered = await sendAndReceive(ws, {
|
|
id: 30,
|
|
method: 'node.register',
|
|
params: {
|
|
nodeId: 'node-ios',
|
|
role: 'companion',
|
|
protocolVersion: 1,
|
|
capabilities: ['notifications'],
|
|
},
|
|
});
|
|
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
|
|
|
|
const push = await sendAndReceive(ws, {
|
|
id: 31,
|
|
method: 'node.push_token.set',
|
|
params: {
|
|
provider: 'apns',
|
|
token: 'abcd1234abcd1234abcd1234abcd1234',
|
|
topic: 'com.example.flynn',
|
|
environment: 'sandbox',
|
|
},
|
|
});
|
|
const preview = ((push as GatewayResponse).result as {
|
|
push: { tokenPreview: string };
|
|
}).push.tokenPreview;
|
|
expect(preview).toContain('abcd1234');
|
|
|
|
const nodes = await sendAndReceive(ws, {
|
|
id: 32,
|
|
method: 'system.nodes',
|
|
params: { role: 'companion', limit: 10 },
|
|
});
|
|
const list = ((nodes as GatewayResponse).result as {
|
|
nodes: Array<{ nodeId: string; push?: { tokenPreview: string } }>;
|
|
}).nodes;
|
|
const iosNode = list.find((entry) => entry.nodeId === 'node-ios');
|
|
expect(iosNode?.push?.tokenPreview).toContain('abcd1234');
|
|
expect(iosNode?.push?.tokenPreview).not.toContain('abcd1234abcd1234abcd1234');
|
|
} finally {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('supports android fcm push token registration', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
|
|
c.on('open', () => resolve(c));
|
|
c.on('error', reject);
|
|
});
|
|
|
|
try {
|
|
const registered = await sendAndReceive(ws, {
|
|
id: 40,
|
|
method: 'node.register',
|
|
params: {
|
|
nodeId: 'node-android',
|
|
role: 'companion',
|
|
protocolVersion: 1,
|
|
capabilities: ['notifications'],
|
|
},
|
|
});
|
|
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
|
|
|
|
const push = await sendAndReceive(ws, {
|
|
id: 41,
|
|
method: 'node.push_token.set',
|
|
params: {
|
|
provider: 'fcm',
|
|
token: 'fcm_abcdefghijklmnopqrstuvwxyz123456',
|
|
},
|
|
});
|
|
expect(((push as GatewayResponse).result as { updated: boolean }).updated).toBe(true);
|
|
|
|
const nodes = await sendAndReceive(ws, {
|
|
id: 42,
|
|
method: 'system.nodes',
|
|
params: { role: 'companion', limit: 20 },
|
|
});
|
|
const list = ((nodes as GatewayResponse).result as {
|
|
nodes: Array<{ nodeId: string; push?: { provider: string; environment?: string } }>;
|
|
}).nodes;
|
|
const androidNode = list.find((entry) => entry.nodeId === 'node-android');
|
|
expect(androidNode?.push?.provider).toBe('fcm');
|
|
expect(androidNode?.push?.environment).toBeUndefined();
|
|
} finally {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
});
|