Files
SnapOtter/tests/unit/api/jobs/alert-evaluator.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

169 lines
5.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
const statfsMock = vi.hoisted(() => vi.fn());
const getSettingStringMock = vi.hoisted(() => vi.fn());
const deliverWebhookMock = vi.hoisted(() => vi.fn());
const decryptMock = vi.hoisted(() => vi.fn());
const isEncryptedMock = vi.hoisted(() => vi.fn());
const getActiveLicenseMock = vi.hoisted(() => vi.fn());
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
const selectMock = vi.hoisted(() => vi.fn());
function queryChain<T>(result: T) {
const chain = {
from: vi.fn(() => chain),
where: vi.fn(() => Promise.resolve(result)),
};
return chain;
}
async function loadAlertEvaluator() {
vi.resetModules();
statfsMock.mockReset();
getSettingStringMock.mockReset();
deliverWebhookMock.mockReset();
decryptMock.mockReset();
isEncryptedMock.mockReset();
getActiveLicenseMock.mockReset();
isFeatureEnabledMock.mockReset();
isFeatureEnabledMock.mockReturnValue(true);
selectMock.mockReset();
vi.doMock("node:fs/promises", () => ({
statfs: statfsMock,
}));
vi.doMock("drizzle-orm", () => ({
and: vi.fn(() => "and"),
eq: vi.fn(() => "eq"),
gte: vi.fn(() => "gte"),
sql: vi.fn(() => "sql"),
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: {
WORKSPACE_PATH: "/workspace",
DATA_ENCRYPTION_KEY: "test-key",
},
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {
select: selectMock,
},
schema: {
auditLog: {
action: "action",
createdAt: "createdAt",
},
},
}));
vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({
getSettingString: getSettingStringMock,
}));
vi.doMock("../../../../apps/api/src/lib/encryption.js", () => ({
decrypt: decryptMock,
isEncrypted: isEncryptedMock,
}));
vi.doMock("../../../../apps/api/src/lib/webhook-delivery.js", () => ({
deliverWebhook: deliverWebhookMock,
}));
vi.doMock("@snapotter/enterprise", () => ({
getActiveLicense: getActiveLicenseMock,
isFeatureEnabled: isFeatureEnabledMock,
}));
return import("../../../../apps/api/src/jobs/alert-evaluator.js");
}
describe("alert evaluator behavior", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns before reading settings when the admin_alerts feature is unlicensed", async () => {
const { evaluateAlerts } = await loadAlertEvaluator();
isFeatureEnabledMock.mockReturnValue(false);
await expect(evaluateAlerts()).resolves.toBeUndefined();
expect(getSettingStringMock).not.toHaveBeenCalled();
expect(statfsMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("returns early when webhook destination settings are invalid JSON", async () => {
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock.mockResolvedValueOnce("{invalid");
await evaluateAlerts();
expect(statfsMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("returns early when no enabled alert destinations are configured", async () => {
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/siem", authHeader: "", enabled: true, type: "siem" },
{ url: "https://example.test/alerts", authHeader: "", enabled: false, type: "alerts" },
]),
);
await evaluateAlerts();
expect(statfsMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("delivers triggered alerts to enabled alert webhooks with decrypted auth", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{
url: "https://example.test/alerts",
authHeader: "enc:token",
enabled: true,
type: "alerts",
},
{
url: "https://example.test/ignored",
authHeader: "",
enabled: true,
type: "siem",
},
]),
)
.mockResolvedValueOnce(JSON.stringify({ timestamp: "2026-06-27T11:59:00.000Z" }));
statfsMock.mockResolvedValue({ bfree: 100, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 21 }]));
getActiveLicenseMock.mockReturnValue({ expiresAt: "2026-07-05T12:00:00.000Z" });
isEncryptedMock.mockReturnValue(true);
decryptMock.mockResolvedValue("Bearer decrypted");
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(deliverWebhookMock).toHaveBeenCalledTimes(1);
expect(deliverWebhookMock).toHaveBeenCalledWith(
"https://example.test/alerts",
"Bearer decrypted",
expect.arrayContaining([
expect.objectContaining({ condition: "disk_space_low" }),
expect.objectContaining({ condition: "auth_anomaly", failedLogins: 21 }),
expect.objectContaining({ condition: "backup_stale" }),
expect.objectContaining({ condition: "license_expiring", daysLeft: 6 }),
]),
{ maxRetries: 1 },
);
});
});