/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */ import { describe, expect, it } from 'vitest'; import { Hono } from 'hono'; import type { Position, Principal, Surface } from '@contracts'; import { positionIdSchema, staffIdSchema } from '@contracts'; import { AccountDisabledError, TokenRejectedError } from '@modules/org'; import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts'; import type { AppEnv } from '../http-env.ts'; import { buildRouteRegistry } from '../route-registry.ts'; import { createTicketIssuer } from '../ticket.ts'; const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; const principal = (ends: Surface[], roles: string[]): Principal => ({ staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles, kind: 'person' }, position, roles, ends, }); const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']); const USER = principal(['workbench'], ['user']); const NOBODY = principal([], ['auditor']); const tickets = createTicketIssuer(Buffer.alloc(32, 3)); const now = new Date('2026-08-25T09:00:00Z'); function makeApp(): Hono { const app = new Hono(); app.use( '*', createAuthMiddleware({ registry: buildRouteRegistry([ { method: 'GET', path: '/api/health', access: 'public' }, { method: 'GET', path: '/api/me', access: 'any' }, { method: 'GET', path: '/api/desk/tasks', access: 'workbench' }, { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, ]), org: { authenticate: async (token) => { if (token === 'admin') return ADMIN; if (token === 'user') return USER; if (token === 'nobody') return NOBODY; if (token === 'disabled') throw new AccountDisabledError('alice'); throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'); }, principalOf: async (id) => (id === 'sub-1' ? USER : undefined), }, tickets, clock: { now: () => now }, }), ); app.get('/api/health', (c) => c.json({ ok: true })); app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null })); app.get('/api/desk/tasks', (c) => c.json({ ok: true })); app.get('/api/gov/tasks', (c) => c.json({ ok: true })); app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true })); app.get('/api/gov/staff', (c) => c.json({ ok: true })); return app; } const app = makeApp(); // app.request() 的类型签名是 Response | Promise(Hono 4.12);包一层 Promise.resolve // 只是把类型收窄成调用方好用的 Promise,不改变行为(await 对两者结果一致)。 const get = (path: string, headers: Record = {}): Promise => Promise.resolve(app.request(path, { headers })); const post = (path: string, headers: Record = {}): Promise => Promise.resolve(app.request(path, { method: 'POST', headers })); const head = (path: string, headers: Record = {}): Promise => Promise.resolve(app.request(path, { method: 'HEAD', headers })); type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; describe('鉴权中间件', () => { it('public 不要凭据', async () => { expect((await get('/api/health')).status).toBe(200); }); it('没凭据 → 401', async () => { const res = await get('/api/me'); expect(res.status).toBe(401); expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' }); }); it('坏 token → 401 带人话', async () => { const res = await get('/api/me', { authorization: 'Bearer garbage' }); expect(res.status).toBe(401); expect(((await res.json()) as Body).message).toContain('签名校验失败'); }); it('any:登录即可,连无端可进的人也行', async () => { const res = await get('/api/me', { authorization: 'Bearer nobody' }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ account: 'alice' }); }); it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => { const res = await get('/api/gov/tasks', { authorization: 'Bearer user' }); expect(res.status).toBe(403); const body = (await res.json()) as Body; expect(body.code).toBe('forbidden'); expect(body.message).toContain('运营中心'); expect(body.message).toContain('工作台'); expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] }); }); it('端够了 → 放行;数组归属任一命中即可', async () => { expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200); expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200); expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403); }); it('账号停用 → 403(重新登录没用)', async () => { const res = await get('/api/me', { authorization: 'Bearer disabled' }); expect(res.status).toBe(403); expect(((await res.json()) as Body).message).toContain('停用'); }); it('票据:GET 上认,POST 上不认', async () => { const { ticket } = tickets.issue('sub-1', now); expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401); }); it('过期票据 → 401 指路重取', async () => { const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000)); const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`); expect(res.status).toBe(401); expect(((await res.json()) as Body).message).toContain('/api/auth/ticket'); }); it('Bearer 与票据同时在:Bearer 说了算', async () => { const { ticket } = tickets.issue('sub-1', now); expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); }); it('HEAD 按 GET 判:端不够 → 403,与 GET 同款', async () => { // Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 c.req.method 仍是 'HEAD' —— // 不归一的话查表必然 miss、落进「any」兜底,于是「只要登录就能跑任何 GET 处理器」。 expect((await get('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); expect((await head('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); }); it('HEAD 该进的还得进:端够了 → 200', async () => { expect((await head('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); }); it('HEAD 上的票据与 GET 同款(归一之后两条路一致)', async () => { const { ticket } = tickets.issue('sub-1', now); expect((await head(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); expect((await head('/api/gov/tasks', {})).status).toBe(401); }); it('未知路由:没凭据先 401,有凭据再 404', async () => { expect((await get('/api/nope')).status).toBe(401); expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); }); }); describe('小件', () => { it('bearerOf 认大小写与空白', () => { expect(bearerOf('bearer abc')).toBe('abc'); expect(bearerOf('Bearer abc ')).toBe('abc'); expect(bearerOf('Basic abc')).toBeUndefined(); expect(bearerOf(undefined)).toBeUndefined(); expect(bearerOf('Bearer ')).toBeUndefined(); }); it('requiredEnds', () => { expect(requiredEnds('public')).toBeUndefined(); expect(requiredEnds('any')).toBeUndefined(); expect(requiredEnds('ops')).toEqual(['ops']); expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']); }); it('forbiddenBody 对无端可进的人如实说', () => { expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进'); }); });