Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
463 lines
19 KiB
TypeScript
463 lines
19 KiB
TypeScript
import { once } from 'node:events';
|
|
import type { IncomingMessage } from 'node:http';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
import { EventSubscriptions, normalizeEventSubscriptionOptions } from '../src/subscriptions';
|
|
import type { StructuredResult } from '../src/results';
|
|
|
|
const SELF = 10001;
|
|
const GROUP = 20001;
|
|
const USER = 30001;
|
|
const cleanup: Array<() => void | Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
for (const close of cleanup.splice(0).reverse()) {
|
|
await close();
|
|
}
|
|
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function source(
|
|
options: { hello?: boolean; token?: string; selfId?: number; autoPong?: boolean } = {},
|
|
) {
|
|
const server = new WebSocketServer({
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
autoPong: options.autoPong ?? true,
|
|
});
|
|
const sockets = new Set<WebSocket>();
|
|
const requests: IncomingMessage[] = [];
|
|
const received: string[] = [];
|
|
server.on('connection', (socket, request) => {
|
|
sockets.add(socket);
|
|
requests.push(request);
|
|
socket.on('close', () => sockets.delete(socket));
|
|
socket.on('message', (bytes) => received.push(bytes.toString()));
|
|
|
|
if (options.hello !== false) {
|
|
socket.send(
|
|
JSON.stringify({
|
|
self_id: options.selfId ?? SELF,
|
|
post_type: 'meta_event',
|
|
meta_event_type: 'lifecycle',
|
|
sub_type: 'connect',
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
await once(server, 'listening');
|
|
cleanup.push(async () => {
|
|
for (const socket of sockets) {
|
|
socket.terminate();
|
|
}
|
|
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
const address = server.address();
|
|
|
|
if (typeof address !== 'object' || !address) {
|
|
throw new Error('Test WebSocket listener unavailable');
|
|
}
|
|
|
|
const url = `ws://127.0.0.1:${address.port}/event`;
|
|
const manager = new EventSubscriptions({ url, accessToken: options.token, timeoutMs: 2000 });
|
|
cleanup.push(() => manager.close());
|
|
return {
|
|
manager,
|
|
requests,
|
|
received,
|
|
sockets,
|
|
url,
|
|
send(event: unknown) {
|
|
for (const socket of sockets) {
|
|
socket.send(JSON.stringify(event));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function result(value: StructuredResult): Record<string, unknown> {
|
|
expect(value.isError).toBeUndefined();
|
|
return value.structuredContent.result as Record<string, unknown>;
|
|
}
|
|
|
|
function snapshot(manager: EventSubscriptions, uri: string) {
|
|
const entry = manager.readResource(uri).contents[0]!;
|
|
|
|
if (!('text' in entry)) {
|
|
throw new Error('Expected JSON resource');
|
|
}
|
|
|
|
return JSON.parse(entry.text);
|
|
}
|
|
|
|
async function subscribe(manager: EventSubscriptions, args: Record<string, unknown> = {}) {
|
|
const value = result(await manager.callTool('subscribe_events', { self_id: SELF, ...args }));
|
|
expect(value.status).toBe('connected');
|
|
return value.uri as string;
|
|
}
|
|
|
|
function message(fields: Record<string, unknown> = {}) {
|
|
return {
|
|
self_id: SELF,
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
group_id: GROUP,
|
|
user_id: USER,
|
|
message_id: -42,
|
|
message: [{ type: 'text', data: { text: 'fixture' } }],
|
|
...fields,
|
|
};
|
|
}
|
|
|
|
describe('isolated OneBot event resources', () => {
|
|
it('validates startup configuration before constructing an event manager or connecting', async () => {
|
|
const test = await source();
|
|
expect(
|
|
normalizeEventSubscriptionOptions({ url: test.url, accessToken: 'fixture-token' }),
|
|
).toEqual({ url: test.url, accessToken: 'fixture-token', timeoutMs: 30_000 });
|
|
expect(
|
|
normalizeEventSubscriptionOptions({ url: 'wss://EXAMPLE.invalid', timeoutMs: 1234 }),
|
|
).toEqual({ url: 'wss://example.invalid/', timeoutMs: 1234 });
|
|
|
|
for (const url of [
|
|
'not-a-url',
|
|
'https://example.invalid',
|
|
'ws://user:secret@example.invalid',
|
|
'ws://example.invalid?token=secret',
|
|
'ws://example.invalid/#secret',
|
|
]) {
|
|
expect(() => normalizeEventSubscriptionOptions({ url })).toThrow(
|
|
'SNOWLUMA_MCP_EVENTS_URL',
|
|
);
|
|
}
|
|
|
|
for (const timeoutMs of [0, -1, 0.5, NaN, Infinity]) {
|
|
expect(() => normalizeEventSubscriptionOptions({ url: test.url, timeoutMs })).toThrow(
|
|
'timeout',
|
|
);
|
|
}
|
|
|
|
expect(test.requests).toEqual([]);
|
|
});
|
|
|
|
it('connects only on explicit subscription and uses an authorization header without logging credentials', async () => {
|
|
const test = await source({ token: 'secret-event-token' });
|
|
expect(test.requests).toHaveLength(0);
|
|
expect(test.manager.resources()).toEqual([]);
|
|
const uri = await subscribe(test.manager);
|
|
expect(test.requests).toHaveLength(1);
|
|
expect(test.requests[0]!.headers.authorization).toBe('Bearer secret-event-token');
|
|
expect(test.requests[0]!.url).toBe('/event');
|
|
expect(test.manager.resources()).toMatchObject([{ uri }]);
|
|
expect(snapshot(test.manager, uri)).toMatchObject({
|
|
self_id: SELF,
|
|
status: 'connected',
|
|
events: [],
|
|
next_cursor: 0,
|
|
gap: false,
|
|
});
|
|
expect(test.received).toEqual([]);
|
|
});
|
|
|
|
it('filters event type, group and user before buffering and preserves signed message IDs', async () => {
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager, {
|
|
types: ['message'],
|
|
group_ids: [GROUP],
|
|
user_ids: [USER],
|
|
});
|
|
test.send(message({ post_type: 'notice' }));
|
|
test.send(message({ group_id: GROUP + 1 }));
|
|
test.send(message({ user_id: USER + 1 }));
|
|
test.send(message({ group_id: undefined, message_type: 'private' }));
|
|
test.send(message());
|
|
test.send(message({ post_type: 'message_sent', message_id: -43 }));
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(2));
|
|
const read = snapshot(test.manager, `${uri}?after=1`);
|
|
expect(read.events).toMatchObject([
|
|
{ cursor: 2, event: { message_id: -43, post_type: 'message_sent' } },
|
|
]);
|
|
expect(read.gap).toBe(false);
|
|
expect(test.received).toEqual([]);
|
|
});
|
|
|
|
it('retains bounded event and byte windows with explicit cursor gaps', async () => {
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager);
|
|
|
|
for (let index = 0; index < 300; index++) {
|
|
test.send(message({ message_id: -index - 1 }));
|
|
}
|
|
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(300));
|
|
const read = snapshot(test.manager, uri);
|
|
expect(read.events).toHaveLength(256);
|
|
expect(read).toMatchObject({ oldest_cursor: 45, dropped_events: 44, gap: true });
|
|
expect(snapshot(test.manager, `${uri}?after=44`).gap).toBe(false);
|
|
expect(snapshot(test.manager, `${uri}?after=300`).events).toEqual([]);
|
|
expect(() => snapshot(test.manager, `${uri}?after=301`)).toThrow('cursor');
|
|
|
|
for (let index = 0; index < 8; index++) {
|
|
test.send(message({ raw_message: 'x'.repeat(200_000) }));
|
|
}
|
|
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(308));
|
|
const large = snapshot(test.manager, uri);
|
|
expect(large.events).toHaveLength(5);
|
|
expect(large.dropped_events).toBe(303);
|
|
expect(large.gap).toBe(true);
|
|
});
|
|
|
|
it('rejects another account at connection validation and never exposes its events', async () => {
|
|
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source({ selfId: SELF + 1 });
|
|
const reply = await test.manager.callTool('subscribe_events', { self_id: SELF });
|
|
expect(reply).toMatchObject({
|
|
isError: true,
|
|
structuredContent: { diagnostics: { kind: 'transport' } },
|
|
});
|
|
expect(test.manager.resources()).toEqual([]);
|
|
expect(log.mock.calls.flat().join(' ')).toContain('account_mismatch');
|
|
expect(log.mock.calls.flat().join(' ')).not.toContain(String(SELF + 1));
|
|
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
|
});
|
|
|
|
it('makes account changes and disconnections visible without automatic reconnection', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager);
|
|
test.send(message());
|
|
test.send(message({ self_id: SELF + 1, raw_message: 'other-account-private-data' }));
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).status).toBe('disconnected'));
|
|
const read = snapshot(test.manager, uri);
|
|
expect(read).toMatchObject({ gap: true, problem: 'account_mismatch', next_cursor: 1 });
|
|
expect(JSON.stringify(read)).not.toContain('other-account-private-data');
|
|
expect(test.requests).toHaveLength(1);
|
|
const retry = await test.manager.callTool('subscribe_events', { self_id: SELF });
|
|
expect(retry.isError).toBe(true);
|
|
expect(test.requests).toHaveLength(1);
|
|
});
|
|
|
|
it('shares one source for matching accounts and releases it after the last subscription', async () => {
|
|
const test = await source();
|
|
const first = await subscribe(test.manager);
|
|
const second = await subscribe(test.manager, { types: ['notice', 'request'] });
|
|
expect(test.requests).toHaveLength(1);
|
|
const mismatch = await test.manager.callTool('subscribe_events', { self_id: SELF + 1 });
|
|
expect(mismatch).toMatchObject({
|
|
isError: true,
|
|
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
|
|
});
|
|
result(await test.manager.callTool('unsubscribe_events', { uri: first }));
|
|
expect(test.sockets.size).toBe(1);
|
|
test.send(message({ post_type: 'request', request_type: 'friend' }));
|
|
await vi.waitFor(() => expect(snapshot(test.manager, second).next_cursor).toBe(1));
|
|
test.manager.remove(second);
|
|
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
|
expect(test.manager.resources()).toEqual([]);
|
|
expect(() => snapshot(test.manager, first)).toThrow('not found');
|
|
await subscribe(test.manager, { self_id: SELF });
|
|
expect(test.requests).toHaveLength(2);
|
|
});
|
|
|
|
it('rejects malformed and oversized frames with a visible gap instead of swallowing them', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager);
|
|
test.send({ post_type: 'message', self_id: 'wrong-type' });
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).problem).toBe('invalid_event'));
|
|
expect(snapshot(test.manager, uri).gap).toBe(true);
|
|
test.manager.remove(uri);
|
|
const large = await subscribe(test.manager);
|
|
test.send(message({ raw_message: 'x'.repeat(300_000) }));
|
|
await vi.waitFor(() =>
|
|
expect(snapshot(test.manager, large).problem).toBe('frame_too_large'),
|
|
);
|
|
expect(snapshot(test.manager, large).events).toEqual([]);
|
|
});
|
|
|
|
it('handles transport closure, cancellation and explicit close without leaking connections', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager);
|
|
|
|
for (const socket of test.sockets) {
|
|
socket.close();
|
|
}
|
|
|
|
await vi.waitFor(() =>
|
|
expect(snapshot(test.manager, uri).problem).toBe('connection_closed'),
|
|
);
|
|
test.manager.remove(uri);
|
|
const pending = await source({ hello: false });
|
|
const controller = new AbortController();
|
|
const subscription = pending.manager.callTool(
|
|
'subscribe_events',
|
|
{ self_id: SELF },
|
|
controller.signal,
|
|
);
|
|
await vi.waitFor(() => expect(pending.sockets.size).toBe(1));
|
|
controller.abort();
|
|
expect(await subscription).toMatchObject({
|
|
isError: true,
|
|
structuredContent: { diagnostics: { kind: 'cancelled' } },
|
|
});
|
|
await vi.waitFor(() => expect(pending.sockets.size).toBe(0));
|
|
expect(pending.manager.resources()).toEqual([]);
|
|
await subscribe(test.manager);
|
|
test.manager.close();
|
|
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
|
});
|
|
|
|
it('keeps notification backlog bounded and coalesces updates behind a slow reader', async () => {
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager);
|
|
let release!: () => void;
|
|
const blocked = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const notify = vi.fn(async () => blocked);
|
|
test.manager.onUpdate(notify);
|
|
test.send(message());
|
|
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(1));
|
|
|
|
for (let index = 0; index < 500; index++) {
|
|
test.send(message());
|
|
}
|
|
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(501));
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
expect(snapshot(test.manager, uri).events).toHaveLength(256);
|
|
release();
|
|
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('makes notification delivery failures observable without logging sensitive input', async () => {
|
|
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source({ token: 'private-token' });
|
|
const uri = await subscribe(test.manager);
|
|
const detach = test.manager.onUpdate(async () => {
|
|
throw new Error('sensitive-event-or-token');
|
|
});
|
|
test.send(message());
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).notification_failed).toBe(true));
|
|
expect(log.mock.calls.flat().join(' ')).toContain('notification failed');
|
|
expect(log.mock.calls.flat().join(' ')).not.toContain('sensitive-event-or-token');
|
|
expect(log.mock.calls.flat().join(' ')).not.toContain('private-token');
|
|
detach();
|
|
});
|
|
|
|
it('validates filter and resource inputs and caps resource count', async () => {
|
|
const test = await source();
|
|
|
|
for (const args of [
|
|
{ self_id: -1 },
|
|
{ self_id: SELF, types: [] },
|
|
{ self_id: SELF, types: ['other'] },
|
|
{ self_id: SELF, group_ids: [GROUP, GROUP] },
|
|
{ self_id: SELF, user_ids: ['30001'] },
|
|
{ self_id: SELF, unexpected: true },
|
|
]) {
|
|
expect(await test.manager.callTool('subscribe_events', args)).toMatchObject({
|
|
isError: true,
|
|
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
|
|
});
|
|
}
|
|
|
|
expect(test.requests).toHaveLength(0);
|
|
|
|
for (let index = 0; index < 16; index++) {
|
|
await subscribe(test.manager);
|
|
}
|
|
|
|
expect((await test.manager.callTool('subscribe_events', { self_id: SELF })).isError).toBe(
|
|
true,
|
|
);
|
|
expect(test.requests).toHaveLength(1);
|
|
const uri = test.manager.resources()[0]!.uri;
|
|
expect(() => test.manager.remove(`${uri}?after=0`)).toThrow('canonical');
|
|
expect(() => snapshot(test.manager, `${uri}?after=-1`)).toThrow('Invalid event');
|
|
expect(() => snapshot(test.manager, `${uri}?after=9007199254740992`)).toThrow('cursor');
|
|
expect(() => new EventSubscriptions({ url: 'http://example.invalid' })).toThrow('ws/wss');
|
|
expect(
|
|
() => new EventSubscriptions({ url: 'ws://example.invalid?access_token=secret' }),
|
|
).toThrow('without credentials');
|
|
});
|
|
|
|
it('isolates resource identifiers and buffers between MCP sessions', async () => {
|
|
const first = await source();
|
|
const second = await source();
|
|
const uri = await subscribe(first.manager);
|
|
const other = await subscribe(second.manager);
|
|
first.send(message({ raw_message: 'first-session-private-event' }));
|
|
await vi.waitFor(() => expect(snapshot(first.manager, uri).next_cursor).toBe(1));
|
|
expect(snapshot(second.manager, other).events).toEqual([]);
|
|
expect(() => snapshot(second.manager, uri)).toThrow('not found');
|
|
expect(other).not.toBe(uri);
|
|
});
|
|
|
|
it('cleans up resources when constructing the WebSocket request fails synchronously', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source({ token: 'bad\nheader' });
|
|
const reply = await test.manager.callTool('subscribe_events', { self_id: SELF });
|
|
expect(reply.isError).toBe(true);
|
|
expect(test.manager.resources()).toEqual([]);
|
|
expect(test.requests).toEqual([]);
|
|
});
|
|
|
|
it('does not report a removed pending subscription as successfully connected', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source({ hello: false });
|
|
const first = test.manager.callTool('subscribe_events', { self_id: SELF });
|
|
const second = test.manager.callTool('subscribe_events', { self_id: SELF });
|
|
await vi.waitFor(() => expect(test.sockets.size).toBe(1));
|
|
const firstUri = test.manager.resources()[0]!.uri;
|
|
test.manager.remove(firstUri);
|
|
expect((await first).isError).toBe(true);
|
|
test.send({ self_id: SELF, post_type: 'meta_event', meta_event_type: 'lifecycle' });
|
|
expect((await second).isError).toBeUndefined();
|
|
expect(test.manager.resources()).toHaveLength(1);
|
|
expect(() => snapshot(test.manager, firstUri)).toThrow('not found');
|
|
});
|
|
|
|
it('reports a half-open connection when the WebSocket peer stops answering pings', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const timer = vi.spyOn(globalThis, 'setInterval');
|
|
const test = await source({ autoPong: false });
|
|
const uri = await subscribe(test.manager);
|
|
const heartbeat = timer.mock.calls.find((call) => call[1] === 30_000)?.[0];
|
|
|
|
if (typeof heartbeat !== 'function') {
|
|
throw new Error('Expected a heartbeat callback');
|
|
}
|
|
|
|
const socket = Array.from(test.sockets)[0]!;
|
|
const ping = once(socket, 'ping');
|
|
heartbeat();
|
|
await ping;
|
|
heartbeat();
|
|
expect(snapshot(test.manager, uri)).toMatchObject({
|
|
status: 'disconnected',
|
|
problem: 'connection_timeout',
|
|
gap: true,
|
|
});
|
|
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
|
});
|
|
|
|
it('rejects excessively nested JSON before it can crash resource serialization', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const test = await source();
|
|
const uri = await subscribe(test.manager);
|
|
const raw = `{"self_id":${SELF},"post_type":"message","nested":${'['.repeat(10_000)}0${']'.repeat(10_000)}}`;
|
|
|
|
for (const socket of test.sockets) {
|
|
socket.send(raw);
|
|
}
|
|
|
|
await vi.waitFor(() => expect(snapshot(test.manager, uri).problem).toBe('invalid_event'));
|
|
expect(snapshot(test.manager, uri)).toMatchObject({ events: [], gap: true });
|
|
});
|
|
});
|