feat(database): 后台成员组(MemberGroup)管理与嵌套解析

This commit is contained in:
2026-07-26 18:06:18 +08:00
parent 2f2ece1a3a
commit 11a7ec8004
17 changed files with 2289 additions and 124 deletions
+311
View File
@@ -0,0 +1,311 @@
/**
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
* (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<void> {
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-0028 决策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 actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual(["group.create", "group.update"]);
});
});
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 · 审计(C3/决策4)", () => {
it("建组/加成员/删组写 AuditEntry(挂 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 actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual([
"group.create", "group.member_add", "group.member_remove", "group.delete",
]);
});
});