fix: SSE 订阅——形状预检先于变更、连接归属校验、task-access 超时与告警
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, beforeAll } from 'vitest';
|
||||
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';
|
||||
|
||||
@@ -88,6 +91,89 @@ describe('SSE HTTP 面', () => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user