forked from EduCraft/curriculum-project-hub
4849a765da
GrantDto 加 principalName:USER → User.displayName,GROUP → MemberGroup.name, 取不到行(用户/组已删)时回落为 principalId,与 /database/api/me 同一回落语义。 解析走批量 helper(两条 IN 查询,非 N+1),listGrants/putGrants/forceAdjustGrants 三个出口共用,保证 GET 与 PUT 响应同形状。组不按 archivedAt 过滤 —— 已归档组的 历史授权仍需显示名字,否则管理员无法辨认后收回。 principalName 是纯展示字段;写路径仍只认 principalId,不得据此做授权判断。 前端: - GrantsPanel 主体列由裸 id 改为展示名,id 移入 title 供排查;收回确认框同步。 - LibraryView 侧栏身份区改用 $me.displayName(/me 早已返回,此前未消费)。 - types.ts 去掉重复声明的 Grant 与无引用的 GroupSearchResult。 集成测试断言三种情形(displayName / 组名 / 已删主体回落)。
265 lines
12 KiB
TypeScript
265 lines
12 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)],
|
|
maxFileBytes: 10 * 1024 * 1024,
|
|
};
|
|
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");
|
|
});
|
|
|
|
it("grants 返回 principalName:USER→displayName / GROUP→组名;取不到行时回落为 id", async () => {
|
|
const rootId = await createRoot(ADMIN_COOKIE());
|
|
const group = await prisma.memberGroup.create({ data: { name: "物理组" } });
|
|
|
|
const put = await app.inject({
|
|
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
|
|
headers: { cookie: ADMIN_COOKIE() },
|
|
payload: {
|
|
grants: [
|
|
{ principalType: "USER", principalId: "u_alice", role: "EDIT" },
|
|
{ principalType: "GROUP", principalId: group.id, role: "VIEW" },
|
|
// 数据库里没有对应 User 行(已删/脏数据)→ 回落为 principalId
|
|
{ principalType: "USER", principalId: "u_ghost", role: "VIEW" },
|
|
],
|
|
},
|
|
});
|
|
expect(put.statusCode).toBe(200);
|
|
|
|
const list = await app.inject({
|
|
method: "GET", url: `/database/api/nodes/${rootId}/grants`,
|
|
headers: { cookie: ADMIN_COOKIE() },
|
|
});
|
|
expect(list.statusCode).toBe(200);
|
|
const byPrincipal = new Map<string, string>(
|
|
list.json().grants.map((g: { principalId: string; principalName: string }) => [g.principalId, g.principalName]),
|
|
);
|
|
expect(byPrincipal.get("u_alice")).toBe("Alice");
|
|
expect(byPrincipal.get(group.id)).toBe("物理组");
|
|
expect(byPrincipal.get("u_ghost")).toBe("u_ghost");
|
|
// 创建者授权也要解析出名字
|
|
const creator = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant);
|
|
expect(creator.principalName).toBe("Admin");
|
|
// PUT 响应与 GET 同形状
|
|
expect(put.json().grants.every((g: { principalName?: string }) => typeof g.principalName === "string")).toBe(true);
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|