Files
SnapOtter/tests/unit/api/jobs/siem-forward.behavior.test.ts
T
SnapOtterandGitHub ae6a4c8b7c fix: error-only Sentry telemetry, storm-proof capture, and crash fixes (#476)
Removes Sentry tracing entirely (BullMQ idle polling burned 4.8M transactions in 2 days at the baked 0.1 rate), decouples PostHog sampling, and replaces the type-only error scrub with a vetted-field sanitizer plus SafeError/ToolInputError contracts. One classified capture path with per-signature throttles and a per-process ceiling makes storms impossible (NODE-1E was 4,541 events from one 30s loop). Browser errors move to a dedicated web Sentry project with their own source maps. Adds the SNAPOTTER_TELEMETRY runtime kill switch and silences test fleets.

Crash fixes: remote 204/304 SSRF process kill (NODE-20), conversion-preset boot crash loop (NODE-21), Redis version preflight + unhandled subscribe rejection (NODE-1T), Sign PDF on plain-http origins (NODE-1K/1M), wavesurfer/pdf.js teardown rejections (NODE-1P/1N), bundle-import ZlibError to 400 (NODE-1Z), chart-maker input errors declassified (NODE-1H/1J), asset requests skip the session DB lookup (NODE-1D).
2026-07-10 21:41:49 +08:00

189 lines
5.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
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());
function queryChain<T>(result: T, terminalWhere = false) {
const chain = {
from: vi.fn(() => chain),
where: vi.fn(() => (terminalWhere ? Promise.resolve(result) : chain)),
orderBy: vi.fn(() => chain),
limit: vi.fn(() => Promise.resolve(result)),
};
return chain;
}
async function loadSiemForward() {
vi.resetModules();
readSiemConfigMock.mockReset();
deliverWebhookMock.mockReset();
upsertSettingMock.mockReset();
decryptMock.mockReset();
isEncryptedMock.mockReset();
isFeatureEnabledMock.mockReset();
isFeatureEnabledMock.mockReturnValue(true);
selectMock.mockReset();
vi.doMock("drizzle-orm", () => ({
asc: vi.fn(() => "asc"),
eq: vi.fn(() => "eq"),
gte: vi.fn(() => "gte"),
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: { DATA_ENCRYPTION_KEY: "test-key" },
}));
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");
}
describe("SIEM forwarding behavior", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns without querying audit rows when SIEM is disabled", async () => {
const { runSiemForward } = await loadSiemForward();
readSiemConfigMock.mockResolvedValue({ enabled: false, webhookUrl: "https://siem.test" });
await expect(runSiemForward()).resolves.toBeUndefined();
expect(selectMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("opens the circuit breaker at five consecutive failures", async () => {
const { runSiemForward } = await loadSiemForward();
readSiemConfigMock.mockResolvedValue({ enabled: true, webhookUrl: "https://siem.test" });
selectMock.mockReturnValueOnce(queryChain([{ value: "5" }], true));
await expect(runSiemForward()).resolves.toBeUndefined();
expect(selectMock).toHaveBeenCalledTimes(1);
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("maps audit rows, decrypts auth, advances cursor, and resets failures after success", async () => {
const { runSiemForward } = await loadSiemForward();
const createdAt = new Date("2026-06-29T12:00:00.000Z");
readSiemConfigMock.mockResolvedValue({
enabled: true,
webhookUrl: "https://siem.test/events",
authHeader: "enc:auth",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "2" }], true))
.mockReturnValueOnce(queryChain([{ value: "2026-06-29T11:00:00.000Z" }], true))
.mockReturnValueOnce(
queryChain([
{
createdAt,
action: "LOGIN_FAILED",
actorId: "user-1",
actorUsername: "ada",
targetType: "session",
targetId: "session-1",
ipAddress: "203.0.113.10",
details: { reason: "bad_password" },
},
]),
);
isEncryptedMock.mockReturnValue(true);
decryptMock.mockResolvedValue("Bearer clear");
deliverWebhookMock.mockResolvedValue({ success: true });
await expect(runSiemForward()).resolves.toEqual({ forwarded: 1 });
expect(deliverWebhookMock).toHaveBeenCalledWith("https://siem.test/events", "Bearer clear", [
{
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" },
},
]);
expect(upsertSettingMock).toHaveBeenCalledWith(
"siem_last_forwarded_at",
"2026-06-29T12:00:00.000Z",
);
expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "0");
});
it("increments failure counter when delivery fails", async () => {
const { runSiemForward } = await loadSiemForward();
readSiemConfigMock.mockResolvedValue({
enabled: true,
webhookUrl: "https://siem.test/events",
authHeader: "",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "4" }], true))
.mockReturnValueOnce(queryChain([], true))
.mockReturnValueOnce(
queryChain([
{
createdAt: new Date("2026-06-29T12:00:00.000Z"),
action: "FILE_DELETED",
},
]),
);
deliverWebhookMock.mockResolvedValue({ success: false, error: "downstream 500" });
await expect(runSiemForward()).resolves.toBeUndefined();
expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "5");
});
});