feat(database): init database folder frontend and permission

This commit is contained in:
ymy
2026-07-23 23:41:11 +08:00
parent 5df1900ca8
commit 4021e58d5d
67 changed files with 9436 additions and 150 deletions
+227
View File
@@ -0,0 +1,227 @@
/**
* 文件库 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<void> {
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<string> {
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");
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* 文件库树服务集成测试(真实 Postgres)。
* 覆盖:root 创建规则、父级 EDIT+ 创建、D8(404/403)、D11 toggle 冻结、
* D12 move(环拒绝/目标授权/并发对移)、D14 兄弟名冲突、D15 祖先删除整支隐藏、
* D17 breadcrumb 占位、provisioning 状态机、审计落库。
* 运行前提:本地 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,
renameNode,
moveNode,
softDeleteNode,
breadcrumb,
getEffectiveRole,
listChildren,
type FileLibActor,
type TreeServiceDeps,
} from "../../src/database/filelib/treeService.js";
import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
import { FileLibError } from "../../src/database/filelib/model.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 deps(): TreeServiceDeps {
return {
prisma,
groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
versionStore: createInMemoryVersionStore(),
organizationId: DEFAULT_ORG_ID,
storageRoot: "/tmp/filelib-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 } });
}
}
beforeEach(async () => {
await resetDb();
await seedUsers();
});
describe("treeService · 创建规则", () => {
it("root 仅网站管理员可建;创建者自动 MANAGE", async () => {
await expect(createNode(deps(), ALICE, { parentId: null, kind: "FOLDER", name: "根" }))
.rejects.toMatchObject({ statusCode: 403 });
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
expect(await getEffectiveRole(deps(), ADMIN, root.id)).toBe("MANAGE");
});
it("非 root:父级无权限 404、仅 VIEW 403、EDIT+ 可建;项目下禁止子节点", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
await expect(createNode(deps(), BOB, { parentId: root.id, kind: "FOLDER", name: "必修一" }))
.rejects.toMatchObject({ statusCode: 404 });
await createNode(deps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "化学",
grants: [{ principalType: "USER", principalId: "u_alice", role: "VIEW" }],
});
await expect(createNode(deps(), ALICE, { parentId: (await listChildren(deps(), ALICE, null))[0]!.id, kind: "FOLDER", name: "x" }))
.rejects.toMatchObject({ statusCode: 403 });
const child = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
const project = await createNode(deps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" });
expect(project.provisionStatus).toBe("READY"); // provisioning 状态机:mock init 后 READY
expect(project.storageDir).toContain(project.id);
await expect(createNode(deps(), ADMIN, { parentId: project.id, kind: "FOLDER", name: "非法" }))
.rejects.toMatchObject({ statusCode: 400, code: "invalid_parent" });
});
it("D14:活跃兄弟名大小写不敏感唯一 → 409", async () => {
await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "Physics" });
await expect(createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "physics" }))
.rejects.toMatchObject({ statusCode: 409, code: "name_conflict" });
});
});
describe("treeService · D11 独立权限开关", () => {
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 不可见
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"); // 恢复
});
});
describe("treeService · rename / move / delete", () => {
it("rename 需 MANAGE;兄弟名冲突 409", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
const other = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修二" });
await expect(renameNode(deps(), ADMIN, other.id, "必修一"))
.rejects.toMatchObject({ statusCode: 409 });
await expect(renameNode(deps(), BOB, other.id, "改名"))
.rejects.toMatchObject({ statusCode: 404 });
expect((await renameNode(deps(), ADMIN, other.id, "选修")).name).toBe("选修");
});
it("move:拒绝移入自己的子树;目标父需 EDIT+;Group 授权链路生效", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
const b = await createNode(deps(), ADMIN, { parentId: a.id, kind: "FOLDER", name: "B" });
await expect(moveNode(deps(), ADMIN, a.id, b.id))
.rejects.toMatchObject({ statusCode: 400, code: "move_into_own_subtree" });
// bob 在 g_physics 组;组在 root 上有 EDIT → bob 可把 B 移到 root(D12:本节点 MANAGE + 目标父 EDIT)
await createNode(deps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享区",
grants: [{ principalType: "GROUP", principalId: "g_physics", role: "EDIT" }],
});
const shared = (await listChildren(deps(), ADMIN, null)).find((n) => n.name === "共享区")!;
await expect(moveNode(deps(), BOB, b.id, shared.id))
.rejects.toMatchObject({ statusCode: 404 }); // bob 对 B 无 MANAGE → 404
await prisma.fileLibGrant.create({
data: { organizationId: DEFAULT_ORG_ID, nodeId: b.id, principalType: "GROUP", principalId: "g_physics", role: "MANAGE" },
});
const moved = await moveNode(deps(), BOB, b.id, shared.id);
expect(moved.parentId).toBe(shared.id);
const sharedRow = await prisma.fileLibNode.findUnique({ where: { id: shared.id } });
expect(moved.pathIds).toBe(`${sharedRow!.pathIds}/${b.id}`);
});
it("move 到 root 需网站管理员", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
await createNode(deps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "给爱丽丝",
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
});
await expect(moveNode(deps(), ALICE, a.id, null))
.rejects.toMatchObject({ statusCode: 404 }); // ALICE 对 a 无权限
await expect(moveNode(deps(), ADMIN, a.id, null)).resolves.toMatchObject({ parentId: null });
});
it("D15:祖先删除 → 整支后代不可见(404)", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
await softDeleteNode(deps(), ADMIN, root.id);
await expect(getEffectiveRole(deps(), ADMIN, a.id)).rejects.toMatchObject({ statusCode: 404 });
await expect(renameNode(deps(), ADMIN, a.id, "B")).rejects.toMatchObject({ statusCode: 404 });
});
});
describe("treeService · D17 breadcrumb", () => {
it("无 View 的祖先只给占位,不泄露名字", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
await prisma.fileLibGrant.create({
data: { organizationId: DEFAULT_ORG_ID, nodeId: a.id, principalType: "USER", principalId: "u_alice", role: "VIEW" },
});
const crumbs = await breadcrumb(deps(), ALICE, a.id);
expect(crumbs).toHaveLength(2);
expect(crumbs[0]).toMatchObject({ id: null, name: null }); // root 对 ALICE 不可见
expect(crumbs[1]).toMatchObject({ id: a.id, name: "A" });
});
});
describe("treeService · 审计落库(C3)", () => {
it("创建/改名/移动/删除均写 AuditEntry", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
await renameNode(deps(), ADMIN, root.id, "物理学");
await softDeleteNode(deps(), ADMIN, root.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(["folder.create", "folder.rename", "folder.delete"]);
});
});