Files
chlova/orchestrator/test/alert-scheduler.test.ts
Kantin-Petit db96c4a25e feat: hook 1ère exéc provisoire + scheduler digest/J-1 + config webhook (v0.17.0)
ALERT_WEBHOOK_URL (secret) → HttpAlertSender sinon NullAlertSender.
Gatekeeper émet onFirstProvisionalExec (1ère exéc PROVISOIRE) et route
onBlockedAttempt vers une alerte. Scheduler quotidien digest + rappel J-1,
câblé Phase 2 + arrêt propre. 53 tests verts.

Palier de risque : reversible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:52:59 +02:00

69 lines
2.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AssetRepository } from "../src/gatekeeper/repository.js";
import { Gatekeeper } from "../src/gatekeeper/gatekeeper.js";
import { runAlertCycleOnce } from "../src/alerts/scheduler.js";
import { createAsset, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js";
import { createLogger } from "../src/audit/log.js";
import type { AlertSender, AlertEvent } from "../src/alerts/types.js";
import type { ToolSpec } from "../src/agent/types.js";
const log = createLogger("silent");
class CapturingSender implements AlertSender {
events: AlertEvent[] = [];
async send(e: AlertEvent): Promise<void> {
this.events.push(e);
}
}
let repo: AssetRepository;
beforeEach(() => (repo = new AssetRepository(":memory:")));
afterEach(() => repo.close());
describe("Gatekeeper first_provisional_exec", () => {
it("alerte à la 1ʳᵉ exécution d'un asset PROVISOIRE puis plus", () => {
const onFirstProvisionalExec = vi.fn();
const gk = new Gatekeeper(repo, log, { onFirstProvisionalExec });
// asset provisoire pré-existant, exécutable via la capacité privilégiée
const id = "tool:portainer.redeploy";
repo.create(createAsset({ id, type: "tool", version: "1.0.0", riskTier: "reversible" }));
repo.updateStatus(id, "provisoire");
const spec: ToolSpec = {
name: "portainer.redeploy",
description: "",
parameters: {},
server: "portainer",
readOnly: false,
riskTier: "privileged",
};
expect(gk.authorizeTool(spec).allowed).toBe(true);
expect(gk.authorizeTool(spec).allowed).toBe(true);
expect(onFirstProvisionalExec).toHaveBeenCalledOnce(); // une seule fois
expect(repo.get(id)?.execCount).toBe(2);
});
});
describe("runAlertCycleOnce", () => {
it("émet J-1 + digest quand des assets sont en attente", async () => {
const now = 2_000_000_000;
// provisoire qui expire dans ~1h → J-1
repo.create(createAsset({ id: "soon", type: "tool", version: "1.0.0", riskTier: "reversible", now: now - PROVISIONAL_TTL_MS + 3_600_000 }));
// privilégié bloqué → compte dans le digest
repo.create(createAsset({ id: "blk", type: "stack-portainer", version: "1.0.0", riskTier: "privileged", now }));
const sender = new CapturingSender();
await runAlertCycleOnce(repo, sender, now);
const kinds = sender.events.map((e) => e.kind);
expect(kinds).toContain("countdown_j1");
expect(kinds).toContain("daily_digest");
});
it("n'émet rien sans asset en attente", async () => {
const sender = new CapturingSender();
await runAlertCycleOnce(repo, sender, Date.now());
expect(sender.events).toHaveLength(0);
});
});