feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:54:45 +08:00
parent c96ea60482
commit 26523d1b54
29 changed files with 2881 additions and 209 deletions
+9 -1
View File
@@ -226,10 +226,18 @@ describe("filelib http · 文件冲突流", () => {
expect(conflict.statusCode).toBe(409);
expect(conflict.json().error.currentVersion).toBe(commit.json().version);
const audit = await prisma.auditEntry.findFirst({
// 冲突事件走事务外旁路(ADR-0039):业务抛了 409,日志仍须存在,
// 且带上起始版本与冲突版本。
const audit = await prisma.fileLibAuditLog.findFirst({
where: { organizationId: DEFAULT_ORG_ID, action: "file.conflict_detected" },
});
expect(audit).not.toBeNull();
expect(audit!.result).toBe("FAILURE");
expect(audit!.failureReason).toContain("version_conflict");
expect(audit!.context).toMatchObject({
baseVersion: v1,
currentVersion: commit.json().version,
});
});
});
+35 -7
View File
@@ -163,16 +163,44 @@ describe("treeService · D17 breadcrumb", () => {
});
});
describe("treeService · 审计落库(C3)", () => {
it("创建/改名/移动/删除均写 AuditEntry", async () => {
describe("treeService · 审计落库(ADR-0039)", () => {
it("创建/改名/移动/删除均写 FileLibAuditLog", 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({
const rows = await prisma.fileLibAuditLog.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"]);
orderBy: { seq: "asc" },
});
expect(rows.map((e) => e.action)).toEqual(["folder.create", "folder.rename", "folder.delete"]);
// 改名要留下前后值 —— 这正是旧 metadata blob 做不到的事。
const rename = rows[1]!;
expect(rename.beforeValue).toMatchObject({ name: "物理" });
expect(rename.afterValue).toMatchObject({ name: "物理学" });
expect(rename.result).toBe("SUCCESS");
expect(rename.actorUserId).toBe(ADMIN.userId);
// 姓名快照与对象名称直接落列,查询无需回查 User/Node。
expect(rename.actorName).not.toBe("");
expect(rename.objectName).toBe("物理学");
// 哈希链:seq 连续,prevHash 串上一条。
expect(rows.map((e) => e.seq)).toEqual([1n, 2n, 3n]);
expect(rows[0]!.prevHash).toBeNull();
expect(rows[1]!.prevHash).toBe(rows[0]!.entryHash);
expect(rows[2]!.prevHash).toBe(rows[1]!.entryHash);
});
it("日志只追加:UPDATE 与 DELETE 被数据库触发器拒绝", async () => {
const node = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "不可篡改" });
const row = await prisma.fileLibAuditLog.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, objectId: node.id },
});
await expect(
prisma.$executeRawUnsafe(`UPDATE "FileLibAuditLog" SET "action" = 'forged' WHERE "id" = $1`, row.id),
).rejects.toThrow(/immutable/);
await expect(
prisma.$executeRawUnsafe(`DELETE FROM "FileLibAuditLog" WHERE "id" = $1`, row.id),
).rejects.toThrow(/append-only/);
});
});
+17 -13
View File
@@ -158,15 +158,16 @@ describe("memberGroupService · 改名/改描述(决策6)", () => {
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 });
});
it("改名写 group.update 审计", async () => {
it("改名写 group.update 审计(含前后值)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
const actions = (await prisma.auditEntry.findMany({
const rows = await prisma.fileLibAuditLog.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual(["group.create", "group.update"]);
orderBy: { seq: "asc" },
});
expect(rows.map((e) => e.action)).toEqual(["group.create", "group.update"]);
expect(rows[1]!.beforeValue).toMatchObject({ name: "G" });
expect(rows[1]!.afterValue).toMatchObject({ name: "G2" });
});
});
@@ -293,19 +294,22 @@ describe("memberGroupService · 搜索 breadcrumb", () => {
});
});
describe("memberGroupService · 审计(C3/决策4)", () => {
it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => {
describe("memberGroupService · 审计(ADR-0039/决策4)", () => {
it("建组/加成员/删组写 FileLibAuditLog(挂 silo org)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await removeMember(svc(), ADMIN, g.id, "u_alice");
await deleteMemberGroup(svc(), ADMIN, g.id);
const actions = (await prisma.auditEntry.findMany({
const rows = await prisma.fileLibAuditLog.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual([
orderBy: { seq: "asc" },
});
expect(rows.map((e) => e.action)).toEqual([
"group.create", "group.member_add", "group.member_remove", "group.delete",
]);
// 组不在文件库树上,objectPath 用 group: 前缀 —— 与节点路径空间隔离,
// 因此组日志只对网站管理员可见(ADR-0039)。
expect(rows.every((e) => e.objectPath === `group:${g.id}`)).toBe(true);
expect(rows.every((e) => e.objectType === "GROUP")).toBe(true);
});
});