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);
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* 审计模块纯逻辑单测:哈希链、规范化、保留期下限、客户端信息采集、CSV 导出。
*
* 落库路径(writeAudit / queryAuditLogs)依赖真 DB,不在这一层测 —— 这里只钉
* 那些"算错了不会报错、只会静默产出错数据"的纯函数,尤其是哈希链:
* 它一旦算不稳,防篡改校验就会假报或漏报。
*/
import { describe, expect, it } from "vitest";
import {
AUDIT_RETENTION_DAYS_MIN,
actorName,
canonicalize,
computeEntryHash,
resolveRetentionDays,
type HashableEntry,
} from "../../src/database/audit/auditModel.js";
import { requestClient } from "../../src/database/audit/requestContext.js";
import { toCsv, type AuditEntryDto } from "../../src/database/audit/auditQuery.js";
function entry(partial: Partial<HashableEntry> = {}): HashableEntry {
return {
organizationId: "org1",
seq: 1n,
occurredAt: new Date("2026-08-06T10:00:00.000Z"),
action: "folder.create",
result: "SUCCESS",
failureReason: null,
actorUserId: "u1",
actorName: "张老师",
actorIsAdmin: false,
objectType: "FOLDER",
objectId: "n1",
objectName: "教案",
objectPath: "/n1",
beforeValue: null,
afterValue: { name: "教案" },
context: null,
clientIp: "10.0.0.1",
userAgent: "Mozilla/5.0",
...partial,
};
}
describe("canonicalize · 规范化", () => {
it("键序不影响结果 —— 否则同一条记录换个写法就算出不同哈希", () => {
expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 }));
});
it("嵌套对象同样排序", () => {
expect(canonicalize({ x: { q: 1, p: 2 } })).toBe(canonicalize({ x: { p: 2, q: 1 } }));
});
it("数组保序(顺序是数组的语义)", () => {
expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
});
it("undefined 值不参与,与缺键等价", () => {
expect(canonicalize({ a: 1, b: undefined })).toBe(canonicalize({ a: 1 }));
});
it("null 与缺键不等价", () => {
expect(canonicalize({ a: 1, b: null })).not.toBe(canonicalize({ a: 1 }));
});
});
describe("computeEntryHash · 哈希链", () => {
it("同输入同输出(确定性)", () => {
expect(computeEntryHash(entry(), null)).toBe(computeEntryHash(entry(), null));
});
it("prevHash 不同 → 哈希不同(链得真的串起来)", () => {
expect(computeEntryHash(entry(), null)).not.toBe(computeEntryHash(entry(), "abc"));
});
it.each([
["action", { action: "folder.delete" }],
["actorUserId", { actorUserId: "u2" }],
["actorName", { actorName: "李老师" }],
["actorIsAdmin", { actorIsAdmin: true }],
["objectId", { objectId: "n2" }],
["objectName", { objectName: "改过的名字" }],
["objectPath", { objectPath: "/n2" }],
["result", { result: "FAILURE" as const }],
["failureReason", { failureReason: "forbidden" }],
["beforeValue", { beforeValue: { name: "旧" } }],
["afterValue", { afterValue: { name: "篡改" } }],
["context", { context: { k: 1 } }],
["clientIp", { clientIp: "10.0.0.9" }],
["userAgent", { userAgent: "curl/8" }],
["seq", { seq: 2n }],
["occurredAt", { occurredAt: new Date("2026-08-06T10:00:01.000Z") }],
["organizationId", { organizationId: "org2" }],
])("改 %s 即改哈希(任一字段被动过都要暴露)", (_field, patch) => {
expect(computeEntryHash(entry(patch as Partial<HashableEntry>), "prev")).not.toBe(
computeEntryHash(entry(), "prev"),
);
});
it("seq 用字符串参与,bigint 与等值 number 结果一致", () => {
expect(computeEntryHash(entry({ seq: 5n }), null)).toBe(computeEntryHash(entry({ seq: 5 }), null));
});
it("输出是 64 位 hex(sha256)", () => {
expect(computeEntryHash(entry(), null)).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("resolveRetentionDays · 保留期", () => {
it("默认 180 天", () => {
expect(resolveRetentionDays(undefined)).toBe(AUDIT_RETENTION_DAYS_MIN);
expect(AUDIT_RETENTION_DAYS_MIN).toBe(180);
});
it("可以调高", () => {
expect(resolveRetentionDays("365")).toBe(365);
});
it("调低到下限以下被拒 —— 合规底线不可配置", () => {
expect(resolveRetentionDays("30")).toBe(180);
expect(resolveRetentionDays("0")).toBe(180);
expect(resolveRetentionDays("-1")).toBe(180);
});
it("非法值按缺省处理", () => {
expect(resolveRetentionDays("abc")).toBe(180);
expect(resolveRetentionDays("1.5")).toBe(180);
expect(resolveRetentionDays("")).toBe(180);
});
});
describe("actorName · 姓名快照", () => {
it("有 displayName 用它", () => {
expect(actorName({ userId: "u1", displayName: "张老师" })).toBe("张老师");
});
it("缺失或空白回落 userId —— 字段永不为空", () => {
expect(actorName({ userId: "u1" })).toBe("u1");
expect(actorName({ userId: "u1", displayName: " " })).toBe("u1");
});
});
describe("requestClient · 客户端信息", () => {
it("优先取 X-Forwarded-For 最左一跳(真实客户端)", () => {
const client = requestClient({
ip: "127.0.0.1",
headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1", "user-agent": "UA" },
});
expect(client?.ip).toBe("203.0.113.5");
});
it("无 XFF 时回落 request.ip", () => {
expect(requestClient({ ip: "10.1.2.3", headers: {} })?.ip).toBe("10.1.2.3");
});
it("超长 UA 截断(不撑爆行)", () => {
const client = requestClient({ ip: "1.1.1.1", headers: { "user-agent": "x".repeat(2000) } });
expect(client?.userAgent?.length).toBe(512);
});
it("两项都采不到 → undefined(客户端信息是可选字段)", () => {
expect(requestClient({ headers: {} })).toBeUndefined();
expect(requestClient(undefined)).toBeUndefined();
});
});
describe("toCsv · 导出", () => {
function dto(partial: Partial<AuditEntryDto> = {}): AuditEntryDto {
return {
id: "log1", seq: "1", occurredAt: new Date("2026-08-06T10:00:00.000Z"),
action: "folder.create", result: "SUCCESS", failureReason: null,
actorUserId: "u1", actorName: "张老师", actorIsAdmin: false,
objectType: "FOLDER", objectId: "n1", objectName: "教案", objectPath: "/n1",
beforeValue: null, afterValue: { name: "教案" }, context: null,
clientIp: "10.0.0.1", userAgent: "UA", archivedAt: null,
...partial,
};
}
it("带 UTF-8 BOM(否则 Excel 打开中文全乱码)", () => {
expect(toCsv([])).toMatch(/^/);
});
it("含双引号的值被正确转义", () => {
expect(toCsv([dto({ objectName: 'a"b' })])).toContain('"a""b"');
});
it("公式前缀被中和 —— 防 Excel CSV 注入", () => {
for (const evil of ["=1+1", "+cmd", "-2", "@SUM(A1)"]) {
expect(toCsv([dto({ objectName: evil })])).toContain(`"'${evil}"`);
}
});
it("换行不破坏结构(整格加引号)", () => {
const csv = toCsv([dto({ objectName: "a\nb" })]);
expect(csv).toContain('"a\nb"');
});
it("失败记录带原因", () => {
const csv = toCsv([dto({ result: "FAILURE", failureReason: "version_conflict" })]);
expect(csv).toContain("失败");
expect(csv).toContain("version_conflict");
});
});