fix: 终审修复——SSE 鉴权 401/503 分类、touch.workflow 默认拒、/subscribe 非数组 422

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lovefyf
2026-08-13 14:26:48 +08:00
co-authored by Claude Opus 4.8
parent e13d86fe4f
commit 9a02094281
3 changed files with 72 additions and 9 deletions
+45 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeAll, vi } from 'vitest';
import { Hono } from 'hono';
import { SseHub } from '../../src/modules/sse/hub';
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';
@@ -62,6 +62,24 @@ describe('SseHub 路由', () => {
});
});
it('I6:touch.workflow.* 默认拒——没有授权规则的主题不进可订阅集', async () => {
// 形状白名单曾经收着 touch.workflow.,而 subscribe 的鉴权分支只覆盖 touch.task./stream.task.:
// 形状一过就直接进订阅集,任何登录用户订 touch.workflow.<任意 id> 都能收别人流程的推送。
// N3 给工作流补上可见性规则之后再放回来。
expect(isSubscribable('touch.workflow.w1')).toBe(false);
expect(isSubscribable('touch.task.t1')).toBe(true);
expect(isSubscribable('stream.task.t1')).toBe(true);
const hub = new SseHub();
const a = conn('a', 'sub-a');
hub.register(a.c);
// 放行一切的 access 口:证明拒绝来自形状白名单本身,而不是碰巧被授权挡住了
const res = await hub.subscribe('a', 'touch.workflow.w1', { allowed: async () => true });
expect(res).toMatchObject({ ok: false, reason: 'bad-shape' });
expect(a.c.topics.size).toBe(0);
hub.deliver('touch.workflow.w1', '{}');
expect(a.got).toEqual([]);
});
it('D12:shutdown 逐条调 close 把手并摘除连接', () => {
const hub = new SseHub();
let closed = 0;
@@ -119,6 +137,32 @@ describe('SSE HTTP 面', () => {
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, { allowed: async () => true }));
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);