Files
curriculum-project-hub/hub/test/unit/filelib-audit.test.ts
T

204 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 审计模块纯逻辑单测:哈希链、规范化、保留期下限、客户端信息采集、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");
});
});