Files
Kantin-Petit 0a2eb203ee 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>
2026-06-23 01:50:18 +02:00

54 lines
2.4 KiB
TypeScript

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"]);
});
});