SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,462 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user