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,620 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mutation-focused coverage for apps/api/src/jobs/siem-forward.ts. The sibling
|
||||
// jobs/siem-forward.behavior.test.ts covers the main paths; this file pins the
|
||||
// values a mutant would flip: the circuit-breaker >= 5 boundary, the parseInt
|
||||
// radix, the enabled/webhookUrl gate (both operands), the empty-rows guard,
|
||||
// the cursor gte-vs-unbounded branch, the batch limit, the full per-event
|
||||
// payload shape, the last-row cursor selection, the failureCount > 0 reset
|
||||
// guard, and the failureCount + 1 increment arithmetic.
|
||||
//
|
||||
// This module lives at tests/unit/api, one directory above jobs/, so the
|
||||
// relative mock paths use three "../" segments.
|
||||
|
||||
const readSiemConfigMock = vi.hoisted(() => vi.fn());
|
||||
const deliverWebhookMock = vi.hoisted(() => vi.fn());
|
||||
const upsertSettingMock = vi.hoisted(() => vi.fn());
|
||||
const decryptMock = vi.hoisted(() => vi.fn());
|
||||
const isEncryptedMock = vi.hoisted(() => vi.fn());
|
||||
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
|
||||
const selectMock = vi.hoisted(() => vi.fn());
|
||||
const gteMock = vi.hoisted(() => vi.fn(() => "gte-condition"));
|
||||
const ascMock = vi.hoisted(() => vi.fn(() => "asc-order"));
|
||||
|
||||
/**
|
||||
* Build a chained query stub. Settings reads terminate at .where(); the
|
||||
* audit-log read terminates at .limit(). The audit chain records the where()
|
||||
* argument so tests can assert the cursor condition that was applied.
|
||||
*/
|
||||
function queryChain<T>(result: T, terminalWhere = false) {
|
||||
const calls: { whereArg?: unknown; limitArg?: unknown } = {};
|
||||
const chain = {
|
||||
from: vi.fn(() => chain),
|
||||
where: vi.fn((arg: unknown) => {
|
||||
calls.whereArg = arg;
|
||||
return terminalWhere ? Promise.resolve(result) : chain;
|
||||
}),
|
||||
orderBy: vi.fn(() => chain),
|
||||
limit: vi.fn((arg: unknown) => {
|
||||
calls.limitArg = arg;
|
||||
return Promise.resolve(result);
|
||||
}),
|
||||
calls,
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
async function loadSiemForward(options: { dataEncryptionKey?: string } = {}) {
|
||||
const { dataEncryptionKey = "test-key" } = options;
|
||||
vi.resetModules();
|
||||
readSiemConfigMock.mockReset();
|
||||
deliverWebhookMock.mockReset();
|
||||
upsertSettingMock.mockReset();
|
||||
decryptMock.mockReset();
|
||||
isEncryptedMock.mockReset();
|
||||
isFeatureEnabledMock.mockReset();
|
||||
isFeatureEnabledMock.mockReturnValue(true);
|
||||
selectMock.mockReset();
|
||||
gteMock.mockClear();
|
||||
ascMock.mockClear();
|
||||
|
||||
vi.doMock("drizzle-orm", () => ({
|
||||
asc: ascMock,
|
||||
eq: vi.fn(() => "eq"),
|
||||
gte: gteMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../../../apps/api/src/config.js", () => ({
|
||||
env: { DATA_ENCRYPTION_KEY: dataEncryptionKey },
|
||||
}));
|
||||
|
||||
vi.doMock("../../../apps/api/src/db/index.js", () => ({
|
||||
db: { select: selectMock },
|
||||
schema: {
|
||||
auditLog: {
|
||||
action: "action",
|
||||
actorId: "actorId",
|
||||
actorUsername: "actorUsername",
|
||||
targetType: "targetType",
|
||||
targetId: "targetId",
|
||||
ipAddress: "ipAddress",
|
||||
details: "details",
|
||||
createdAt: "createdAt",
|
||||
},
|
||||
settings: { key: "key", value: "value" },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("../../../apps/api/src/lib/encryption.js", () => ({
|
||||
decrypt: decryptMock,
|
||||
isEncrypted: isEncryptedMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../../../apps/api/src/lib/settings-helpers.js", () => ({
|
||||
upsertSetting: upsertSettingMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../../../apps/api/src/lib/webhook-delivery.js", () => ({
|
||||
deliverWebhook: deliverWebhookMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../../../apps/api/src/routes/enterprise/siem.js", () => ({
|
||||
readSiemConfig: readSiemConfigMock,
|
||||
}));
|
||||
|
||||
vi.doMock("@snapotter/enterprise", () => ({ isFeatureEnabled: isFeatureEnabledMock }));
|
||||
|
||||
return import("../../../apps/api/src/jobs/siem-forward.js");
|
||||
}
|
||||
|
||||
/** One audit row with every field populated. */
|
||||
function auditRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
createdAt: new Date("2026-06-29T12:00:00.000Z"),
|
||||
action: "LOGIN_FAILED",
|
||||
actorId: "user-1",
|
||||
actorUsername: "ada",
|
||||
targetType: "session",
|
||||
targetId: "session-1",
|
||||
ipAddress: "203.0.113.10",
|
||||
details: { reason: "bad_password" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("siem-forward.ts mutation coverage", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("license gate", () => {
|
||||
it("returns undefined and reads no config when unlicensed", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
isFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
expect(readSiemConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the siem_forwarding feature specifically", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
isFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(isFeatureEnabledMock).toHaveBeenCalledWith("siem_forwarding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config gate (enabled && webhookUrl)", () => {
|
||||
it("returns without reading state when config is disabled", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: false,
|
||||
webhookUrl: "https://siem.test",
|
||||
});
|
||||
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
expect(selectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns without reading state when webhookUrl is empty even if enabled", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({ enabled: true, webhookUrl: "" });
|
||||
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
expect(selectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns without reading state when config is null", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue(null);
|
||||
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
expect(selectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds past the gate only when enabled and a webhookUrl are both present", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
});
|
||||
// failure count read, then cursor read, then an empty audit page.
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([]));
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
// First state read happened, proving we passed the config gate.
|
||||
expect(selectMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("circuit breaker (failureCount >= 5)", () => {
|
||||
async function runWithFailureCount(value: string, rows: ReturnType<typeof auditRow>[] = []) {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain(rows));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
return runSiemForward();
|
||||
}
|
||||
|
||||
it("stays open (skips delivery) at exactly 5 failures", async () => {
|
||||
await runWithFailureCount("5", [auditRow()]);
|
||||
expect(deliverWebhookMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays closed (attempts delivery) at 4 failures, straddling the >= boundary", async () => {
|
||||
const result = await runWithFailureCount("4", [auditRow()]);
|
||||
expect(deliverWebhookMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ forwarded: 1 });
|
||||
});
|
||||
|
||||
it("treats a missing failure count as 0 and proceeds", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
// First settings read returns no row -> failureCount defaults to 0.
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(deliverWebhookMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("parses the failure count in base 10", async () => {
|
||||
// "10" must read as ten (>= 5 -> open), not as any other radix.
|
||||
await runWithFailureCount("10", [auditRow()]);
|
||||
expect(deliverWebhookMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor read and audit query", () => {
|
||||
it("applies a gte(createdAt) condition when a cursor exists", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
const auditChain = queryChain([auditRow()]);
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([{ value: "2026-06-29T11:00:00.000Z" }], true))
|
||||
.mockReturnValueOnce(auditChain);
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
// gte(createdAt, new Date(cursor)) built the condition, which was passed
|
||||
// to where(). A mutant dropping the cursor branch passes undefined.
|
||||
expect(gteMock).toHaveBeenCalledWith("createdAt", new Date("2026-06-29T11:00:00.000Z"));
|
||||
expect(auditChain.calls.whereArg).toBe("gte-condition");
|
||||
});
|
||||
|
||||
it("passes an undefined condition (unbounded) when there is no cursor", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
const auditChain = queryChain([auditRow()]);
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
// no cursor row -> cursorDate null -> conditions undefined
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(auditChain);
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(gteMock).not.toHaveBeenCalled();
|
||||
expect(auditChain.calls.whereArg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders ascending by createdAt and limits to 500 rows", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
const auditChain = queryChain([auditRow()]);
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(auditChain);
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(ascMock).toHaveBeenCalledWith("createdAt");
|
||||
expect(auditChain.orderBy).toHaveBeenCalledWith("asc-order");
|
||||
expect(auditChain.calls.limitArg).toBe(500);
|
||||
});
|
||||
|
||||
it("returns undefined without delivering when no rows match", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([]));
|
||||
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
expect(deliverWebhookMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event payload mapping", () => {
|
||||
it("maps each audit row to the exact SIEM event shape", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test/events",
|
||||
authHeader: "",
|
||||
});
|
||||
const rowA = auditRow();
|
||||
const rowB = auditRow({
|
||||
createdAt: new Date("2026-06-29T12:05:00.000Z"),
|
||||
action: "FILE_DELETED",
|
||||
actorId: "user-2",
|
||||
actorUsername: "grace",
|
||||
targetType: "file",
|
||||
targetId: "file-9",
|
||||
ipAddress: "198.51.100.7",
|
||||
details: { name: "secret.pdf" },
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([rowA, rowB]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(deliverWebhookMock).toHaveBeenCalledWith("https://siem.test/events", "", [
|
||||
{
|
||||
timestamp: "2026-06-29T12:00:00.000Z",
|
||||
event: "LOGIN_FAILED",
|
||||
actorId: "user-1",
|
||||
actorUsername: "ada",
|
||||
targetType: "session",
|
||||
targetId: "session-1",
|
||||
ip: "203.0.113.10",
|
||||
details: { reason: "bad_password" },
|
||||
},
|
||||
{
|
||||
timestamp: "2026-06-29T12:05:00.000Z",
|
||||
event: "FILE_DELETED",
|
||||
actorId: "user-2",
|
||||
actorUsername: "grace",
|
||||
targetType: "file",
|
||||
targetId: "file-9",
|
||||
ip: "198.51.100.7",
|
||||
details: { name: "secret.pdf" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes the timestamp from createdAt via toISOString", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
const row = auditRow({ createdAt: new Date("2026-01-02T03:04:05.678Z") });
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([row]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
const events = deliverWebhookMock.mock.calls[0][2] as { timestamp: string }[];
|
||||
expect(events[0].timestamp).toBe("2026-01-02T03:04:05.678Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth header decryption", () => {
|
||||
it("decrypts an encrypted header before delivery", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "$ENC$blob",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
isEncryptedMock.mockReturnValue(true);
|
||||
decryptMock.mockResolvedValue("Bearer clear");
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(deliverWebhookMock.mock.calls[0][1]).toBe("Bearer clear");
|
||||
});
|
||||
|
||||
it("sends the raw header when isEncrypted is false", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "Bearer raw",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
isEncryptedMock.mockReturnValue(false);
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(decryptMock).not.toHaveBeenCalled();
|
||||
expect(deliverWebhookMock.mock.calls[0][1]).toBe("Bearer raw");
|
||||
});
|
||||
|
||||
it("does not decrypt when DATA_ENCRYPTION_KEY is unset", async () => {
|
||||
const { runSiemForward } = await loadSiemForward({ dataEncryptionKey: "" });
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "$ENC$blob",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
isEncryptedMock.mockReturnValue(true);
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(decryptMock).not.toHaveBeenCalled();
|
||||
expect(deliverWebhookMock.mock.calls[0][1]).toBe("$ENC$blob");
|
||||
});
|
||||
|
||||
it("falls back to an empty header when decrypt resolves null", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "$ENC$blob",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
isEncryptedMock.mockReturnValue(true);
|
||||
decryptMock.mockResolvedValue(null);
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(deliverWebhookMock.mock.calls[0][1]).toBe("");
|
||||
});
|
||||
|
||||
it("does not attempt decryption when the header is empty", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(isEncryptedMock).not.toHaveBeenCalled();
|
||||
expect(decryptMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("success state updates", () => {
|
||||
it("advances the cursor to the LAST row timestamp and resets failures", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
const rows = [
|
||||
auditRow({ createdAt: new Date("2026-06-29T12:00:00.000Z") }),
|
||||
auditRow({ createdAt: new Date("2026-06-29T12:01:00.000Z") }),
|
||||
auditRow({ createdAt: new Date("2026-06-29T12:02:30.000Z") }),
|
||||
];
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "3" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain(rows));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await runSiemForward();
|
||||
|
||||
expect(result).toEqual({ forwarded: 3 });
|
||||
// Cursor is the LAST (third) row, not the first.
|
||||
expect(upsertSettingMock).toHaveBeenCalledWith(
|
||||
"siem_last_forwarded_at",
|
||||
"2026-06-29T12:02:30.000Z",
|
||||
);
|
||||
// failureCount was 3 (> 0) so it is reset to "0".
|
||||
expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "0");
|
||||
});
|
||||
|
||||
it("does not reset the failure counter when it was already 0", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
// Cursor still advances, but no reset write for a zero counter.
|
||||
expect(upsertSettingMock).toHaveBeenCalledTimes(1);
|
||||
expect(upsertSettingMock).toHaveBeenCalledWith(
|
||||
"siem_last_forwarded_at",
|
||||
"2026-06-29T12:00:00.000Z",
|
||||
);
|
||||
expect(upsertSettingMock).not.toHaveBeenCalledWith("siem_consecutive_failures", "0");
|
||||
});
|
||||
|
||||
it("returns the exact forwarded count", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
const rows = [auditRow(), auditRow(), auditRow(), auditRow()];
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain(rows));
|
||||
deliverWebhookMock.mockResolvedValue({ success: true });
|
||||
|
||||
await expect(runSiemForward()).resolves.toEqual({ forwarded: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure state updates", () => {
|
||||
it("increments the failure counter by exactly one and returns undefined", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "4" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: false, error: "downstream 500" });
|
||||
|
||||
await expect(runSiemForward()).resolves.toBeUndefined();
|
||||
|
||||
// 4 + 1 = 5; a mutated + would produce a different value.
|
||||
expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "5");
|
||||
// A failure must not advance the cursor.
|
||||
expect(upsertSettingMock).not.toHaveBeenCalledWith(
|
||||
"siem_last_forwarded_at",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("increments from zero to one on the first failure", async () => {
|
||||
const { runSiemForward } = await loadSiemForward();
|
||||
readSiemConfigMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
webhookUrl: "https://siem.test",
|
||||
authHeader: "",
|
||||
});
|
||||
selectMock
|
||||
.mockReturnValueOnce(queryChain([{ value: "0" }], true))
|
||||
.mockReturnValueOnce(queryChain([], true))
|
||||
.mockReturnValueOnce(queryChain([auditRow()]));
|
||||
deliverWebhookMock.mockResolvedValue({ success: false, error: "boom" });
|
||||
|
||||
await runSiemForward();
|
||||
|
||||
expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "1");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user