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:
44
services/darkwatch/Dockerfile
Normal file
44
services/darkwatch/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json turbo.json ./
|
||||
COPY packages/api/package.json ./packages/api/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY packages/core/package.json ./packages/core/ 2>/dev/null || true
|
||||
COPY packages/jobs/package.json ./packages/jobs/
|
||||
COPY packages/shared-notifications/package.json ./packages/shared-notifications/
|
||||
COPY services/darkwatch/package.json ./services/darkwatch/
|
||||
COPY services/spamshield/package.json ./services/spamshield/
|
||||
COPY services/voiceprint/package.json ./services/voiceprint/
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY packages/types/tsconfig.json ./packages/types/
|
||||
COPY packages/db/tsconfig.json ./packages/db/
|
||||
COPY services/darkwatch/tsconfig.json ./services/darkwatch/
|
||||
COPY services/darkwatch/ ./services/darkwatch/
|
||||
COPY packages/types/ ./packages/types/
|
||||
COPY packages/db/ ./packages/db/
|
||||
|
||||
RUN npm run build --workspace=@shieldai/types --workspace=@shieldai/db --workspace=@shieldai/darkwatch
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 shieldai
|
||||
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/services/darkwatch/dist ./dist
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/services/darkwatch/package.json ./package.json
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/packages/db ./packages/db
|
||||
|
||||
USER shieldai
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -3,3 +3,5 @@ export * from "./hibp/HIBPService";
|
||||
export * from "./matching/MatchingEngine";
|
||||
export * from "./alerts/AlertPipeline";
|
||||
export * from "./scanner/ScanService";
|
||||
export * from "./scheduler/ScanScheduler";
|
||||
export * from "./webhooks/WebhookHandler";
|
||||
|
||||
168
services/darkwatch/src/scheduler/ScanScheduler.ts
Normal file
168
services/darkwatch/src/scheduler/ScanScheduler.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { SubscriptionTier } from "@shieldai/types";
|
||||
|
||||
const TIER_CONFIG = {
|
||||
[SubscriptionTier.BASIC]: { intervalMinutes: 1440, cron: "0 0 * * *" },
|
||||
[SubscriptionTier.PLUS]: { intervalMinutes: 360, cron: "0 */6 * * *" },
|
||||
[SubscriptionTier.PREMIUM]: { intervalMinutes: 60, cron: "0 * * * *" },
|
||||
} as const;
|
||||
|
||||
export class ScanScheduler {
|
||||
/**
|
||||
* Get the scan interval (in minutes) for a given subscription tier.
|
||||
*/
|
||||
public static getIntervalForTier(tier: SubscriptionTier): number {
|
||||
return TIER_CONFIG[tier]?.intervalMinutes ?? TIER_CONFIG[SubscriptionTier.BASIC].intervalMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cron expression for a given subscription tier.
|
||||
*/
|
||||
public static getCronForTier(tier: SubscriptionTier): string {
|
||||
return TIER_CONFIG[tier]?.cron ?? TIER_CONFIG[SubscriptionTier.BASIC].cron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a user has an active scan schedule based on their subscription tier.
|
||||
* Creates or updates the schedule record.
|
||||
*/
|
||||
async ensureScheduleForUser(userId: string): Promise<{ scheduled: boolean; intervalMinutes: number }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { subscriptionTier: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { scheduled: false, intervalMinutes: 0 };
|
||||
}
|
||||
|
||||
const tier = user.subscriptionTier ?? SubscriptionTier.BASIC;
|
||||
const config = TIER_CONFIG[tier];
|
||||
const nextScan = this.calculateNextScan();
|
||||
|
||||
const schedule = await prisma.scanSchedule.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
intervalMinutes: config.intervalMinutes,
|
||||
cronExpression: config.cron,
|
||||
nextScanAt: nextScan,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
intervalMinutes: config.intervalMinutes,
|
||||
cronExpression: config.cron,
|
||||
status: "ACTIVE",
|
||||
nextScanAt: nextScan,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
scheduled: schedule.status === "ACTIVE",
|
||||
intervalMinutes: schedule.intervalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active schedules that are due for scanning.
|
||||
*/
|
||||
async getDueSchedules(): Promise<Array<{ userId: string; intervalMinutes: number; cronExpression: string }>> {
|
||||
const now = new Date();
|
||||
|
||||
const due = await prisma.scanSchedule.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
OR: [
|
||||
{ nextScanAt: { lte: now } },
|
||||
{ nextScanAt: null },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
intervalMinutes: true,
|
||||
cronExpression: true,
|
||||
},
|
||||
});
|
||||
|
||||
return due;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a schedule as scanned and compute the next scan time.
|
||||
*/
|
||||
async markScanned(userId: string): Promise<Date> {
|
||||
const schedule = await prisma.scanSchedule.findUnique({ where: { userId } });
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error(`ScanSchedule not found for user ${userId}`);
|
||||
}
|
||||
|
||||
const nextScan = this.calculateNextScan(schedule.intervalMinutes);
|
||||
|
||||
await prisma.scanSchedule.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
lastScanAt: new Date(),
|
||||
nextScanAt: nextScan,
|
||||
},
|
||||
});
|
||||
|
||||
return nextScan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause scheduling for a user (e.g., on subscription downgrade or pause).
|
||||
*/
|
||||
async pauseSchedule(userId: string): Promise<void> {
|
||||
await prisma.scanSchedule.updateMany({
|
||||
where: { userId, status: "ACTIVE" },
|
||||
data: { status: "PAUSED" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume scheduling for a user and recalculate based on current tier.
|
||||
*/
|
||||
async resumeSchedule(userId: string): Promise<void> {
|
||||
await this.ensureScheduleForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current schedule for a user.
|
||||
*/
|
||||
async getSchedule(userId: string) {
|
||||
return prisma.scanSchedule.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active schedules (for admin/monitoring).
|
||||
*/
|
||||
async listActiveSchedules(limit = 100, offset = 0) {
|
||||
return prisma.scanSchedule.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscriptionTier: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { nextScanAt: "asc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next scan time based on interval.
|
||||
*/
|
||||
private calculateNextScan(intervalMinutes?: number): Date {
|
||||
const minutes = intervalMinutes ?? 60;
|
||||
const next = new Date();
|
||||
next.setMinutes(next.getMinutes() + minutes);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
193
services/darkwatch/src/webhooks/WebhookHandler.ts
Normal file
193
services/darkwatch/src/webhooks/WebhookHandler.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { DataSource, WebhookEventType } from "@shieldai/types";
|
||||
|
||||
export class WebhookHandler {
|
||||
private secret: string;
|
||||
|
||||
constructor(secret?: string) {
|
||||
this.secret = secret || process.env.WEBHOOK_SECRET || "default-webhook-secret";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature of incoming webhook payload.
|
||||
*/
|
||||
verifySignature(payload: string, signature: string | string[]): boolean {
|
||||
if (!signature) return false;
|
||||
|
||||
const sigArray = Array.isArray(signature) ? signature : [signature];
|
||||
const expected = this.computeSignature(payload);
|
||||
|
||||
for (const sig of sigArray) {
|
||||
if (timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming webhook event.
|
||||
* Validates, stores, and triggers appropriate action.
|
||||
*/
|
||||
async processEvent(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
source?: string,
|
||||
signature?: string
|
||||
): Promise<{ eventId: string; scanTriggered: boolean }> {
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
|
||||
if (signature && !this.verifySignature(payloadStr, signature)) {
|
||||
throw new Error("Webhook signature verification failed");
|
||||
}
|
||||
|
||||
const eventTypeNormalized = this.normalizeEventType(eventType);
|
||||
|
||||
const event = await prisma.webhookEvent.create({
|
||||
data: {
|
||||
eventType: eventTypeNormalized,
|
||||
payload: payloadStr,
|
||||
source,
|
||||
signature,
|
||||
},
|
||||
});
|
||||
|
||||
let scanTriggered = false;
|
||||
|
||||
if (eventTypeNormalized === WebhookEventType.SCAN_TRIGGER) {
|
||||
const userId = payload.userId as string | undefined;
|
||||
const source = (payload.dataSource as string) || undefined;
|
||||
|
||||
if (userId) {
|
||||
scanTriggered = await this.triggerScanFromWebhook(event.id, userId, source);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.webhookEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
processed: true,
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { eventId: event.id, scanTriggered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a scan job from a webhook event.
|
||||
*/
|
||||
private async triggerScanFromWebhook(
|
||||
eventId: string,
|
||||
userId: string,
|
||||
dataSource?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const job = await prisma.scanJob.create({
|
||||
data: {
|
||||
userId,
|
||||
status: "PENDING",
|
||||
source: (dataSource as DataSource) || undefined,
|
||||
scheduledBy: "webhook",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.webhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { scanJobId: job.id },
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Webhook] Scan trigger failed for event ${eventId}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook event history.
|
||||
*/
|
||||
async getEventHistory(limit = 50, offset = 0) {
|
||||
return prisma.webhookEvent.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: { scanJob: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific user (via linked scan jobs).
|
||||
*/
|
||||
async getUserEvents(userId: string, limit = 50, offset = 0) {
|
||||
return prisma.webhookEvent.findMany({
|
||||
where: {
|
||||
scanJob: { userId },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process unprocessed webhook events (retry mechanism).
|
||||
*/
|
||||
async processPendingEvents(): Promise<number> {
|
||||
const pending = await prisma.webhookEvent.findMany({
|
||||
where: {
|
||||
processed: false,
|
||||
eventType: WebhookEventType.SCAN_TRIGGER,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
|
||||
for (const event of pending) {
|
||||
try {
|
||||
const payload = JSON.parse(event.payload) as Record<string, unknown>;
|
||||
const userId = payload.userId as string | undefined;
|
||||
|
||||
if (userId) {
|
||||
const success = await this.triggerScanFromWebhook(
|
||||
event.id,
|
||||
userId,
|
||||
payload.dataSource as string | undefined
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await prisma.webhookEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { processed: true, processedAt: new Date() },
|
||||
});
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Webhook] Retry failed for event ${event.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private computeSignature(payload: string): string {
|
||||
return createHmac("sha256", this.secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
private normalizeEventType(eventType: string): WebhookEventType {
|
||||
const upper = eventType.toUpperCase().replace(/\s+/g, "_");
|
||||
const validTypes: WebhookEventType[] = [WebhookEventType.SCAN_TRIGGER, WebhookEventType.BREACH_DETECTED, WebhookEventType.SUBSCRIPTION_CHANGE];
|
||||
return validTypes.includes(upper as WebhookEventType) ? (upper as WebhookEventType) : WebhookEventType.SCAN_TRIGGER;
|
||||
}
|
||||
}
|
||||
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