Files
ShieldAI/services/darkwatch/test/webhook.test.ts
Michael Freno 9fb5379b7a Add tier-based scan scheduler and webhook triggers (FRE-4498)
- ScanScheduler: tier-based scheduling (BASIC=24h, PLUS=6h, PREMIUM=1h)
- WebhookHandler: HMAC-verified webhook ingestion with SCAN_TRIGGER support
- API routes: /scheduler and /webhooks endpoints under /api/v1/darkwatch
- Jobs: scheduled scan checker + webhook retry processor via BullMQ
- Schema: ScanSchedule, WebhookEvent models; ScanJob.scheduledBy field
- Types: ScheduleStatus, WebhookEventType, WebhookTriggerInput
- Tests: scheduler lifecycle + webhook signature/processing tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 10:57:56 -04:00

202 lines
6.1 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { WebhookHandler } from "../src/webhooks/WebhookHandler";
import prisma from "@shieldai/db";
const TEST_SECRET = "test-webhook-secret-2026";
let runId = Date.now();
describe("WebhookHandler", () => {
let handler: WebhookHandler;
beforeEach(() => {
handler = new WebhookHandler(TEST_SECRET);
});
describe("signature verification", () => {
it("verifies valid signature", () => {
const payload = JSON.stringify({ userId: "test-123" });
const sig = handler["computeSignature"](payload);
expect(handler.verifySignature(payload, sig)).toBe(true);
});
it("rejects invalid signature", () => {
const payload = JSON.stringify({ userId: "test-123" });
expect(handler.verifySignature(payload, "invalid-sig")).toBe(false);
});
it("rejects missing signature", () => {
expect(handler.verifySignature("payload", "")).toBe(false);
});
it("accepts signature from array", () => {
const payload = JSON.stringify({ userId: "test-123" });
const sig = handler["computeSignature"](payload);
expect(handler.verifySignature(payload, ["other", sig, "another"])).toBe(true);
});
});
describe("processEvent", () => {
let userId: string;
beforeEach(async () => {
const user = await prisma.user.create({
data: {
email: `webhook-test-${runId}@shieldai.local`,
subscriptionTier: "PREMIUM",
},
});
userId = user.id;
runId = Date.now();
});
afterEach(async () => {
await prisma.webhookEvent.deleteMany();
await prisma.scanJob.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
});
it("processes SCAN_TRIGGER event", async () => {
const result = await handler.processEvent("SCAN_TRIGGER", {
userId,
dataSource: "HIBP",
});
expect(result.eventId).toBeDefined();
expect(result.scanTriggered).toBe(true);
const job = await prisma.scanJob.findFirst({
where: { userId, scheduledBy: "webhook" },
});
expect(job).not.toBeNull();
});
it("processes BREACH_DETECTED event", async () => {
const result = await handler.processEvent("BREACH_DETECTED", {
userId,
breachName: "TestBreach",
});
expect(result.eventId).toBeDefined();
expect(result.scanTriggered).toBe(false);
});
it("normalizes event type", async () => {
const result = await handler.processEvent("scan_trigger", {
userId,
});
expect(result.eventId).toBeDefined();
const event = await prisma.webhookEvent.findUnique({
where: { id: result.eventId },
});
expect(event?.eventType).toBe("SCAN_TRIGGER");
});
it("returns false for non-existent user", async () => {
const result = await handler.processEvent("SCAN_TRIGGER", {
userId: "non-existent-user-id",
});
expect(result.scanTriggered).toBe(false);
});
it("links scan job to webhook event", async () => {
const result = await handler.processEvent("SCAN_TRIGGER", {
userId,
});
expect(result.scanTriggered).toBe(true);
const event = await prisma.webhookEvent.findUnique({
where: { id: result.eventId },
});
expect(event?.scanJobId).toBeDefined();
expect(event?.processed).toBe(true);
});
});
describe("signature validation in processEvent", () => {
it("accepts event with valid signature", async () => {
const payload = { userId: "test" };
const payloadStr = JSON.stringify(payload);
const sig = handler["computeSignature"](payloadStr);
const result = await handler.processEvent("SCAN_TRIGGER", payload, undefined, sig);
expect(result.eventId).toBeDefined();
});
it("rejects event with invalid signature", async () => {
const payload = { userId: "test" };
try {
await handler.processEvent("SCAN_TRIGGER", payload, undefined, "bad-signature");
expect(true).toBe(false);
} catch (err) {
expect((err as Error).message).toContain("signature");
}
});
it("accepts event without signature when no signature provided", async () => {
const result = await handler.processEvent("SCAN_TRIGGER", { userId: "test" });
expect(result.eventId).toBeDefined();
});
});
describe("processPendingEvents", () => {
it("retries unprocessed events", async () => {
const user = await prisma.user.create({
data: {
email: `retry-test-${runId}@shieldai.local`,
subscriptionTier: "BASIC",
},
});
runId = Date.now();
await prisma.webhookEvent.create({
data: {
eventType: "SCAN_TRIGGER",
payload: JSON.stringify({ userId: user.id }),
processed: false,
},
});
const processed = await handler.processPendingEvents();
expect(processed).toBeGreaterThanOrEqual(1);
const job = await prisma.scanJob.findFirst({
where: { userId: user.id, scheduledBy: "webhook" },
});
expect(job).not.toBeNull();
await prisma.scanJob.deleteMany({ where: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
});
});
describe("getEventHistory", () => {
afterEach(async () => {
await prisma.webhookEvent.deleteMany();
});
it("returns events ordered by creation time", async () => {
await handler.processEvent("SCAN_TRIGGER", { userId: "user-1" });
await handler.processEvent("BREACH_DETECTED", { userId: "user-2" });
const events = await handler.getEventHistory();
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events[0].createdAt.getTime()).toBeGreaterThanOrEqual(events[1].createdAt.getTime());
});
it("respects limit and offset", async () => {
for (let i = 0; i < 5; i++) {
await handler.processEvent("SCAN_TRIGGER", { userId: `user-${i}` });
}
const events = await handler.getEventHistory(3, 0);
expect(events).toHaveLength(3);
});
});
});