mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user