forked from EduCraft/curriculum-project-hub
228 lines
9.8 KiB
TypeScript
228 lines
9.8 KiB
TypeScript
/**
|
|
* 文件库 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");
|
|
});
|
|
});
|