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>
This commit is contained in:
195
services/darkwatch/test/scheduler.test.ts
Normal file
195
services/darkwatch/test/scheduler.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { ScanScheduler } from "../src/scheduler/ScanScheduler";
|
||||
import { SubscriptionTier } from "@shieldai/types";
|
||||
import prisma from "@shieldai/db";
|
||||
|
||||
let runId = Date.now();
|
||||
|
||||
describe("ScanScheduler", () => {
|
||||
let scheduler: ScanScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
scheduler = new ScanScheduler();
|
||||
});
|
||||
|
||||
describe("static tier configuration", () => {
|
||||
it("returns correct interval for BASIC tier", () => {
|
||||
expect(ScanScheduler.getIntervalForTier(SubscriptionTier.BASIC)).toBe(1440);
|
||||
});
|
||||
|
||||
it("returns correct interval for PLUS tier", () => {
|
||||
expect(ScanScheduler.getIntervalForTier(SubscriptionTier.PLUS)).toBe(360);
|
||||
});
|
||||
|
||||
it("returns correct interval for PREMIUM tier", () => {
|
||||
expect(ScanScheduler.getIntervalForTier(SubscriptionTier.PREMIUM)).toBe(60);
|
||||
});
|
||||
|
||||
it("returns correct cron for BASIC tier", () => {
|
||||
expect(ScanScheduler.getCronForTier(SubscriptionTier.BASIC)).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("returns correct cron for PLUS tier", () => {
|
||||
expect(ScanScheduler.getCronForTier(SubscriptionTier.PLUS)).toBe("0 */6 * * *");
|
||||
});
|
||||
|
||||
it("returns correct cron for PREMIUM tier", () => {
|
||||
expect(ScanScheduler.getCronForTier(SubscriptionTier.PREMIUM)).toBe("0 * * * *");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureScheduleForUser", () => {
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `scheduler-test-${runId}@shieldai.local`,
|
||||
subscriptionTier: "BASIC",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
runId = Date.now();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("creates schedule for new user", async () => {
|
||||
const result = await scheduler.ensureScheduleForUser(userId);
|
||||
expect(result.scheduled).toBe(true);
|
||||
expect(result.intervalMinutes).toBe(1440);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule).not.toBeNull();
|
||||
expect(schedule?.status).toBe("ACTIVE");
|
||||
expect(schedule?.cronExpression).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("updates schedule on tier change", async () => {
|
||||
await scheduler.ensureScheduleForUser(userId);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { subscriptionTier: "PREMIUM" },
|
||||
});
|
||||
|
||||
const result = await scheduler.ensureScheduleForUser(userId);
|
||||
expect(result.intervalMinutes).toBe(60);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule?.cronExpression).toBe("0 * * * *");
|
||||
});
|
||||
|
||||
it("returns false for non-existent user", async () => {
|
||||
const result = await scheduler.ensureScheduleForUser("non-existent-id");
|
||||
expect(result.scheduled).toBe(false);
|
||||
expect(result.intervalMinutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule lifecycle", () => {
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `lifecycle-test-${runId}@shieldai.local`,
|
||||
subscriptionTier: "PLUS",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
runId = Date.now();
|
||||
await scheduler.ensureScheduleForUser(userId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("marks schedule as scanned and updates next scan time", async () => {
|
||||
const before = await scheduler.getSchedule(userId);
|
||||
const nextScan = await scheduler.markScanned(userId);
|
||||
|
||||
const after = await scheduler.getSchedule(userId);
|
||||
expect(after?.lastScanAt).not.toBeNull();
|
||||
expect(after?.nextScanAt?.getTime()).toBeGreaterThan(nextScan.getTime() - 5000);
|
||||
expect(after?.nextScanAt).not.toEqual(before?.nextScanAt);
|
||||
});
|
||||
|
||||
it("pauses schedule", async () => {
|
||||
await scheduler.pauseSchedule(userId);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule?.status).toBe("PAUSED");
|
||||
});
|
||||
|
||||
it("resumes paused schedule", async () => {
|
||||
await scheduler.pauseSchedule(userId);
|
||||
await scheduler.resumeSchedule(userId);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule?.status).toBe("ACTIVE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDueSchedules", () => {
|
||||
let userId1: string;
|
||||
let userId2: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user1 = await prisma.user.create({
|
||||
data: {
|
||||
email: `due-test-1-${runId}@shieldai.local`,
|
||||
subscriptionTier: "PREMIUM",
|
||||
},
|
||||
});
|
||||
userId1 = user1.id;
|
||||
|
||||
const user2 = await prisma.user.create({
|
||||
data: {
|
||||
email: `due-test-2-${runId}@shieldai.local`,
|
||||
subscriptionTier: "BASIC",
|
||||
},
|
||||
});
|
||||
userId2 = user2.id;
|
||||
runId = Date.now();
|
||||
|
||||
await scheduler.ensureScheduleForUser(userId1);
|
||||
await scheduler.ensureScheduleForUser(userId2);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId: userId1 } });
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId: userId2 } });
|
||||
await prisma.user.delete({ where: { id: userId1 } });
|
||||
await prisma.user.delete({ where: { id: userId2 } });
|
||||
});
|
||||
|
||||
it("returns schedules that are due", async () => {
|
||||
const pastDate = new Date(Date.now() - 60000);
|
||||
await prisma.scanSchedule.update({
|
||||
where: { userId: userId1 },
|
||||
data: { nextScanAt: pastDate },
|
||||
});
|
||||
|
||||
const due = await scheduler.getDueSchedules();
|
||||
const dueUserIds = due.map((s) => s.userId);
|
||||
expect(dueUserIds).toContain(userId1);
|
||||
});
|
||||
|
||||
it("includes schedules with null nextScanAt", async () => {
|
||||
await prisma.scanSchedule.update({
|
||||
where: { userId: userId2 },
|
||||
data: { nextScanAt: null },
|
||||
});
|
||||
|
||||
const due = await scheduler.getDueSchedules();
|
||||
const dueUserIds = due.map((s) => s.userId);
|
||||
expect(dueUserIds).toContain(userId2);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
services/darkwatch/test/webhook.test.ts
Normal file
201
services/darkwatch/test/webhook.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user