Files
lovefyfandClaude Fable 5 5ebef45b1d feat: SSE 放行 touch.workflow——参与人+admin 可见性(经任务服务归属端口)
I6 把 touch.workflow. 从可订阅集摘出去,理由是**当时没有规则**:形状一过就直接进订阅集,
而鉴权分支只覆盖 touch.task./stream.task.,任何登录用户订 touch.workflow.<任意 id> 都能收
别人流程的推送。N3 的引擎每次推进都发 touch.workflow.<instanceId>,规则也定了:参与人
(节点 ownerSub)/审批人(approverSub)/admin。规则到位,故放回来。

- workflow-access.ts:与 task-access 同形的 HTTP 口——服务账号 token、3s 超时、非 200
  一律 false 并告警(失败关闭)。打的是任务侧新加的 /workflow/:id/access。
- hub:SUBSCRIBABLE 收回 touch.workflow.,subscribe 补上对应鉴权分支——放形状与补鉴权
  必须同一次改动,只放形状就是把 I6 原样退回去。admin 旁路(全可见,也免掉白跑的往返)。
- 两口分开而不是复用一口:任务问的是单张单的归属,工作流问的是实例**定义里**的节点归属。
- app:两口共用同一个 token provider,缓存只此一份。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:07:47 +08:00

325 lines
15 KiB
TypeScript
Raw Permalink 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, isSubscribable } 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 { createHttpWorkflowAccess } from '../../src/modules/sse/workflow-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 };
}
/** 一律放行/一律拒绝的假口:两口分开传,才证明得了"这一拒来自哪一口" */
const ALLOW = { allowed: async (): Promise<boolean> => true };
const DENY = { allowed: async (): Promise<boolean> => false };
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 res = await hub.subscribe('a', 'stream.task.t9', DENY, ALLOW);
expect(res.ok).toBe(false);
expect(a.c.topics.has('stream.task.t9')).toBe(false);
});
it('D3:形状校验排在连接查找之前,失败带判别式 reason 供路由分流', async () => {
const hub = new SseHub();
// 连接不存在 + 形状也不对 → 必须报形状(否则 HTTP 面永远回不出 422)
expect(await hub.subscribe('nope', 'touch.staff.hack', DENY, DENY)).toMatchObject({
ok: false,
reason: 'bad-shape',
});
expect(await hub.subscribe('nope', 'touch.task.t1', DENY, DENY)).toMatchObject({
ok: false,
reason: 'no-conn',
});
const a = conn('a', 'sub-a');
hub.register(a.c);
expect(await hub.subscribe('a', 'stream.task.t9', DENY, DENY)).toMatchObject({
ok: false,
reason: 'denied',
});
});
it('N3(原 I6 反转):touch.workflow.* 放回可订阅集——规则到位了才收得进来', async () => {
// I6 当初把它摘出去,是因为**当时没有规则**:形状一过就直接进订阅集,而鉴权分支只覆盖
// touch.task./stream.task.,任何登录用户订 touch.workflow.<任意 id> 都能收别人流程的推送。
// N3 补上归属口(参与人/审批人,admin 旁路)之后放回来——放形状与补鉴权是同一次改动,
// 下面两条用例就是"规则确实生效"的那一半,缺了它这条断言等于把 I6 原样退回去。
expect(isSubscribable('touch.workflow.w1')).toBe(true);
expect(isSubscribable('touch.task.t1')).toBe(true);
expect(isSubscribable('stream.task.t1')).toBe(true);
// 放回的只是这一节:多一节的主题、以及别的域照旧不可订阅
expect(isSubscribable('touch.workflow.w1.extra')).toBe(false);
expect(isSubscribable('touch.workflow.')).toBe(false);
expect(isSubscribable('touch.staff.hack')).toBe(false);
});
it('N3:工作流按归属口放行——拒则不入集不投递,准则入集且收得到', async () => {
const hub = new SseHub();
const a = conn('a', 'sub-a');
hub.register(a.c);
// 任务口一律放行:这样"被拒"只可能来自**工作流**那一口,而不是碰巧被任务授权挡住
const denied = await hub.subscribe('a', 'touch.workflow.w1', ALLOW, DENY);
expect(denied).toMatchObject({ ok: false, reason: 'denied' });
expect(denied.ok === false && denied.message).toContain('参与人/审批人或 admin');
expect(a.c.topics.size).toBe(0);
hub.deliver('touch.workflow.w1', '{}');
expect(a.got).toEqual([]); // 拒了就一条都不该扇过去
const asked: string[][] = [];
const ok = await hub.subscribe('a', 'touch.workflow.w1', ALLOW, {
allowed: async (id: string, sub: string): Promise<boolean> => {
asked.push([id, sub]);
return true;
},
});
expect(ok).toEqual({ ok: true });
expect(asked).toEqual([['w1', 'sub-a']]); // 问的是主题末节+**连接的** staffSub,不是调用方的
hub.deliver('touch.workflow.w1', '{"n":1}');
expect(a.got.map((e) => e.topic)).toEqual(['touch.workflow.w1']);
});
it('N3:admin 订工作流不问归属口——admin 全可见,白跑一次跨服务往返没意义', async () => {
const hub = new SseHub();
const a = conn('adm', 'sub-adm');
hub.register({ ...a.c, isAdmin: true }); // topics/send 与 a.c 共用,断言照旧看 a
let calls = 0;
const counting = {
allowed: async (): Promise<boolean> => {
calls += 1;
return false; // 真被问到就会拒——订上了即证明没问
},
};
expect(await hub.subscribe('adm', 'touch.workflow.w9', counting, counting)).toEqual({ ok: true });
expect(calls).toBe(0);
expect(a.c.topics.has('touch.workflow.w9')).toBe(true);
hub.deliver('touch.workflow.w9', '{}');
expect(a.got.map((e) => e.topic)).toEqual(['touch.workflow.w9']);
});
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, 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('I7:add/remove 不是数组 → 422(不是 500)', async () => {
// `as string[]` 只是断言。字符串上没有 .find,此前直接 TypeError 冒到 500——
// 请求写错却报成服务端故障,而 500 在网关这一层还会被客户端/重试层当成可重试。
const hub = new SseHub();
const a = conn('live-7', 'test-sub-1');
hub.register(a.c);
const app = new Hono().route('/api/stream', sseRoutes(hub, ALLOW, ALLOW));
const token = await makeToken({ roles: ['user'], aud: 'api-gateway' });
const post = async (payload: unknown): Promise<Response> =>
app.request('/api/stream/subscribe', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const badAdd = await post({ connectionId: 'live-7', add: 'touch.task.t1' });
expect(badAdd.status).toBe(422);
expect(await badAdd.json()).toMatchObject({ error: 'validation_failed' });
const badRemove = await post({ connectionId: 'live-7', remove: { '0': 'touch.task.t1' } });
expect(badRemove.status).toBe(422);
expect(a.c.topics.size).toBe(0); // 422 零副作用
// 缺省(undefined)仍然合法:只订不退、只退不订都是正常用法
expect((await post({ connectionId: 'live-7', add: ['touch.task.t1'] })).status).toBe(200);
expect(a.c.topics.has('touch.task.t1')).toBe(true);
});
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('workflow-access 与 task-access 同形:非 200 → false(失败关闭),打的是 /workflow/:id/access', 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 wf = createHttpWorkflowAccess(async () => 'stub-token');
expect(await wf.allowed('w 1', 'sub/a')).toBe(false);
// 路径与任务口分开:任务侧是两条端点,判据也不同源
expect(calls[0]).toContain('/workflow/w%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 wf.allowed('w1', '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);
});
});