Files
SnowLuma/packages/onebot/tests/http-server-tracing.test.ts
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

226 lines
8.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import {
currentRequestId,
getLogLevel,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
import { HttpServerAdapter } from '../src/network/http-server-adapter';
import { httpTraceparent } from '../src/network/http-trace';
import { defineStreamAction } from '../src/action-kit';
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
import type { ApiHandler } from '../src/api-handler';
const first = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const second = '00-12345678901234567890123456789012-1234567890123456-00';
describe('HTTP traceparent boundary', () => {
it('accepts defined fields of future versions without logging their opaque extension', () => {
expect(httpTraceparent(first)).toBe(first);
expect(httpTraceparent(`01-${first.slice(3)}-opaque`)).toBe(`01-${first.slice(3)}`);
});
it.each([
undefined,
[first, second],
first.toUpperCase(),
first.replace(/^00/, 'ff'),
first.replace(first.slice(3, 35), '0'.repeat(32)),
first.replace(first.slice(36, 52), '0'.repeat(16)),
`${first}-extra`,
`${first}\n`,
`${first},${second}`,
`${first}\nprivate`,
])('rejects malformed or ambiguous header %j', (value) => {
expect(httpTraceparent(value)).toBeUndefined();
});
});
describe('HTTP distributed trace correlation', () => {
let adapter: HttpServerAdapter | undefined;
let level: string;
let entries: LogEntry[];
let unsubscribe: () => void;
beforeEach(() => {
level = getLogLevel();
setLogLevel('trace');
entries = [];
unsubscribe = subscribeLogs((entry) => entries.push(entry));
});
afterEach(async () => {
await adapter?.close();
adapter = undefined;
unsubscribe();
setLogLevel(level);
vi.restoreAllMocks();
});
async function open(api: ApiHandler): Promise<string> {
adapter = new HttpServerAdapter(
'http-tracing',
{
name: 'http-tracing',
host: '127.0.0.1',
port: 0,
path: '/',
accessToken: 'test-token',
messageFormat: 'array',
reportSelfMessage: false,
},
{
uin: '10001',
api,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
},
);
await adapter.open();
const server = (adapter as unknown as { server: Server }).server;
return `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
}
function send(endpoint: string, action: string, traceparent: string, token = 'test-token') {
return fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
traceparent,
tracestate: 'vendor=private-state',
baggage: 'private-baggage',
},
body: JSON.stringify({ action, params: {} }),
});
}
it('links concurrent HTTP traces to each action lifecycle without changing local request IDs', async () => {
const inside = new Map<string, number | undefined>();
const started = Promise.withResolvers<void>();
let count = 0;
const api = createCompiledTestHandler(
{} as never,
['trace_first', 'trace_second'].map((name) =>
testAction(name, async () => {
inside.set(name, currentRequestId());
count++;
if (count === 2) {
started.resolve();
}
await started.promise;
expect(currentRequestId()).toBe(inside.get(name));
return { status: 'ok', retcode: 0, data: { name } };
}),
),
);
const endpoint = await open(api);
const responses = await Promise.all([
send(endpoint, 'trace_first', first),
send(endpoint, 'trace_second', second),
]);
expect(await responses[0]!.json()).toEqual({
status: 'ok',
retcode: 0,
data: { name: 'trace_first' },
});
expect(await responses[1]!.json()).toEqual({
status: 'ok',
retcode: 0,
data: { name: 'trace_second' },
});
expect(inside.get('trace_first')).toBeTypeOf('number');
expect(inside.get('trace_second')).not.toBe(inside.get('trace_first'));
for (const [name, traceparent] of [
['trace_first', first],
['trace_second', second],
]) {
const correlation = entries.find(
(entry) => entry.message === `http_trace traceparent=${traceparent}`,
);
expect(correlation?.req).toBe(inside.get(name!));
const lifecycle = entries.filter(
(entry) =>
entry.scope === 'Bridge.Action' && entry.message.includes(`action=${name}`),
);
expect(lifecycle).toHaveLength(2);
expect(lifecycle.every((entry) => entry.req === correlation?.req)).toBe(true);
}
const correlationText = entries
.filter((entry) => entry.message.startsWith('http_trace'))
.map((entry) => entry.message)
.join('\n');
expect(correlationText).not.toMatch(/private-state|private-baggage/);
});
it('uses the same request context for streamed action frames and completion', async () => {
const api = createCompiledTestHandler({} as never, [
defineStreamAction({
name: 'trace_stream_http',
params: {},
run: async (_params, _ctx, sink) => {
await sink.send({ type: 'stream', data: 'chunk' });
return { status: 'ok', retcode: 0, data: { type: 'response' } };
},
}),
]);
const endpoint = await open(api);
const response = await send(endpoint, 'trace_stream_http', first);
const wire = await response.text();
expect(response.status).toBe(200);
expect(wire).toContain('chunk');
const correlation = entries.find(
(entry) => entry.message === `http_trace traceparent=${first}`,
);
const lifecycle = entries.filter(
(entry) =>
entry.scope === 'Bridge.Action' &&
entry.message.includes('action=trace_stream_http'),
);
expect(lifecycle.length).toBeGreaterThanOrEqual(3);
expect(lifecycle.every((entry) => entry.req === correlation?.req)).toBe(true);
});
it('ignores invalid trace input without changing authorization or leaking it into logs', async () => {
const run = vi.fn(async () => ({ status: 'ok' as const, retcode: 0, data: null }));
const api = createCompiledTestHandler({} as never, [testAction('trace_guard', run)]);
const endpoint = await open(api);
const rejected = await send(endpoint, 'trace_guard', first, 'wrong-token');
expect(rejected.status).toBe(401);
expect(run).not.toHaveBeenCalled();
expect(entries.some((entry) => entry.message.startsWith('http_trace'))).toBe(false);
const accepted = await send(endpoint, 'trace_guard', 'private-invalid-trace');
expect(await accepted.json()).toEqual({ status: 'ok', retcode: 0, data: null });
expect(run).toHaveBeenCalledOnce();
expect(
entries.some((entry) => entry.message === 'http_trace ignored invalid traceparent'),
).toBe(true);
expect(entries.map((entry) => entry.message).join('\n')).not.toMatch(
/private-invalid-trace|private-state|private-baggage/,
);
});
it('does not allocate a local request ID when TRACE logging is disabled', async () => {
setLogLevel('info');
let requestId: number | undefined;
const api = createCompiledTestHandler({} as never, [
testAction('trace_disabled', async () => {
requestId = currentRequestId();
return { status: 'ok', retcode: 0, data: null };
}),
]);
const response = await send(await open(api), 'trace_disabled', first);
expect(response.status).toBe(200);
await response.text();
expect(requestId).toBeUndefined();
expect(entries.some((entry) => entry.message.startsWith('http_trace'))).toBe(false);
});
});