/** * 文件库 HTTP 端点集成测试(真实 Postgres + Fastify inject)。 * 覆盖:401 门禁、D8(404/403)、8.1 授权矩阵、文件冲突流(409+审计)、导出状态机。 * 运行前提:本地 PG 且已 migrate(同 filelib-tree.test.ts)。 */ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import Fastify, { type FastifyInstance } from "fastify"; import fastifyCookie from "@fastify/cookie"; import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js"; import { signSession, SESSION_COOKIE_NAME } from "../../src/admin/auth/session.js"; import { registerFileLibRoutes } from "../../src/database/routes/filelibRoutes.js"; import { registerFileRoutes } from "../../src/database/routes/fileRoutes.js"; import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js"; import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js"; import { createManifestStubAdapter } from "../../src/database/filelib/exportService.js"; import type { FileLibRouteDeps } from "../../src/database/filelib/routeShared.js"; const SECRET = "filelib-test-secret"; let app: FastifyInstance; let deps: FileLibRouteDeps; function cookie(userId: string, openId: string): string { return `${SESSION_COOKIE_NAME}=${signSession({ userId, feishuOpenId: openId }, SECRET)}`; } const ADMIN_COOKIE = () => cookie("u_admin", "ou_admin"); const ALICE_COOKIE = () => cookie("u_alice", "ou_alice"); const BOB_COOKIE = () => cookie("u_bob", "ou_bob"); async function seedUsers(): Promise { await prisma.user.create({ data: { id: "u_admin", feishuOpenId: "ou_admin", displayName: "Admin" } }); await prisma.user.create({ data: { id: "u_alice", feishuOpenId: "ou_alice", displayName: "Alice" } }); await prisma.user.create({ data: { id: "u_bob", feishuOpenId: "ou_bob", displayName: "Bob" } }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u_admin", role: "OWNER" }, }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u_alice", role: "MEMBER" }, }); await prisma.organizationMembership.create({ data: { organizationId: DEFAULT_ORG_ID, userId: "u_bob", role: "MEMBER" }, }); } beforeEach(async () => { await resetDb(); await seedUsers(); const versionStore = createInMemoryVersionStore(); deps = { prisma, sessionSecret: SECRET, organizationId: DEFAULT_ORG_ID, storageRoot: "/tmp/filelib-it", groupResolver: createStaticGroupResolver({}), versionStore, exportAdapters: [createManifestStubAdapter(versionStore)], }; app = Fastify({ logger: false }); await app.register(fastifyCookie); await registerFileLibRoutes(app, deps); await registerFileRoutes(app, deps); await app.ready(); }); afterAll(async () => { await app?.close(); }); async function createRoot(cookieHeader: string, name = "物理"): Promise { const res = await app.inject({ method: "POST", url: "/database/api/nodes", headers: { cookie: cookieHeader }, payload: { parentId: null, kind: "FOLDER", name }, }); expect(res.statusCode).toBe(201); return res.json().node.id as string; } describe("filelib http · 门禁与 D8", () => { it("无 session → 401", async () => { const res = await app.inject({ method: "GET", url: "/database/api/nodes" }); expect(res.statusCode).toBe(401); }); it("/me:OWNER → isWebsiteAdmin=true;MEMBER → false", async () => { const admin = await app.inject({ method: "GET", url: "/database/api/me", headers: { cookie: ADMIN_COOKIE() } }); expect(admin.json().isWebsiteAdmin).toBe(true); const alice = await app.inject({ method: "GET", url: "/database/api/me", headers: { cookie: ALICE_COOKIE() } }); expect(alice.json().isWebsiteAdmin).toBe(false); }); it("root 创建:MEMBER 403;无权限节点 GET 404、VIEW 越权 DELETE 403", async () => { const forbidden = await app.inject({ method: "POST", url: "/database/api/nodes", headers: { cookie: ALICE_COOKIE() }, payload: { parentId: null, kind: "FOLDER", name: "越权" }, }); expect(forbidden.statusCode).toBe(403); const rootId = await createRoot(ADMIN_COOKIE()); const notFound = await app.inject({ method: "GET", url: `/database/api/nodes/${rootId}`, headers: { cookie: BOB_COOKIE() } }); expect(notFound.statusCode).toBe(404); await app.inject({ method: "PUT", url: `/database/api/nodes/${rootId}/grants`, headers: { cookie: ADMIN_COOKIE() }, payload: { grants: [{ principalType: "USER", principalId: "u_alice", role: "VIEW" }] }, }); const visible = await app.inject({ method: "GET", url: `/database/api/nodes/${rootId}`, headers: { cookie: ALICE_COOKIE() } }); expect(visible.statusCode).toBe(200); expect(visible.json().node.role).toBe("VIEW"); const denied = await app.inject({ method: "DELETE", url: `/database/api/nodes/${rootId}`, headers: { cookie: ALICE_COOKIE() } }); expect(denied.statusCode).toBe(403); }); }); describe("filelib http · 8.1 授权矩阵", () => { it("非创建者的 MANAGE 授 MANAGE → 403;创建者可授;creator grant 不可收回", async () => { const rootId = await createRoot(ADMIN_COOKIE()); // admin(创建者)授 alice MANAGE → 允许 const put1 = await app.inject({ method: "PUT", url: `/database/api/nodes/${rootId}/grants`, headers: { cookie: ADMIN_COOKIE() }, payload: { grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }] }, }); expect(put1.statusCode).toBe(200); // alice(MANAGE 但非创建者)授 bob MANAGE → 403 const put2 = await app.inject({ method: "PUT", url: `/database/api/nodes/${rootId}/grants`, headers: { cookie: ALICE_COOKIE() }, payload: { grants: [{ principalType: "USER", principalId: "u_bob", role: "MANAGE" }] }, }); expect(put2.statusCode).toBe(403); expect(put2.json().error.code).toBe("only_creator_can_grant_manage"); // alice 授 bob EDIT → 允许 const put3 = await app.inject({ method: "PUT", url: `/database/api/nodes/${rootId}/grants`, headers: { cookie: ALICE_COOKIE() }, payload: { grants: [{ principalType: "USER", principalId: "u_bob", role: "EDIT" }] }, }); expect(put3.statusCode).toBe(200); // creator grant 不可收回 const list = await app.inject({ method: "GET", url: `/database/api/nodes/${rootId}/grants`, headers: { cookie: ADMIN_COOKIE() } }); const creatorGrant = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant); const revoke = await app.inject({ method: "DELETE", url: `/database/api/nodes/${rootId}/grants/${creatorGrant.id}`, headers: { cookie: ADMIN_COOKIE() }, }); expect(revoke.statusCode).toBe(403); expect(revoke.json().error.code).toBe("cannot_touch_creator"); }); }); describe("filelib http · 文件冲突流", () => { it("上传→读→提交→409 冲突→conflict 审计落库", async () => { const res = await app.inject({ method: "POST", url: "/database/api/nodes", headers: { cookie: ADMIN_COOKIE() }, payload: { parentId: null, kind: "PROJECT", name: "TH-141" }, }); const projectId = res.json().node.id as string; const up = await app.inject({ method: "PUT", url: `/database/api/projects/${projectId}/file`, headers: { cookie: ADMIN_COOKIE() }, payload: { path: "docs/a.md", content: "第一版" }, }); expect(up.statusCode).toBe(201); const read = await app.inject({ method: "GET", url: `/database/api/projects/${projectId}/file?path=docs/a.md`, headers: { cookie: ADMIN_COOKIE() }, }); expect(read.json().content).toBe("第一版"); const v1 = read.json().version as string; const commit = await app.inject({ method: "POST", url: `/database/api/projects/${projectId}/file/commits`, headers: { cookie: ADMIN_COOKIE() }, payload: { path: "docs/a.md", baseVersion: v1, content: "第二版" }, }); expect(commit.statusCode).toBe(200); const conflict = await app.inject({ method: "POST", url: `/database/api/projects/${projectId}/file/commits`, headers: { cookie: ADMIN_COOKIE() }, payload: { path: "docs/a.md", baseVersion: v1, content: "幽灵版" }, }); expect(conflict.statusCode).toBe(409); expect(conflict.json().error.currentVersion).toBe(commit.json().version); const audit = await prisma.auditEntry.findFirst({ where: { organizationId: DEFAULT_ORG_ID, action: "file.conflict_detected" }, }); expect(audit).not.toBeNull(); }); }); describe("filelib http · 导出状态机", () => { it("提交 → 轮询 DONE → 下载 manifest", async () => { const res = await app.inject({ method: "POST", url: "/database/api/nodes", headers: { cookie: ADMIN_COOKIE() }, payload: { parentId: null, kind: "PROJECT", name: "导出源" }, }); const projectId = res.json().node.id as string; const submit = await app.inject({ method: "POST", url: `/database/api/projects/${projectId}/exports`, headers: { cookie: ADMIN_COOKIE() }, payload: { target: "manifest" }, }); expect(submit.statusCode).toBe(202); const jobId = submit.json().jobId as string; let job: { status: string } = { status: "QUEUED" }; for (let i = 0; i < 50 && job.status !== "DONE" && job.status !== "FAILED"; i += 1) { await new Promise((r) => setTimeout(r, 50)); const poll = await app.inject({ method: "GET", url: `/database/api/exports/${jobId}`, headers: { cookie: ADMIN_COOKIE() } }); job = poll.json(); } expect(job.status).toBe("DONE"); const download = await app.inject({ method: "GET", url: `/database/api/exports/${jobId}/download`, headers: { cookie: ADMIN_COOKIE() } }); expect(download.statusCode).toBe(200); expect(download.body).toContain("manifest"); }); });