import { API_PREFIX, listEndpoints } from "@umt/contracts"; import { describe, expect, it } from "vitest"; import { createApp } from "../src/http/app.ts"; import { PUBLIC_AUTH_PATHS } from "../src/http/auth.ts"; import { EventBus } from "../src/http/sse.ts"; import { SERVER_MODULES } from "../src/modules/index.ts"; import type { ServerContext } from "../src/modules/types.ts"; import { DEV_KEY, json, memoryLogger } from "./helpers.ts"; // OpenAPI 里出现的每条 /api/v1 路由都必须登记在 @umt/contracts 的 Api 注册表(web 与测试都从那里取路径/schema)。 describe("Api 注册表覆盖", () => { it("OpenAPI 文档中的每条路由都在 listEndpoints() 里", async () => { const app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger: memoryLogger().logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: SERVER_MODULES, ctx: { role: "all" } as unknown as ServerContext, }); const res = await app.request("/api/openapi.json"); expect(res.status).toBe(200); const doc = (await json(res)) as { paths: Record> }; const served: string[] = []; for (const [path, ops] of Object.entries(doc.paths)) { if (!path.startsWith(`${API_PREFIX}/`)) continue; // /api/openapi.json 等非业务路由 const rel = path.slice(API_PREFIX.length); for (const method of Object.keys(ops)) served.push(`${method.toUpperCase()} ${rel}`); } expect(served.length).toBeGreaterThan(0); const registered = new Set(listEndpoints().map((e) => `${e.method.toUpperCase()} ${e.path}`)); const missing = served.filter((k) => !registered.has(k)).sort(); expect(missing, `以下路由未登记到 packages/contracts/src/api.ts:\n${missing.join("\n")}`).toEqual([]); // 反向:api.ts 登记的每条端点都必须有实现(OpenAPI 里可见) const servedSet = new Set(served); const unimplemented = [...registered].filter((k) => !servedSet.has(k)).sort(); expect(unimplemented, `以下端点登记在 api.ts 但未实现:\n${unimplemented.join("\n")}`).toEqual([]); }); }); // 验收 S10:注册表的鉴权标注与服务端实际行为一致(生成 scope 文档不误导集成方) describe("Api 注册表鉴权标注", () => { it("auth:'public' 的端点恰好是 PUBLIC_AUTH_PATHS", () => { const pub = listEndpoints() .filter((e) => e.auth === "public") .map((e) => e.path) .sort(); expect(pub).toEqual([...PUBLIC_AUTH_PATHS].sort()); }); it("只接受控制台会话的端点都标 auth:'session' 且 scope 为 admin", () => { const session = listEndpoints() .filter((e) => e.auth === "session") .map((e) => `${e.method.toUpperCase()} ${e.path}`) .sort(); expect(session).toEqual( [ "GET /auth/totp", "GET /files/download", "PATCH /approvals/{id}", "POST /approvals", "POST /auth/password", "POST /auth/totp", "POST /files/trash/purge", // W7-A Pi 上游:写、测试、订阅登录只接受控制台会话 "POST /pi/upstreams", "PATCH /pi/upstreams/{id}", "DELETE /pi/upstreams/{id}", "POST /pi/upstreams/{id}/test", "POST /pi/upstreams/{id}/oauth/start", "POST /pi/upstreams/{id}/oauth/complete", "POST /pi/upstreams/{id}/oauth/logout", // W7-C 定时任务看板:全部只接受控制台会话 "GET /schedules", "POST /schedules", "PATCH /schedules/{id}", "DELETE /schedules/{id}", "POST /schedules/{id}/run-now", "POST /schedules/{id}/skip", "GET /schedules/{id}/runs", ].sort(), ); for (const e of listEndpoints().filter((x) => x.auth === "session")) expect(e.scope).toBe("admin"); }); it("auth:'session' 的端点拒绝 Bearer(DEV_ADMIN_KEY 也不行)", async () => { const app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger: memoryLogger().logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: SERVER_MODULES, ctx: { role: "all" } as unknown as ServerContext, }); for (const e of listEndpoints().filter((x) => x.auth === "session")) { const path = e.path.replace("{id}", "00000000-0000-4000-8000-000000000000"); const res = await app.request(`${API_PREFIX}${path}`, { method: e.method.toUpperCase(), headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" }, ...(e.method === "get" ? {} : { body: "{}" }), }); expect(res.status, `${e.method} ${e.path}`).toBe(403); } }); // 验收 07 S-2 反向:没有标 auth:"session" 的端点,不得在中间件或处理器里暗中只认管理员会话。 // DEV key 带 admin scope,对这些端点不应拿到 403。会话中间件排在入参校验之前,所以即使空 body/query 校验失败, // 暗挂的 requireAdminSession 也会先 403 暴露出来;ctx 为空壳,处理器多半 400/500,那无所谓——只要不是 403。 it("未标 auth:'session' 的非公开端点,admin scope 的 Bearer 不会被 403(标注与中间件不漂移)", async () => { const app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger: memoryLogger().logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: SERVER_MODULES, ctx: { role: "all" } as unknown as ServerContext, }); const forbidden: string[] = []; for (const e of listEndpoints().filter((x) => x.auth !== "session" && x.auth !== "public")) { const path = e.path.replace(/\{[^}]+\}/g, "00000000-0000-4000-8000-000000000000"); const ctl = new AbortController(); const res = await Promise.race([ app.request(`${API_PREFIX}${path}`, { method: e.method.toUpperCase(), headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" }, signal: ctl.signal, ...(e.method === "get" ? {} : { body: "{}" }), }), new Promise((r) => setTimeout(() => r(null), 2000)), ]).catch(() => null); ctl.abort(); await res?.body?.cancel().catch(() => undefined); if (res?.status === 403) forbidden.push(`${e.method.toUpperCase()} ${e.path}`); } expect(forbidden, `以下端点实际只认会话,请在 api.ts 标 auth:"session":\n${forbidden.join("\n")}`).toEqual([]); }, 60_000); });