/** * 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0038。 * 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递 * (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。 * 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。 */ import { beforeEach, describe, expect, it } from "vitest"; import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js"; import { addMember, createMemberGroup, deleteMemberGroup, listMemberGroups, listMembers, removeMember, searchMemberGroups, searchUsers, updateMemberGroup, type MemberGroupServiceDeps, } from "../../src/database/filelib/memberGroupService.js"; import { createMemberGroupResolver } from "../../src/database/filelib/memberGroupResolver.js"; import { createNode, getEffectiveRole, type FileLibActor, type TreeServiceDeps, } from "../../src/database/filelib/treeService.js"; import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js"; const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true }; const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false }; function svc(): MemberGroupServiceDeps { return { prisma, organizationId: DEFAULT_ORG_ID }; } /** treeService deps 用真实 MemberGroup 解析器,串起「组授权 → 成员生效」全链路。 */ function treeDeps(): TreeServiceDeps { return { prisma, groupResolver: createMemberGroupResolver(prisma), versionStore: createInMemoryVersionStore(), organizationId: DEFAULT_ORG_ID, storageRoot: "/tmp/member-groups-test", }; } async function seedUsers(): Promise { for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) { await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } }); } } /** 建嵌套链 A>B>C,返回三者 id。 */ async function seedChain(): Promise<{ a: string; b: string; c: string }> { const a = await createMemberGroup(svc(), ADMIN, { name: "A" }); const b = await createMemberGroup(svc(), ADMIN, { name: "B", parentId: a.id }); const c = await createMemberGroup(svc(), ADMIN, { name: "C", parentId: b.id }); return { a: a.id, b: b.id, c: c.id }; } beforeEach(async () => { await resetDb(); await seedUsers(); }); describe("memberGroupService · 创建与闭包", () => { it("建根 depth0;建子继承祖先闭包,depth 递增", async () => { const { a, b, c } = await seedChain(); // 闭包不变量:C 有 (A,C,2)/(B,C,1)/(C,C,0) 三行。 const closureC = await prisma.memberGroupClosure.findMany({ where: { descendantId: c }, orderBy: { depth: "asc" }, }); expect(closureC.map((r) => [r.ancestorId, r.depth])).toEqual([ [c, 0], [b, 1], [a, 2], ]); const groups = await listMemberGroups(svc(), ADMIN); const byId = new Map(groups.map((g) => [g.id, g])); expect(byId.get(a)?.depth).toBe(0); expect(byId.get(b)?.depth).toBe(1); expect(byId.get(c)?.depth).toBe(2); expect(byId.get(b)?.parentId).toBe(a); }); it("父组不存在/已归档 → 404", async () => { await expect(createMemberGroup(svc(), ADMIN, { name: "X", parentId: "nope" })) .rejects.toMatchObject({ statusCode: 404 }); }); }); describe("memberGroupResolver · 解析(直接组 ∪ 活跃祖先)", () => { it("成员在 C → 解析得 {C,B,A};无所属 → 空", async () => { const { a, b, c } = await seedChain(); await addMember(svc(), ADMIN, c, { userId: "u_alice" }); const resolver = createMemberGroupResolver(prisma); const ids = await resolver.resolveMemberGroupIds("u_alice"); expect([...ids].sort()).toEqual([a, b, c].sort()); expect(await resolver.resolveMemberGroupIds("u_bob")).toEqual([]); }); }); describe("memberGroupService · 3.2 祖先授权递归传递", () => { it("给祖先组 A 授文件夹权限 → C 的成员经 effectiveRole 拿到该权限", async () => { const { a, c } = await seedChain(); await addMember(svc(), ADMIN, c, { userId: "u_alice" }); // ADMIN 建根文件夹,授权给"祖先组 A"。ALICE 只在 C,靠祖先方向解析命中 A。 const folder = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "共享", grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }], }); expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT"); }); }); describe("memberGroupService · 改名/改描述(决策6)", () => { it("改名不动闭包:depth/parentId/子树关系全保持", async () => { const { a, b, c } = await seedChain(); const before = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] }); const updated = await updateMemberGroup(svc(), ADMIN, b, { name: "B2", description: "改后" }); expect(updated.name).toBe("B2"); expect(updated.description).toBe("改后"); expect(updated.parentId).toBe(a); expect(updated.depth).toBe(1); // 闭包逐行未变 —— rename 不碰层级(ADR-0038 决策6 的核心不变量)。 const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] }); expect(after).toEqual(before); // C 仍在 B 之下,depth 不变。 const byId = new Map((await listMemberGroups(svc(), ADMIN)).map((g) => [g.id, g])); expect(byId.get(c)?.depth).toBe(2); expect(byId.get(c)?.parentId).toBe(b); }); it("空描述清空;字段缺省则不动;两者皆缺 → 400", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G", description: "原描述" }); expect((await updateMemberGroup(svc(), ADMIN, g.id, { description: "" })).description).toBeNull(); // 只传 name → 描述保持(此时已是 null)。 const renamed = await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" }); expect(renamed.name).toBe("G2"); expect(renamed.description).toBeNull(); await expect(updateMemberGroup(svc(), ADMIN, g.id, {})).rejects.toMatchObject({ statusCode: 400 }); }); it("空名 → 400;已归档组 → 404;非管理员 → 403", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: " " })).rejects.toMatchObject({ statusCode: 400 }); await expect(updateMemberGroup(svc(), ALICE, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 403 }); await deleteMemberGroup(svc(), ADMIN, g.id); await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 }); }); it("改名写 group.update 审计(含前后值)", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" }); const rows = await prisma.fileLibAuditLog.findMany({ where: { organizationId: DEFAULT_ORG_ID }, orderBy: { seq: "asc" }, }); expect(rows.map((e) => e.action)).toEqual(["group.create", "group.update"]); expect(rows[1]!.beforeValue).toMatchObject({ name: "G" }); expect(rows[1]!.afterValue).toMatchObject({ name: "G2" }); }); }); describe("memberGroupService · 级联软删 + 实时失效", () => { it("软删 B → B、C 从 list/解析消失,经此支的权限立即失效", async () => { const { a, b, c } = await seedChain(); await addMember(svc(), ADMIN, c, { userId: "u_alice" }); const folder = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "共享", grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }], }); expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT"); const { archivedCount } = await deleteMemberGroup(svc(), ADMIN, b); expect(archivedCount).toBe(2); // B + C const remaining = (await listMemberGroups(svc(), ADMIN)).map((g) => g.id); expect(remaining).toEqual([a]); // C 已归档 → ALICE 的直接组失效 → 解析空 → 对该文件夹不再可见(D8 → 404)。 expect(await createMemberGroupResolver(prisma).resolveMemberGroupIds("u_alice")).toEqual([]); await expect(getEffectiveRole(treeDeps(), ALICE, folder.id)).rejects.toMatchObject({ statusCode: 404 }); }); }); describe("memberGroupService · 成员增删", () => { it("重复添加 → 409;移除后可重新添加", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await addMember(svc(), ADMIN, g.id, { userId: "u_alice" }); await expect(addMember(svc(), ADMIN, g.id, { userId: "u_alice" })) .rejects.toMatchObject({ statusCode: 409 }); await removeMember(svc(), ADMIN, g.id, "u_alice"); expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(0); // 重加(revokedAt 软删允许 @@unique([groupId,userId,revokedAt]) 下的新活跃行)。 await addMember(svc(), ADMIN, g.id, { userId: "u_alice" }); expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(1); }); it("按飞书 openId 解析成员", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); const m = await addMember(svc(), ADMIN, g.id, { feishuOpenId: "ou_bob" }); expect(m.userId).toBe("u_bob"); }); it("移除不存在成员 → 404", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await expect(removeMember(svc(), ADMIN, g.id, "u_alice")) .rejects.toMatchObject({ statusCode: 404 }); }); }); describe("memberGroupService · 管理门禁(决策2)", () => { it("非管理员调 CRUD/成员 → 403;搜索不限管理员", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await expect(createMemberGroup(svc(), ALICE, { name: "X" })).rejects.toMatchObject({ statusCode: 403 }); await expect(deleteMemberGroup(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 }); await expect(listMemberGroups(svc(), ALICE)).rejects.toMatchObject({ statusCode: 403 }); await expect(listMembers(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 }); await expect(addMember(svc(), ALICE, g.id, { userId: "u_bob" })).rejects.toMatchObject({ statusCode: 403 }); await expect(removeMember(svc(), ALICE, g.id, "u_bob")).rejects.toMatchObject({ statusCode: 403 }); // 搜索:非管理员可调(授权选择器)。 const results = await searchMemberGroups(svc(), "G"); expect(results.map((r) => r.id)).toContain(g.id); }); }); describe("memberGroupService · 成员选择器 searchUsers", () => { it("按显示名/openId 搜;excludeGroupId 排除已在组成员;仅管理员", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); // 空 q 列出全部(3 个 seed 用户)。 expect((await searchUsers(svc(), ADMIN, "")).length).toBe(3); // 按 displayName 命中(seed 的 displayName 即 id)。 expect((await searchUsers(svc(), ADMIN, "alice")).map((u) => u.userId)).toEqual(["u_alice"]); // 按 openId 命中。 expect((await searchUsers(svc(), ADMIN, "ou_bob")).map((u) => u.userId)).toEqual(["u_bob"]); // 已在组的人被排除 —— 避免选中必然 409 的候选。 await addMember(svc(), ADMIN, g.id, { userId: "u_alice" }); const ids = (await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId); expect(ids).not.toContain("u_alice"); expect(ids).toContain("u_bob"); // 移除后重新成为候选(revokedAt 软删)。 await removeMember(svc(), ADMIN, g.id, "u_alice"); expect((await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId)).toContain("u_alice"); await expect(searchUsers(svc(), ALICE, "")).rejects.toMatchObject({ statusCode: 403 }); }); }); describe("memberGroupService · 成员表字段", () => { it("listMembers 返回 openId/avatar/joinedAt(供成员表列展示)", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await addMember(svc(), ADMIN, g.id, { userId: "u_alice" }); const [m] = await listMembers(svc(), ADMIN, g.id); expect(m).toMatchObject({ userId: "u_alice", displayName: "u_alice", feishuOpenId: "ou_alice", avatarUrl: null, }); expect(m?.joinedAt).toBeInstanceOf(Date); }); }); describe("memberGroupService · 搜索 breadcrumb", () => { it("breadcrumb 由活跃祖先链按 depth 拼(根在前)", async () => { const { c } = await seedChain(); const results = await searchMemberGroups(svc(), "C"); const hit = results.find((r) => r.id === c); expect(hit?.breadcrumb).toBe("A / B / C"); }); it("归档组不出现在搜索(G3)", async () => { const { a, b } = await seedChain(); await deleteMemberGroup(svc(), ADMIN, b); // 归档 B、C const ids = (await searchMemberGroups(svc(), "")).map((r) => r.id); expect(ids).toContain(a); expect(ids).not.toContain(b); }); }); describe("memberGroupService · 审计(ADR-0039/决策4)", () => { it("建组/加成员/删组写 FileLibAuditLog(挂 silo org)", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await addMember(svc(), ADMIN, g.id, { userId: "u_alice" }); await removeMember(svc(), ADMIN, g.id, "u_alice"); await deleteMemberGroup(svc(), ADMIN, g.id); const rows = await prisma.fileLibAuditLog.findMany({ where: { organizationId: DEFAULT_ORG_ID }, orderBy: { seq: "asc" }, }); expect(rows.map((e) => e.action)).toEqual([ "group.create", "group.member_add", "group.member_remove", "group.delete", ]); // 组不在文件库树上,objectPath 用 group: 前缀 —— 与节点路径空间隔离, // 因此组日志只对网站管理员可见(ADR-0039)。 expect(rows.every((e) => e.objectPath === `group:${g.id}`)).toBe(true); expect(rows.every((e) => e.objectType === "GROUP")).toBe(true); }); });