FRE-4471: Scaffold DarkWatch MVP — monorepo, schema, services, API routes, tests
- Turborepo monorepo structure (packages: api, db, types, jobs; services: darkwatch) - Prisma schema: User, WatchListItem, Exposure, Alert, ScanJob models - WatchListService: CRUD with normalization, dedup, tier-based limits - HIBPService: API integration with severity scoring - MatchingEngine: exact-match with content hash dedup - AlertPipeline: dedup window, email notifications - ScanService: orchestrates watch list -> HIBP -> match -> alert flow - BullMQ job workers for scan and alert processing - Fastify API routes: watchlist, exposures, alerts, scan - Docker Compose: PostgreSQL 16 + Redis 7 - 15 unit tests passing - Implementation plan document uploaded
This commit is contained in:
81
services/darkwatch/test/alerts.test.ts
Normal file
81
services/darkwatch/test/alerts.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { AlertPipeline } from "../src/alerts/AlertPipeline";
|
||||
import prisma from "@shieldai/db";
|
||||
import { Severity } from "@shieldai/types";
|
||||
|
||||
describe("AlertPipeline", () => {
|
||||
let pipeline: AlertPipeline;
|
||||
|
||||
beforeEach(() => {
|
||||
pipeline = new AlertPipeline();
|
||||
});
|
||||
|
||||
it("creates alert with dedup key", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `alert-test-${Date.now()}@shieldai.local`, subscriptionTier: "BASIC" },
|
||||
});
|
||||
|
||||
const item = await prisma.watchListItem.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
identifierType: "EMAIL",
|
||||
identifierValue: "test@example.com",
|
||||
identifierHash: "hash-" + Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const exposure = await prisma.exposure.create({
|
||||
data: {
|
||||
watchListItemId: item.id,
|
||||
dataSource: "HIBP",
|
||||
breachName: "TestBreach",
|
||||
exposedAt: new Date(),
|
||||
dataType: ["Email Address"],
|
||||
severity: Severity.CRITICAL,
|
||||
contentHash: "content-" + Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const created = await pipeline.createAlert(user.id, exposure.id, Severity.CRITICAL);
|
||||
expect(created).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates alerts within window", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `dedup-test-${Date.now()}@shieldai.local`, subscriptionTier: "BASIC" },
|
||||
});
|
||||
|
||||
const item = await prisma.watchListItem.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
identifierType: "EMAIL",
|
||||
identifierValue: "dedup@example.com",
|
||||
identifierHash: "dedup-hash-" + Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const exposure = await prisma.exposure.create({
|
||||
data: {
|
||||
watchListItemId: item.id,
|
||||
dataSource: "HIBP",
|
||||
breachName: "DedupBreach",
|
||||
exposedAt: new Date(),
|
||||
dataType: ["Email Address"],
|
||||
severity: Severity.CRITICAL,
|
||||
contentHash: "dedup-content-" + Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const first = await pipeline.createAlert(user.id, exposure.id, Severity.CRITICAL);
|
||||
const second = await pipeline.createAlert(user.id, exposure.id, Severity.CRITICAL);
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
});
|
||||
|
||||
it("computes consistent dedup keys", () => {
|
||||
const key1 = pipeline.computeDedupKey("user-1", "exposure-1");
|
||||
const key2 = pipeline.computeDedupKey("user-1", "exposure-1");
|
||||
expect(key1).toBe(key2);
|
||||
expect(key1).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
62
services/darkwatch/test/hibp.test.ts
Normal file
62
services/darkwatch/test/hibp.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { HIBPService } from "../src/hibp/HIBPService";
|
||||
import { Severity } from "@shieldai/types";
|
||||
|
||||
describe("HIBPService", () => {
|
||||
const hibp = new HIBPService();
|
||||
|
||||
it("computes severity for critical data classes", () => {
|
||||
const breach = {
|
||||
name: "TestBreach",
|
||||
title: "Test Breach",
|
||||
domain: "test.com",
|
||||
loginCount: 0,
|
||||
passwordCount: 0,
|
||||
date: new Date(),
|
||||
breachDate: new Date(),
|
||||
addedDate: new Date(),
|
||||
pwnCount: 1000,
|
||||
dataClasses: ["Password", "Email Address"],
|
||||
logo: "",
|
||||
};
|
||||
expect(hibp.getSeverity(breach)).toBe(Severity.CRITICAL);
|
||||
});
|
||||
|
||||
it("computes severity for warning data classes", () => {
|
||||
const breach = {
|
||||
name: "TestBreach",
|
||||
title: "Test Breach",
|
||||
domain: "test.com",
|
||||
loginCount: 0,
|
||||
passwordCount: 0,
|
||||
date: new Date(),
|
||||
breachDate: new Date(),
|
||||
addedDate: new Date(),
|
||||
pwnCount: 1000,
|
||||
dataClasses: ["Phone Number"],
|
||||
logo: "",
|
||||
};
|
||||
expect(hibp.getSeverity(breach)).toBe(Severity.WARNING);
|
||||
});
|
||||
|
||||
it("computes INFO for old non-critical breaches", () => {
|
||||
const breach = {
|
||||
name: "OldBreach",
|
||||
title: "Old Breach",
|
||||
domain: "old.com",
|
||||
loginCount: 0,
|
||||
passwordCount: 0,
|
||||
date: new Date(),
|
||||
breachDate: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
|
||||
addedDate: new Date(),
|
||||
pwnCount: 1000,
|
||||
dataClasses: ["Name"],
|
||||
logo: "",
|
||||
};
|
||||
expect(hibp.getSeverity(breach)).toBe(Severity.INFO);
|
||||
});
|
||||
|
||||
it("maps to HIBP data source", () => {
|
||||
expect(hibp.mapToDataSource()).toBe("HIBP");
|
||||
});
|
||||
});
|
||||
20
services/darkwatch/test/matching.test.ts
Normal file
20
services/darkwatch/test/matching.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MatchingEngine } from "../src/matching/MatchingEngine";
|
||||
import { DataSource } from "@shieldai/types";
|
||||
|
||||
describe("MatchingEngine", () => {
|
||||
const engine = new MatchingEngine();
|
||||
|
||||
it("computes consistent content hash", () => {
|
||||
const hash1 = engine.computeContentHash(DataSource.HIBP, "TestBreach", "item-123");
|
||||
const hash2 = engine.computeContentHash(DataSource.HIBP, "TestBreach", "item-123");
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("produces different hashes for different inputs", () => {
|
||||
const hash1 = engine.computeContentHash(DataSource.HIBP, "BreachA", "item-123");
|
||||
const hash2 = engine.computeContentHash(DataSource.HIBP, "BreachB", "item-123");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
76
services/darkwatch/test/watchlist.test.ts
Normal file
76
services/darkwatch/test/watchlist.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { WatchListService } from "../src/watchlist/WatchListService";
|
||||
import prisma from "@shieldai/db";
|
||||
import { IdentifierType } from "@shieldai/types";
|
||||
|
||||
let runId = Date.now();
|
||||
|
||||
describe("WatchListService", () => {
|
||||
let service: WatchListService;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
runId = Date.now();
|
||||
service = new WatchListService();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `test-${runId}@shieldai.local`,
|
||||
name: "Test User",
|
||||
subscriptionTier: "PREMIUM",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.watchListItem.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("adds an email identifier", async () => {
|
||||
const item = await service.addItem(userId, IdentifierType.EMAIL, `test-${runId}@example.com`);
|
||||
expect(item.identifierValue).toBe(`test-${runId}@example.com`);
|
||||
expect(item.identifierType).toBe(IdentifierType.EMAIL);
|
||||
expect(item.identifierHash).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("adds a phone identifier", async () => {
|
||||
const digits = String(runId).padStart(10, "0").slice(-10);
|
||||
const phone = `${digits.slice(0,3)}-${digits.slice(3,7)}-${digits.slice(7)}`;
|
||||
const item = await service.addItem(userId, IdentifierType.PHONE, phone);
|
||||
expect(item.identifierType).toBe(IdentifierType.PHONE);
|
||||
expect(item.identifierValue).toMatch(/^\+1\d{10}$/);
|
||||
});
|
||||
|
||||
it("deduplicates by hash", async () => {
|
||||
await service.addItem(userId, IdentifierType.EMAIL, `dedup-${runId}@example.com`);
|
||||
const duplicate = service.addItem(userId, IdentifierType.EMAIL, `DEDUP-${runId}@EXAMPLE.COM`);
|
||||
await expect(duplicate).rejects.toThrow("Identifier already watched");
|
||||
});
|
||||
|
||||
it("lists items", async () => {
|
||||
await service.addItem(userId, IdentifierType.EMAIL, `list-a-${runId}@example.com`);
|
||||
await service.addItem(userId, IdentifierType.EMAIL, `list-b-${runId}@example.com`);
|
||||
const items = await service.listItems(userId);
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removes an item", async () => {
|
||||
const item = await service.addItem(userId, IdentifierType.EMAIL, `remove-${runId}@example.com`);
|
||||
const result = await service.removeItem(userId, item.id);
|
||||
expect(result.count).toBe(1);
|
||||
const remaining = await service.listItems(userId);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enforces BASIC tier limit", async () => {
|
||||
const basicUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { subscriptionTier: "BASIC" },
|
||||
});
|
||||
await service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-1-${runId}@example.com`);
|
||||
await service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-2-${runId}@example.com`);
|
||||
const third = service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-3-${runId}@example.com`);
|
||||
await expect(third).rejects.toThrow("Watch list limit reached");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user