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
+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");
});
});