feat: module alertes (sender fail-soft + builders digest/J-1) (v0.16.0)
AlertEvent sans secret ; HttpAlertSender (POST webhook n8n, best-effort) + NullAlertSender ; buildDailyDigest / selectExpiringSoon purs. 6 tests. Rattrape l'entrée CHANGELOG 0.15.1 omise. Palier de risque : reversible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { HttpAlertSender, NullAlertSender } from "../src/alerts/sender.js";
|
||||
import { buildDailyDigest, selectExpiringSoon } from "../src/alerts/digest.js";
|
||||
import { createAsset, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js";
|
||||
import { createLogger } from "../src/audit/log.js";
|
||||
import type { AlertEvent } from "../src/alerts/types.js";
|
||||
|
||||
const log = createLogger("silent");
|
||||
const blocked: AlertEvent = { kind: "blocked_attempt", assetId: "x", tool: "t", status: "bloqué" };
|
||||
|
||||
describe("HttpAlertSender", () => {
|
||||
it("poste le payload au webhook", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await new HttpAlertSender("http://n8n/webhook", log).send(blocked);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toMatchObject({ source: "chlova", kind: "blocked_attempt", assetId: "x" });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("fail-soft : une panne réseau ne lève pas", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
||||
await expect(new HttpAlertSender("http://n8n/webhook", log).send(blocked)).resolves.toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("NullAlertSender ne lève pas", async () => {
|
||||
await expect(new NullAlertSender(log).send(blocked)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("digest", () => {
|
||||
it("buildDailyDigest compte bloqués vs provisoires", () => {
|
||||
const items = [
|
||||
createAsset({ id: "b", type: "tool", version: "1.0.0", riskTier: "privileged" }),
|
||||
createAsset({ id: "r", type: "tool", version: "1.0.0", riskTier: "reversible" }),
|
||||
];
|
||||
const d = buildDailyDigest(items);
|
||||
expect(d.blocked).toBe(1);
|
||||
expect(d.provisional).toBe(1);
|
||||
expect(d.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("selectExpiringSoon ne retient que les provisoires dans la fenêtre J-1", () => {
|
||||
const now = 1_000_000_000;
|
||||
const soon = createAsset({ id: "soon", type: "tool", version: "1.0.0", riskTier: "reversible", now: now - PROVISIONAL_TTL_MS + 3_600_000 });
|
||||
const far = createAsset({ id: "far", type: "tool", version: "1.0.0", riskTier: "reversible", now });
|
||||
const result = selectExpiringSoon([soon, far], now);
|
||||
expect(result.map((a) => a.id)).toEqual(["soon"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user