Files
curriculum-project-hub/hub/test/integration/member-groups.test.ts
T

316 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 成员组(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<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-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);
});
});