mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
621 lines
22 KiB
TypeScript
621 lines
22 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|