forked from bai/curriculum-project-hub
Merge branch 'maoyuanyang-main'
# Conflicts: # hub/filelib-web/src/lib/GrantsPanel.svelte # hub/filelib-web/src/lib/OverviewPanel.svelte # hub/filelib-web/src/lib/types.ts # hub/src/database/filelib/grantService.ts
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 回收站集成测试(真实 Postgres,ADR-0031;最近打开已由 ADR-0032 移除)。
|
||||
* 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
|
||||
* 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)。
|
||||
* 运行前提:本地 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 {
|
||||
createNode,
|
||||
softDeleteNode,
|
||||
listChildren,
|
||||
renameNode,
|
||||
type FileLibActor,
|
||||
type TreeServiceDeps,
|
||||
} from "../../src/database/filelib/treeService.js";
|
||||
import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js";
|
||||
import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
|
||||
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js";
|
||||
|
||||
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
|
||||
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
|
||||
const BOB: FileLibActor = { userId: "u_bob", isWebsiteAdmin: false };
|
||||
|
||||
function treeDeps(): TreeServiceDeps {
|
||||
return {
|
||||
prisma,
|
||||
groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
|
||||
versionStore: createInMemoryVersionStore(),
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
storageRoot: "/tmp/filelib-test",
|
||||
};
|
||||
}
|
||||
|
||||
function binDeps(): BinDeps {
|
||||
return {
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
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 } });
|
||||
}
|
||||
});
|
||||
|
||||
describe("binService · 列出与可见性", () => {
|
||||
it("只露每支已删子树的顶;管理员全见,直连 MANAGE 可见,无关者不见", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
await createNode(treeDeps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" });
|
||||
// alice 在 child 上直连 MANAGE。
|
||||
const own = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: null, kind: "PROJECT", name: "alice 项目",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||
});
|
||||
|
||||
await softDeleteNode(treeDeps(), ADMIN, child.id); // 删中间层:child 是顶,孙项目不单列
|
||||
await softDeleteNode(treeDeps(), ADMIN, own.id);
|
||||
|
||||
const adminBin = await listBin(binDeps(), ADMIN);
|
||||
expect(adminBin.map((e) => e.name).sort()).toEqual(["alice 项目", "必修一"]);
|
||||
|
||||
const aliceBin = await listBin(binDeps(), ALICE);
|
||||
expect(aliceBin.map((e) => e.name)).toEqual(["alice 项目"]); // 只见自己 MANAGE 的
|
||||
|
||||
const bobBin = await listBin(binDeps(), BOB);
|
||||
expect(bobBin).toEqual([]);
|
||||
|
||||
void root;
|
||||
});
|
||||
});
|
||||
|
||||
describe("binService · 恢复", () => {
|
||||
it("restore 只清本节点:整支立即可见,落 folder.restore 审计;无权者 404", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
||||
|
||||
await expect(restoreBinEntry(binDeps(), BOB, child.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||
await restoreBinEntry(binDeps(), ADMIN, child.id);
|
||||
|
||||
const visible = await listChildren(treeDeps(), ADMIN, root.id);
|
||||
expect(visible.map((c) => c.name)).toContain("必修一");
|
||||
|
||||
const audits = await prisma.auditEntry.findMany({
|
||||
where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore },
|
||||
});
|
||||
expect(audits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ADR-0035:撞名时恢复报 name_conflict_on_restore(不自动改名),清名后可恢复", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
||||
// 删除后同名新建 -> 活跃兄弟占了名字
|
||||
await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
|
||||
await expect(restoreBinEntry(binDeps(), ADMIN, child.id)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
code: "name_conflict_on_restore",
|
||||
});
|
||||
|
||||
// 改名现有节点后恢复 -> 成功,保留原名
|
||||
const active = (await listChildren(treeDeps(), ADMIN, root.id)).find((c) => c.name === "必修一")!;
|
||||
await renameNode(treeDeps(), ADMIN, active.id, "必修一(新)");
|
||||
const result = await restoreBinEntry(binDeps(), ADMIN, child.id);
|
||||
expect(result.name).toBe("必修一");
|
||||
expect(result.renamedFrom).toBeUndefined();
|
||||
|
||||
const visible = await listChildren(treeDeps(), ADMIN, root.id);
|
||||
expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(新)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binService · 彻底删除", () => {
|
||||
it("ADR-0034:与条目可见性同权 —— 直连 MANAGE 可清空,无关者 404;整支硬删 + node.purge 审计", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: root.id, kind: "PROJECT", name: "TH-141",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||
});
|
||||
await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶;alice 在 root 上无直连 MANAGE
|
||||
|
||||
await expect(purgeBinEntry(binDeps(), BOB, root.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||
const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
|
||||
expect(removed).toBe(2);
|
||||
|
||||
expect(await prisma.fileLibNode.count({ where: { id: { in: [root.id, child.id] } } })).toBe(0);
|
||||
expect(await prisma.fileLibGrant.count({ where: { nodeId: child.id } })).toBe(0);
|
||||
|
||||
const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
|
||||
expect(audits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ADR-0034:非管理员的直连 MANAGE 持有者也能彻底删除", async () => {
|
||||
const own = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: null, kind: "PROJECT", name: "alice 项目",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||
});
|
||||
await softDeleteNode(treeDeps(), ADMIN, own.id);
|
||||
|
||||
const { removed } = await purgeBinEntry(binDeps(), ALICE, own.id);
|
||||
expect(removed).toBe(1);
|
||||
expect(await prisma.fileLibNode.count({ where: { id: own.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -80,20 +80,15 @@ describe("treeService · 创建规则", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeService · D11 独立权限开关", () => {
|
||||
it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => {
|
||||
describe("treeService · 项目级 grant 恒生效(ADR-0030)", () => {
|
||||
it("项目级非创建者 grant 创建即生效,创建者仍 MANAGE", async () => {
|
||||
const project = await createNode(deps(), ADMIN, {
|
||||
parentId: null, kind: "PROJECT", name: "TH-141",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
|
||||
});
|
||||
await expect(getEffectiveRole(deps(), ALICE, project.id))
|
||||
.rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见
|
||||
// 无开关、无冻结:alice 的项目级 EDIT 立即可见。
|
||||
expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT");
|
||||
expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE");
|
||||
await prisma.fileLibProjectSettings.update({
|
||||
where: { nodeId: project.id },
|
||||
data: { independentPermissionsEnabled: true },
|
||||
});
|
||||
expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); // 恢复
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export async function resetDb(): Promise<void> {
|
||||
// two tables have no FK to Project and must be cleared explicitly.
|
||||
prisma.permissionGrant.deleteMany(),
|
||||
prisma.permissionSettings.deleteMany(),
|
||||
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
|
||||
// cascade above never reaches it. Clear explicitly — closure/membership
|
||||
// first (they FK into MemberGroup), groups last.
|
||||
prisma.memberGroupClosure.deleteMany(),
|
||||
prisma.memberGroupMembership.deleteMany(),
|
||||
prisma.memberGroup.deleteMany(),
|
||||
prisma.user.deleteMany(),
|
||||
prisma.organization.deleteMany(),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user