Files
SnapOtter/tests/unit/api/audit-mutation.test.ts
T
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
2026-07-24 17:36:57 +08:00

474 lines
18 KiB
TypeScript

/**
* Mutation-focused unit tests for apps/api/src/lib/audit.ts.
*
* These target the survived / no-coverage mutants the existing audit-lib and
* audit-helpers suites leave alive:
* - sanitizeAuditInput: the strip regex, the 200-char slice boundary, and the
* "(empty)" fallback.
* - isToolAuditEnabled: the settings === "true" comparison, the enterprise
* "audit_export" fallback, and the fail-closed catch paths.
* - auditLog: the EXACT values written to the DB (actorId / actorUsername /
* targetType / targetId coalescing chains) and the tamper-resistant HMAC
* branch (the DATA_ENCRYPTION_KEY gate, the tamperResistantAudit === "true"
* gate, and the integrity UPDATE it issues).
* - deriveTargetType: driven through the REAL auditLog insert (the existing
* audit-helpers test only exercises a hand-copied reproduction, so the real
* function's branches survive).
*
* This lane is container-free: the db, config, enterprise, and encryption
* modules are mocked so no Postgres/Redis is required.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Hoisted mock state ──────────────────────────────────────────────────
const state = vi.hoisted(() => ({
insertValues: [] as Record<string, unknown>[],
updateSet: [] as Record<string, unknown>[],
updateWhereCalled: 0,
// Rows returned by db.select().from().where().limit() in call order.
selectRows: [] as unknown[][],
selectIdx: 0,
selectThrows: false,
insertThrows: false,
encryptionKey: "" as string,
auditExportEnabled: false,
enterpriseThrows: false,
}));
vi.mock("../../../apps/api/src/config.js", () => ({
env: {
get DATA_ENCRYPTION_KEY() {
return state.encryptionKey;
},
},
}));
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
insert: () => ({
values: (v: Record<string, unknown>) => {
state.insertValues.push(v);
if (state.insertThrows) throw new Error("DB write failed");
return Promise.resolve();
},
}),
update: () => ({
set: (v: Record<string, unknown>) => {
state.updateSet.push(v);
return {
where: () => {
state.updateWhereCalled += 1;
return Promise.resolve();
},
};
},
}),
select: () => ({
from: () => ({
where: () => ({
limit: () => {
if (state.selectThrows) return Promise.reject(new Error("settings query failed"));
return Promise.resolve(state.selectRows[state.selectIdx++] ?? []);
},
}),
}),
}),
},
schema: {
auditLog: { id: "id" },
settings: { key: "key", value: "value" },
},
}));
vi.mock("@snapotter/enterprise", () => ({
isFeatureEnabled: (feature: string) => {
if (state.enterpriseThrows) throw new Error("enterprise unavailable");
return feature === "audit_export" ? state.auditExportEnabled : false;
},
}));
// deriveAuditHmacKey / computeHmac are real: we assert a real hex string lands in
// the integrity UPDATE, which pins the HMAC branch end-to-end.
import {
auditFromRequest,
auditLog,
isToolAuditEnabled,
sanitizeAuditInput,
} from "../../../apps/api/src/lib/audit.js";
function makeLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
child: vi.fn(),
level: "info",
silent: vi.fn(),
};
}
beforeEach(() => {
state.insertValues = [];
state.updateSet = [];
state.updateWhereCalled = 0;
state.selectRows = [];
state.selectIdx = 0;
state.selectThrows = false;
state.insertThrows = false;
state.encryptionKey = "";
state.auditExportEnabled = false;
state.enterpriseThrows = false;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
// ── sanitizeAuditInput ──────────────────────────────────────────────────
describe("sanitizeAuditInput", () => {
it("strips every disallowed character", () => {
// Contains <, >, &, dquote, squote, CR, LF, NUL, NEL, LS(U+2028), PS(U+2029).
const raw = "a<b>c&d\"e'f\r\n\0g\x85h\u2028i\u2029j";
expect(sanitizeAuditInput(raw)).toBe("abcdefghij");
});
it("keeps a clean string unchanged", () => {
expect(sanitizeAuditInput("plain-value_123")).toBe("plain-value_123");
});
it("truncates to exactly 200 characters", () => {
const result = sanitizeAuditInput("x".repeat(250));
expect(result.length).toBe(200);
expect(result).toBe("x".repeat(200));
});
it("does not truncate a string of exactly 200 characters", () => {
const result = sanitizeAuditInput("y".repeat(200));
expect(result.length).toBe(200);
});
it("keeps 199 characters intact (slice boundary is 200, not off-by-one)", () => {
const result = sanitizeAuditInput("z".repeat(199));
expect(result.length).toBe(199);
});
it("returns the (empty) sentinel when the input is empty", () => {
expect(sanitizeAuditInput("")).toBe("(empty)");
});
it("returns the (empty) sentinel when every character is stripped", () => {
expect(sanitizeAuditInput("<>&\"'\r\n")).toBe("(empty)");
});
});
// ── isToolAuditEnabled ──────────────────────────────────────────────────
describe("isToolAuditEnabled", () => {
it("returns true when the setting value is exactly the string 'true'", async () => {
state.selectRows = [[{ value: "true" }]];
await expect(isToolAuditEnabled()).resolves.toBe(true);
});
it("does not treat a non-'true' setting value as enabled", async () => {
// "1" / "TRUE" / "yes" must NOT count: pins the === "true" comparison.
state.selectRows = [[{ value: "1" }]];
state.auditExportEnabled = false;
await expect(isToolAuditEnabled()).resolves.toBe(false);
});
it("falls back to the enterprise audit_export feature when the setting is absent", async () => {
state.selectRows = [[]];
state.auditExportEnabled = true;
await expect(isToolAuditEnabled()).resolves.toBe(true);
});
it("checks the audit_export feature specifically, not some other feature", async () => {
// isFeatureEnabled returns true ONLY for "audit_export"; if the source passed
// a different feature id the mock would return false. Asserting true pins the
// literal "audit_export".
state.selectRows = [[]];
state.auditExportEnabled = true;
await expect(isToolAuditEnabled()).resolves.toBe(true);
});
it("returns false when neither the setting nor the enterprise feature enable it", async () => {
state.selectRows = [[]];
state.auditExportEnabled = false;
await expect(isToolAuditEnabled()).resolves.toBe(false);
});
it("falls through to the enterprise check when the settings query throws", async () => {
// The first try/catch swallows the DB error and reaches the enterprise path,
// which here enables audit_export -> true. Pins that the catch does not
// short-circuit to false.
state.selectThrows = true;
state.auditExportEnabled = true;
await expect(isToolAuditEnabled()).resolves.toBe(true);
});
it("returns false when the settings query throws and enterprise is unavailable", async () => {
state.selectThrows = true;
state.enterpriseThrows = true;
await expect(isToolAuditEnabled()).resolves.toBe(false);
});
});
// ── auditLog: exact DB-write values + actor/target derivation ────────────
describe("auditLog DB write values", () => {
it("writes userId as actorId in preference to adminId", async () => {
await auditLog(makeLogger() as never, "USER_UPDATED", {
userId: "user-1",
adminId: "admin-9",
targetUserId: "target-7",
});
expect(state.insertValues).toHaveLength(1);
expect(state.insertValues[0].actorId).toBe("user-1");
});
it("falls back to adminId for actorId when userId is absent", async () => {
await auditLog(makeLogger() as never, "ROLE_CREATED", {
adminId: "admin-9",
roleName: "editor",
});
expect(state.insertValues[0].actorId).toBe("admin-9");
});
it("writes actorId null when neither userId nor adminId is present", async () => {
await auditLog(makeLogger() as never, "LOGOUT");
expect(state.insertValues[0].actorId).toBeNull();
});
it("prefers username over newUsername for actorUsername", async () => {
await auditLog(makeLogger() as never, "USER_UPDATED", {
username: "alice",
newUsername: "bob",
});
expect(state.insertValues[0].actorUsername).toBe("alice");
});
it("falls back to newUsername for actorUsername", async () => {
await auditLog(makeLogger() as never, "USER_CREATED", { newUsername: "bob" });
expect(state.insertValues[0].actorUsername).toBe("bob");
});
it("defaults actorUsername to 'system' when no username is present", async () => {
await auditLog(makeLogger() as never, "SETTINGS_UPDATED", {});
expect(state.insertValues[0].actorUsername).toBe("system");
});
it("prefers targetUserId over keyId for targetId", async () => {
await auditLog(makeLogger() as never, "USER_DELETED", {
targetUserId: "target-7",
keyId: "key-3",
});
expect(state.insertValues[0].targetId).toBe("target-7");
});
it("falls back to keyId for targetId", async () => {
await auditLog(makeLogger() as never, "API_KEY_DELETED", { keyId: "key-3" });
expect(state.insertValues[0].targetId).toBe("key-3");
});
it("writes targetId null when neither targetUserId nor keyId is present", async () => {
await auditLog(makeLogger() as never, "LOGOUT");
expect(state.insertValues[0].targetId).toBeNull();
});
it("passes the event through as the action, and ip/requestId through verbatim", async () => {
await auditLog(makeLogger() as never, "LOGIN_SUCCESS", { userId: "u1" }, "10.0.0.4", "req-abc");
expect(state.insertValues[0].action).toBe("LOGIN_SUCCESS");
expect(state.insertValues[0].ipAddress).toBe("10.0.0.4");
expect(state.insertValues[0].requestId).toBe("req-abc");
});
it("stores the details object on the row", async () => {
const details = { userId: "u1", changes: { role: "editor" } };
await auditLog(makeLogger() as never, "USER_UPDATED", details);
expect(state.insertValues[0].details).toEqual(details);
});
it("defaults ip and requestId to null when not supplied", async () => {
await auditLog(makeLogger() as never, "LOGOUT", { userId: "u1" });
expect(state.insertValues[0].ipAddress).toBeNull();
expect(state.insertValues[0].requestId).toBeNull();
});
});
// ── auditLog: deriveTargetType via the real insert path ──────────────────
describe("auditLog targetType derivation (real deriveTargetType)", () => {
it.each([
["USER_CREATED", "user"],
["LOGIN_SUCCESS", "user"],
["LOGIN_FAILED", "user"],
["PASSWORD_CHANGED", "user"],
["OIDC_LOGIN_FAILED", "user"],
["SAML_USER_LINKED", "user"],
["SCIM_USER_CREATED", "user"],
["MFA_ENABLED", "user"],
["LOGOUT", "user"],
["API_KEY_CREATED", "api_key"],
["FILE_UPLOADED", "file"],
["ROLE_UPDATED", "role"],
["SETTINGS_UPDATED", "setting"],
["IP_ALLOWLIST_UPDATED", "setting"],
["TOOL_EXECUTED", "tool"],
["BATCH_STARTED", "tool"],
["PIPELINE_RUN", "tool"],
["LEGAL_HOLD_PLACED", "compliance"],
["SIEM_FORWARD_FAILED", "integration"],
["WEBHOOK_DELIVERED", "integration"],
])("maps %s to targetType %s", async (event, expected) => {
await auditLog(makeLogger() as never, event, {});
expect(state.insertValues[0].targetType).toBe(expected);
});
it("maps an unknown event to a null targetType", async () => {
await auditLog(makeLogger() as never, "SOMETHING_ELSE", {});
expect(state.insertValues[0].targetType).toBeNull();
});
it("does not classify LOGIC_ONLY (LOGI prefix, not LOGIN/LOGOUT) as a user event", async () => {
// Pins the LOGIN prefix and the exact LOGOUT equality: "LOGIC_..." must miss.
await auditLog(makeLogger() as never, "LOGIC_TRACE", {});
expect(state.insertValues[0].targetType).toBeNull();
});
it("requires exact equality for LOGOUT (LOGOUT_X is not a user target via that branch)", async () => {
// LOGOUT_ALL does not start with USER_/LOGIN/PASSWORD and is not === LOGOUT,
// and no other prefix matches, so it is null. Pins the === "LOGOUT" check.
await auditLog(makeLogger() as never, "LOGOUT_ALL", {});
expect(state.insertValues[0].targetType).toBeNull();
});
it("does not treat SETTINGS_CHANGED as a setting (SETTINGS_UPDATED is exact)", async () => {
await auditLog(makeLogger() as never, "SETTINGS_CHANGED", {});
expect(state.insertValues[0].targetType).toBeNull();
});
});
// ── auditLog: DB failure resilience ──────────────────────────────────────
describe("auditLog failure handling", () => {
it("logs a warning and returns without throwing when the insert fails", async () => {
const logger = makeLogger();
state.insertThrows = true;
await expect(
auditLog(logger as never, "SETTINGS_UPDATED", { userId: "u1" }),
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0][0]).toEqual({ event: "SETTINGS_UPDATED" });
});
it("does not attempt the HMAC branch after an insert failure", async () => {
state.insertThrows = true;
state.encryptionKey = "a".repeat(64);
state.selectRows = [[{ value: "true" }]];
await auditLog(makeLogger() as never, "USER_CREATED", { userId: "u1" });
// Early return after the failed insert: no integrity UPDATE issued.
expect(state.updateWhereCalled).toBe(0);
});
});
// ── auditLog: tamper-resistant HMAC branch ───────────────────────────────
describe("auditLog tamper-resistant integrity", () => {
it("computes and stores an integrity HMAC when encryption + tamperResistant are on", async () => {
state.encryptionKey = "a".repeat(64);
state.selectRows = [[{ value: "true" }]]; // tamperResistantAudit === "true"
await auditLog(makeLogger() as never, "USER_CREATED", { userId: "u1" });
expect(state.updateWhereCalled).toBe(1);
expect(state.updateSet).toHaveLength(1);
const integrity = state.updateSet[0].integrity;
expect(typeof integrity).toBe("string");
expect(integrity).toMatch(/^[0-9a-f]{64}$/); // sha256 hex
});
it("skips the HMAC entirely when DATA_ENCRYPTION_KEY is unset", async () => {
state.encryptionKey = "";
state.selectRows = [[{ value: "true" }]];
await auditLog(makeLogger() as never, "USER_CREATED", { userId: "u1" });
expect(state.updateWhereCalled).toBe(0);
expect(state.selectIdx).toBe(0); // never even queried tamperResistantAudit
});
it("skips the HMAC when tamperResistantAudit is not exactly 'true'", async () => {
state.encryptionKey = "a".repeat(64);
state.selectRows = [[{ value: "1" }]]; // not "true"
await auditLog(makeLogger() as never, "USER_CREATED", { userId: "u1" });
expect(state.updateWhereCalled).toBe(0);
});
it("skips the HMAC when the tamperResistantAudit setting row is absent", async () => {
state.encryptionKey = "a".repeat(64);
state.selectRows = [[]]; // no row
await auditLog(makeLogger() as never, "USER_CREATED", { userId: "u1" });
expect(state.updateWhereCalled).toBe(0);
});
it("warns but does not throw when the HMAC update fails", async () => {
const logger = makeLogger();
state.encryptionKey = "a".repeat(64);
state.selectRows = [[{ value: "true" }]];
// Make the update throw by swapping the mock's where to reject once.
// Simpler: force computeHmac path but break update via selectRows shape is not
// possible; instead assert the happy path already covered and that a thrown
// update is caught. We trigger a throw by making update().set().where reject.
const dbModule = await import("../../../apps/api/src/db/index.js");
const original = dbModule.db.update;
(dbModule.db as { update: unknown }).update = () => ({
set: () => ({
where: () => {
throw new Error("update failed");
},
}),
});
try {
await expect(
auditLog(logger as never, "USER_CREATED", { userId: "u1" }),
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0][0]).toEqual({ event: "USER_CREATED" });
} finally {
(dbModule.db as { update: unknown }).update = original;
}
});
});
// ── auditFromRequest: request.ip / request.id capture ─────────────────────
describe("auditFromRequest", () => {
it("captures request.ip and request.id and forwards them to the DB write", async () => {
const logger = makeLogger();
const request = { log: logger, ip: "203.0.113.7", id: "req-xyz" };
const emit = auditFromRequest(request as never);
await emit("LOGIN_SUCCESS", { userId: "u1" });
expect(state.insertValues).toHaveLength(1);
expect(state.insertValues[0].ipAddress).toBe("203.0.113.7");
expect(state.insertValues[0].requestId).toBe("req-xyz");
expect(state.insertValues[0].action).toBe("LOGIN_SUCCESS");
});
it("defaults details to an empty object when called with only an event", async () => {
const logger = makeLogger();
const emit = auditFromRequest({ log: logger, ip: "10.1.1.1", id: "r-2" } as never);
await emit("LOGOUT");
expect(state.insertValues[0].actorUsername).toBe("system");
expect(state.insertValues[0].actorId).toBeNull();
expect(logger.info).toHaveBeenCalledTimes(1);
});
});