forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 文件路径安全单测(Metis 安全红线:穿越/.git/分隔符/深度)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateFilePath, FILE_PATH_MAX_DEPTH, FILE_PATH_MAX_LENGTH } from "../../src/database/filelib/fileService.js";
|
||||
import { FileLibError } from "../../src/database/filelib/model.js";
|
||||
|
||||
describe("validateFilePath · 路径安全", () => {
|
||||
it("正常相对路径通过(含 CJK 与多级)", () => {
|
||||
expect(validateFilePath("a.md")).toBe("a.md");
|
||||
expect(validateFilePath("docs/讲义/第一课.md")).toBe("docs/讲义/第一课.md");
|
||||
expect(validateFilePath("a/b/c/d.txt")).toBe("a/b/c/d.txt");
|
||||
});
|
||||
|
||||
it("拒绝穿越:.. 段、绝对路径", () => {
|
||||
expect(() => validateFilePath("../x")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a/../../x")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("/etc/passwd")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝 .git 段与点段", () => {
|
||||
expect(() => validateFilePath(".git")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a/.git/config")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("./a.md")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝反斜杠、控制字符、空段(双斜杠/尾斜杠)", () => {
|
||||
expect(() => validateFilePath("a\\b.md")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a\nb.md")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a//b.md")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a/b/")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝空路径、超长、超深", () => {
|
||||
expect(() => validateFilePath("")).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath("a".repeat(FILE_PATH_MAX_LENGTH + 1))).toThrowError(FileLibError);
|
||||
expect(() => validateFilePath(Array(FILE_PATH_MAX_DEPTH + 1).fill("d").join("/"))).toThrowError(FileLibError);
|
||||
expect(validateFilePath(Array(FILE_PATH_MAX_DEPTH).fill("d").join("/"))).toContain("d/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 命名规则(D14)单测。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileLibError, nameKey, normalizeNodeName, NODE_NAME_MAX_LENGTH } from "../../src/database/filelib/model.js";
|
||||
|
||||
describe("normalizeNodeName · D14", () => {
|
||||
it("trim 空白", () => {
|
||||
expect(normalizeNodeName(" 物理必修一 ")).toBe("物理必修一");
|
||||
});
|
||||
|
||||
it("NFC 归一化(分解形式 → 合成形式)", () => {
|
||||
expect(normalizeNodeName("é")).toBe("é");
|
||||
});
|
||||
|
||||
it("CJK 与常见字符原样通过", () => {
|
||||
expect(normalizeNodeName("TH-141 表面张力 (上)")).toBe("TH-141 表面张力 (上)");
|
||||
});
|
||||
|
||||
it("空名/全空白 → invalid_name", () => {
|
||||
expect(() => normalizeNodeName("")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName(" ")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("超长 → invalid_name", () => {
|
||||
expect(() => normalizeNodeName("a".repeat(NODE_NAME_MAX_LENGTH + 1))).toThrowError(FileLibError);
|
||||
expect(normalizeNodeName("a".repeat(NODE_NAME_MAX_LENGTH))).toHaveLength(NODE_NAME_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it("拒绝斜杠与反斜杠", () => {
|
||||
expect(() => normalizeNodeName("a/b")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName("a\\b")).toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("拒绝控制字符", () => {
|
||||
expect(() => normalizeNodeName("a\nb")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName("a\tb")).toThrowError(FileLibError);
|
||||
expect(() => normalizeNodeName("a\0b")).toThrowError(FileLibError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nameKey · 大小写不敏感比较键", () => {
|
||||
it("ASCII 折叠", () => {
|
||||
expect(nameKey("Physics")).toBe("physics");
|
||||
});
|
||||
it("CJK 不变", () => {
|
||||
expect(nameKey("物理")).toBe("物理");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。
|
||||
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结;
|
||||
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkAccess, effectiveRole, type EffectiveRoleInput, type FileLibGrantFact } from "../../src/database/filelib/permission.js";
|
||||
|
||||
const base: EffectiveRoleInput = {
|
||||
nodeId: "N",
|
||||
nodeKind: "FOLDER",
|
||||
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
|
||||
independentPermissionsEnabled: false,
|
||||
userId: "u1",
|
||||
groupIds: ["g1"],
|
||||
grants: [],
|
||||
};
|
||||
|
||||
function grant(partial: Partial<FileLibGrantFact>): FileLibGrantFact {
|
||||
return {
|
||||
nodeId: "N",
|
||||
principalType: "USER",
|
||||
principalId: "u1",
|
||||
role: "VIEW",
|
||||
isCreatorGrant: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("effectiveRole · 契约 P6 矩阵", () => {
|
||||
it("无任何 grant → null(无权限)", () => {
|
||||
expect(effectiveRole(base)).toBeNull();
|
||||
});
|
||||
|
||||
it("个人直接 grant 在 self 上 → 生效", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ role: "EDIT" })] })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("Group grant 且用户在组内 → 生效", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ principalType: "GROUP", principalId: "g1", role: "MANAGE" })] })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("Group grant 但用户不在组内 → null", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ principalType: "GROUP", principalId: "g9" })] })).toBeNull();
|
||||
});
|
||||
|
||||
it("祖先链上的 grant 向下继承", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "A", role: "EDIT" })] })).toBe("EDIT");
|
||||
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "R", role: "VIEW" })] })).toBe("VIEW");
|
||||
});
|
||||
|
||||
it("取最高:个人低权限 + Group 高权限 → 高权限(个人不能降权)", () => {
|
||||
const grants = [
|
||||
grant({ role: "VIEW" }),
|
||||
grant({ principalType: "GROUP", principalId: "g1", role: "MANAGE" }),
|
||||
];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("取最高:链上多处 grant,最高者胜", () => {
|
||||
const grants = [
|
||||
grant({ nodeId: "R", role: "MANAGE" }),
|
||||
grant({ nodeId: "A", role: "VIEW" }),
|
||||
grant({ nodeId: "N", role: "EDIT" }),
|
||||
];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("链外节点的 grant 不参与", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "X", role: "MANAGE" })] })).toBeNull();
|
||||
});
|
||||
|
||||
it("他人的个人 grant 不参与", () => {
|
||||
expect(effectiveRole({ ...base, grants: [grant({ principalId: "u2", role: "MANAGE" })] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveRole · D11 独立权限开关", () => {
|
||||
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
|
||||
|
||||
it("开关关闭:项目级非创建者 grant 冻结", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...project, grants })).toBeNull();
|
||||
});
|
||||
|
||||
it("开关关闭:创建者 grant 仍生效", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })];
|
||||
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("开关关闭:祖先链 grant 不受影响", () => {
|
||||
const grants = [
|
||||
grant({ nodeId: "P", role: "MANAGE" }), // 冻结
|
||||
grant({ nodeId: "A", role: "VIEW" }), // 生效
|
||||
];
|
||||
expect(effectiveRole({ ...project, grants })).toBe("VIEW");
|
||||
});
|
||||
|
||||
it("开关开启:项目级 grant 恢复参与", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("文件夹忽略开关(self grant 照常参与)", () => {
|
||||
const grants = [grant({ nodeId: "N", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveRole · 随机化不变量", () => {
|
||||
// 确定性 LCG,替代 property-based 框架(不新增依赖)。
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) % 2147483648;
|
||||
return s / 2147483648;
|
||||
};
|
||||
}
|
||||
const roles = ["VIEW", "EDIT", "MANAGE"] as const;
|
||||
|
||||
it("effective 恰为所有可用 grant 的最高秩(单调、顺序无关)", () => {
|
||||
const rand = lcg(42);
|
||||
for (let round = 0; round < 200; round += 1) {
|
||||
const nodes = ["N", "A", "R", "X"];
|
||||
const principals = ["u1", "u2", "g1", "g9"];
|
||||
const grants: FileLibGrantFact[] = Array.from({ length: Math.floor(rand() * 12) }, () => {
|
||||
const principalId = principals[Math.floor(rand() * principals.length)]!;
|
||||
return grant({
|
||||
nodeId: nodes[Math.floor(rand() * nodes.length)]!,
|
||||
principalType: principalId.startsWith("g") ? "GROUP" : "USER",
|
||||
principalId,
|
||||
role: roles[Math.floor(rand() * roles.length)]!,
|
||||
});
|
||||
});
|
||||
const input: EffectiveRoleInput = { ...base, grants };
|
||||
const applicableRanks = grants
|
||||
.filter((g) => ["N", "A", "R"].includes(g.nodeId))
|
||||
.filter((g) => (g.principalType === "USER" ? g.principalId === "u1" : g.principalId === "g1"))
|
||||
.map((g) => ({ VIEW: 1, EDIT: 2, MANAGE: 3 })[g.role]);
|
||||
const expected = applicableRanks.length === 0 ? 0 : Math.max(...applicableRanks);
|
||||
const actual = effectiveRole(input);
|
||||
expect(actual === null ? 0 : { VIEW: 1, EDIT: 2, MANAGE: 3 }[actual]).toBe(expected);
|
||||
// 顺序无关
|
||||
expect(effectiveRole({ ...input, grants: [...grants].reverse() })).toBe(actual);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAccess · D8 语义", () => {
|
||||
it("无权限 → not_found(404 不泄露)", () => {
|
||||
expect(checkAccess(null, "VIEW")).toEqual({ allowed: false, reason: "not_found" });
|
||||
});
|
||||
it("权限不足 → forbidden(403)", () => {
|
||||
expect(checkAccess("VIEW", "EDIT")).toEqual({ allowed: false, reason: "forbidden" });
|
||||
});
|
||||
it("权限足够 → allowed 并带回实际角色", () => {
|
||||
expect(checkAccess("MANAGE", "EDIT")).toEqual({ allowed: true, role: "MANAGE" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* InMemory VersionStore 语义单测(契约 C1:S1/S2/S7 + 计划 D16/S4)。
|
||||
* mock 必须如实表达:冲突走返回值、baseVersion=null 表新建、init 幂等、
|
||||
* 文件级版本互不影响、同仓库写操作串行。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||
import { FileLibError } from "../../src/database/filelib/model.js";
|
||||
|
||||
const DIR = "/repos/p1";
|
||||
|
||||
describe("InMemoryVersionStore · C1 语义", () => {
|
||||
it("init 幂等(S7);未 init 的仓库操作报 repo_not_found", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await expect(store.head(DIR, "a.md")).rejects.toThrowError(FileLibError);
|
||||
await store.init(DIR);
|
||||
await store.init(DIR); // 幂等,不报错、不重建
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "hello" });
|
||||
await store.init(DIR); // 已有内容后再 init 也不清空
|
||||
expect(await store.head(DIR, "a.md")).toBe("v1");
|
||||
});
|
||||
|
||||
it("新建(baseVersion null)→ ok;head/read 命中", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "v1 内容", author: "u1" });
|
||||
expect(r).toEqual({ status: "ok", version: "v1" });
|
||||
expect((await store.read(DIR, "a.md")).toString()).toBe("v1 内容");
|
||||
});
|
||||
|
||||
it("对已存在文件再次『新建』 → conflict(S2)", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "2" });
|
||||
expect(r).toEqual({ status: "conflict", currentVersion: "v1" });
|
||||
});
|
||||
|
||||
it("baseVersion 落后于当前 → conflict 并带 currentVersion(S1)", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
const ok = await store.commit(DIR, "a.md", { baseVersion: "v1", content: "2" });
|
||||
expect(ok.status).toBe("ok");
|
||||
const stale = await store.commit(DIR, "a.md", { baseVersion: "v1", content: "3" });
|
||||
expect(stale).toEqual({ status: "conflict", currentVersion: "v2" });
|
||||
});
|
||||
|
||||
it("正确 base 连续提交;旧版本仍可读", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "一" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "二" });
|
||||
expect((await store.read(DIR, "a.md", "v1")).toString()).toBe("一");
|
||||
expect((await store.read(DIR, "a.md")).toString()).toBe("二");
|
||||
});
|
||||
|
||||
it("D16 文件级版本:文件 A 的提交不影响文件 B 的 base", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "A1" });
|
||||
const b1 = await store.commit(DIR, "b.md", { baseVersion: null, content: "B1" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "A2" }); // A 前进到 v3
|
||||
// B 仍以自己的 head 为 base,不受 A 推进影响
|
||||
const b2 = await store.commit(DIR, "b.md", {
|
||||
baseVersion: b1.status === "ok" ? b1.version : "",
|
||||
content: "B2",
|
||||
});
|
||||
expect(b2.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("remove:正确 base → ok;head 随后 404;stale base → conflict", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "2" });
|
||||
expect(await store.remove(DIR, "a.md", "v1")).toEqual({ status: "conflict", currentVersion: "v2" });
|
||||
expect((await store.remove(DIR, "a.md", "v2")).status).toBe("ok");
|
||||
await expect(store.head(DIR, "a.md")).rejects.toThrowError(FileLibError);
|
||||
await expect(store.remove(DIR, "a.md", "v3")).rejects.toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("history 新→旧,支持 limit;list 排除已删并按前缀过滤", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "docs/a.md", { baseVersion: null, content: "1", message: "初版" });
|
||||
await store.commit(DIR, "docs/a.md", { baseVersion: "v1", content: "2", message: "二版" });
|
||||
await store.commit(DIR, "other/b.md", { baseVersion: null, content: "x" });
|
||||
const h = await store.history(DIR, "docs/a.md");
|
||||
expect(h.map((v) => v.message)).toEqual(["二版", "初版"]);
|
||||
expect((await store.history(DIR, "docs/a.md", 1)).map((v) => v.message)).toEqual(["二版"]);
|
||||
expect((await store.list(DIR)).map((f) => f.path)).toEqual(["docs/a.md", "other/b.md"]);
|
||||
expect((await store.list(DIR, "docs/")).map((f) => f.path)).toEqual(["docs/a.md"]);
|
||||
await store.remove(DIR, "other/b.md", "v3");
|
||||
expect((await store.list(DIR)).map((f) => f.path)).toEqual(["docs/a.md"]);
|
||||
});
|
||||
|
||||
it("S4 同仓库写串行:并发同 base 提交,恰好一成一冲突", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
|
||||
const [r1, r2] = await Promise.all([
|
||||
store.commit(DIR, "a.md", { baseVersion: "v1", content: "x" }),
|
||||
store.commit(DIR, "a.md", { baseVersion: "v1", content: "y" }),
|
||||
]);
|
||||
const statuses = [r1.status, r2.status].sort();
|
||||
expect(statuses).toEqual(["conflict", "ok"]);
|
||||
});
|
||||
|
||||
it("diff 输出含增删行", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
await store.commit(DIR, "a.md", { baseVersion: null, content: "keep\nold" });
|
||||
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "keep\nnew" });
|
||||
const d = await store.diff(DIR, "a.md", "v1", "v2");
|
||||
expect(d).toContain("-old");
|
||||
expect(d).toContain("+new");
|
||||
expect(d).toContain(" keep");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user