Files
BPOS-Service-Gateway/test/integration/sse.test.ts
T

210 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, beforeAll, vi } from 'vitest';
import { Hono } from 'hono';
import { SseHub } from '../../src/modules/sse/hub';
import type { Connection, SseEvent } from '../../src/modules/sse/hub';
import { sseRoutes } from '../../src/modules/sse/routes';
import { createHttpTaskAccess } from '../../src/modules/sse/task-access';
import { buildApp } from '../../src/app/app';
import { makeToken, setupTestJwks } from '../support/token';
function conn(id: string, staffSub: string, topics: string[] = []): { c: Connection; got: SseEvent[] } {
const got: SseEvent[] = [];
return { c: { id, staffSub, isAdmin: false, topics: new Set(topics), send: (e) => got.push(e) }, got };
}
describe('SseHub 路由', () => {
it('staff 精确投递、system 广播、task 按订阅集、回放窗口', () => {
const hub = new SseHub(8);
const a = conn('a', 'sub-a', ['touch.task.t1']);
const b = conn('b', 'sub-b');
hub.register(a.c);
hub.register(b.c);
hub.deliver('touch.system', '{}');
hub.deliver('touch.staff.sub-a', '{}');
hub.deliver('touch.task.t1', '{}');
expect(a.got.map((e) => e.topic)).toEqual(['touch.system', 'touch.staff.sub-a', 'touch.task.t1']);
expect(b.got.map((e) => e.topic)).toEqual(['touch.system']);
// 断线重连回放:c 以 lastEventId=1 上线且订阅 t1 → 补 seq>1 的可见事件。
// D11:register 只登记、replay 单独一步——路由要把 hello 插在两者之间。
const c = conn('c', 'sub-c', ['touch.task.t1']);
hub.register(c.c);
hub.replay(c.c, 1);
expect(c.got.map((e) => e.topic)).toEqual(['touch.task.t1']);
});
it('subscribe 授权:access 拒 → 不入订阅集', async () => {
const hub = new SseHub();
const a = conn('a', 'sub-a');
hub.register(a.c);
const denyAccess = { allowed: async () => false };
const res = await hub.subscribe('a', 'stream.task.t9', denyAccess);
expect(res.ok).toBe(false);
expect(a.c.topics.has('stream.task.t9')).toBe(false);
});
it('D3:形状校验排在连接查找之前,失败带判别式 reason 供路由分流', async () => {
const hub = new SseHub();
const denyAccess = { allowed: async () => false };
// 连接不存在 + 形状也不对 → 必须报形状(否则 HTTP 面永远回不出 422)
expect(await hub.subscribe('nope', 'touch.staff.hack', denyAccess)).toMatchObject({
ok: false,
reason: 'bad-shape',
});
expect(await hub.subscribe('nope', 'touch.task.t1', denyAccess)).toMatchObject({
ok: false,
reason: 'no-conn',
});
const a = conn('a', 'sub-a');
hub.register(a.c);
expect(await hub.subscribe('a', 'stream.task.t9', denyAccess)).toMatchObject({
ok: false,
reason: 'denied',
});
});
it('D12:shutdown 逐条调 close 把手并摘除连接', () => {
const hub = new SseHub();
let closed = 0;
const a = conn('a', 'sub-a');
hub.register({ ...a.c, close: () => (closed += 1) });
hub.shutdown();
expect(closed).toBe(1);
hub.deliver('touch.system', '{}');
expect(a.got).toEqual([]); // 已摘除,不再收扇出
});
});
describe('SSE HTTP 面', () => {
beforeAll(async () => {
await setupTestJwks();
});
it('subscribe 主题形状校验 422;无 token 401', async () => {
const app = buildApp();
expect((await app.request('/api/stream/subscribe', { method: 'POST' })).status).toBe(401);
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const bad = await app.request('/api/stream/subscribe', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ connectionId: 'nope', add: ['touch.staff.hack'] }),
});
expect(bad.status).toBe(422);
});
it('F1:形状预检先于变更——整单 422 时一条订阅都不留', async () => {
const hub = new SseHub();
const a = conn('live-1', 'test-sub-1'); // token 的默认 sub,过得了 F2 的归属校验
hub.register(a.c);
// 直接挂 sseRoutes 并注入"一律放行"的授权口:走 buildApp 的话内建 access 会在
// await token() 处就抛(测试环境没有 KEYCLOAK_CLIENT_SECRET),t1 到不了订阅集,
// 这条用例也就证明不了"预检之前不会发生变更"这件事。
let accessCalls = 0;
const access = {
allowed: async (): Promise<boolean> => {
accessCalls += 1;
return true;
},
};
const app = new Hono().route('/api/stream', sseRoutes(hub, access));
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const res = await app.request('/api/stream/subscribe', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ connectionId: 'live-1', add: ['touch.task.t1', 'bogus'] }),
});
expect(res.status).toBe(422);
// 关键:合法的 t1 排在非法项**前面**,边订边查的写法会把它真订上
expect(a.c.topics.has('touch.task.t1')).toBe(false);
expect(a.c.topics.size).toBe(0);
expect(accessCalls).toBe(0); // 且一次授权往返都不该白跑
});
it('F2:连接归属校验——他人 connectionId 一律 403(add 与 remove 同守)', async () => {
const hub = new SseHub();
const app = buildApp(hub);
const owner = conn('own-1', 'test-sub-1');
owner.c.topics.add('touch.task.t1');
hub.register(owner.c);
const post = async (tok: string, payload: unknown): Promise<Response> =>
app.request('/api/stream/subscribe', {
method: 'POST',
headers: { authorization: `Bearer ${tok}`, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
// 闯入者:合法 token,但连接不是他的 —— remove 此前完全不过任何校验
const intruder = await makeToken({ sub: 'sub-intruder', roles: ['user'], aud: 'api-gateway' });
const denied = await post(intruder, { connectionId: 'own-1', remove: ['touch.task.t1'] });
expect(denied.status).toBe(403);
expect(await denied.json()).toMatchObject({ error: 'forbidden' });
expect(owner.c.topics.has('touch.task.t1')).toBe(true); // 没被别人退订掉
// 主人自己来则照常放行——校验不是一刀切拒绝
const self = await makeToken({ roles: ['user'], aud: 'api-gateway' }); // sub=test-sub-1
const ok = await post(self, { connectionId: 'own-1', remove: ['touch.task.t1'] });
expect(ok.status).toBe(200);
expect(owner.c.topics.has('touch.task.t1')).toBe(false);
});
it('F3:task-access 非 200 → false(失败关闭)且 id 都转义进 URL', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const calls: string[] = [];
try {
vi.stubGlobal('fetch', (url: string | URL) => {
calls.push(String(url));
return Promise.resolve(new Response('down', { status: 503 }));
});
const access = createHttpTaskAccess(async () => 'stub-token');
expect(await access.allowed('t 1', 'sub/a')).toBe(false);
expect(calls[0]).toContain('/task/t%201/access?staff=sub%2Fa');
expect(warn).toHaveBeenCalled(); // 不出声的"人人被拒"无从诊断
vi.stubGlobal('fetch', () =>
Promise.resolve(
new Response(JSON.stringify({ allowed: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
),
);
expect(await access.allowed('t1', 's1')).toBe(true);
} finally {
vi.unstubAllGlobals();
warn.mockRestore();
}
});
it('D11/D12:hello 先于回放;hub.shutdown() 能把在途长连收尾', async () => {
const hub = new SseHub(8);
const app = buildApp(hub);
hub.deliver('touch.system', '{"n":1}'); // seq 1
hub.deliver('touch.staff.test-sub-1', '{"n":2}'); // seq 2
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const res = await app.request(`/api/stream?access_token=${token}`, {
headers: { 'last-event-id': '1' },
});
expect(res.status).toBe(200);
const body = res.body;
if (!body) throw new Error('SSE 响应缺流体');
const reader = body.getReader();
const decoder = new TextDecoder();
let text = '';
while (!text.includes('event: touch.staff.test-sub-1')) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
expect(text).toContain('event: hello');
expect(text.indexOf('event: hello')).toBeLessThan(text.indexOf('event: touch.staff.test-sub-1'));
expect(text).not.toContain('event: touch.system'); // seq 1 ≤ lastEventId,不补
// D12:回调里挂住的 promise 必须能被 close 把手 settle——settle 了 streamSSE 才会
// close 流,客户端这边才读得到 done。旧写法 `await new Promise(() => {})` 永不 settle:
// 下面这次 read 会一直吊着(用例超时),线上则是 SIGTERM 关不掉服务。
hub.shutdown();
let ended = false;
for (let i = 0; i < 4 && !ended; i += 1) ended = (await reader.read()).done;
expect(ended).toBe(true);
});
});