/** * 审计模块纯逻辑单测:哈希链、规范化、保留期下限、客户端信息采集、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 { 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), "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 { 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"); }); });