feat(darkwatch): implement DarkWatch tRPC router and service layer
- Add darkwatch router with procedures: getWatchlist, addWatchlistItem, removeWatchlistItem, getExposures, getExposureDetails, runScan, getScanStatus, getReports - Add darkwatch service with watchlist CRUD, exposure queries, scan orchestration, tier limit enforcement, report listing - Add scan engine with HIBP, SecurityTrails, Censys, Shodan, and forum scraping modules (circuit breaker pattern, env-var API keys) - Add alert pipeline with severity scoring, deduplication, and exposure-to-alert creation - Add valibot schemas for input validation - Register router in root.ts - Write unit tests for router procedures, service functions, and severity scoring (21 tests passing)
This commit is contained in:
@@ -2,6 +2,7 @@ import { exampleRouter } from "./routers/example";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { billingRouter } from "./routers/billing";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { darkwatchRouter } from "./routers/darkwatch";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -9,6 +10,7 @@ export const appRouter = createTRPCRouter({
|
||||
user: userRouter,
|
||||
billing: billingRouter,
|
||||
notification: notificationRouter,
|
||||
darkwatch: darkwatchRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
202
web/src/server/api/routers/darkwatch.test.ts
Normal file
202
web/src/server/api/routers/darkwatch.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
AddWatchlistItemSchema,
|
||||
RemoveWatchlistItemSchema,
|
||||
ExposureFilterSchema,
|
||||
ExposureDetailsSchema,
|
||||
RunScanSchema,
|
||||
ReportFilterSchema,
|
||||
} from "../schemas/darkwatch";
|
||||
|
||||
vi.mock("~/server/services/darkwatch.service", () => ({
|
||||
getWatchlistItems: vi.fn(),
|
||||
addWatchlistItem: vi.fn(),
|
||||
removeWatchlistItem: vi.fn(),
|
||||
getExposures: vi.fn(),
|
||||
getExposureDetails: vi.fn(),
|
||||
runScan: vi.fn(),
|
||||
getScanStatus: vi.fn(),
|
||||
getReports: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as darkwatchService from "~/server/services/darkwatch.service";
|
||||
|
||||
const mockGetWatchlistItems = vi.mocked(darkwatchService.getWatchlistItems);
|
||||
const mockAddWatchlistItem = vi.mocked(darkwatchService.addWatchlistItem);
|
||||
const mockRemoveWatchlistItem = vi.mocked(darkwatchService.removeWatchlistItem);
|
||||
const mockGetExposures = vi.mocked(darkwatchService.getExposures);
|
||||
const mockGetExposureDetails = vi.mocked(darkwatchService.getExposureDetails);
|
||||
const mockRunScan = vi.mocked(darkwatchService.runScan);
|
||||
const mockGetScanStatus = vi.mocked(darkwatchService.getScanStatus);
|
||||
const mockGetReports = vi.mocked(darkwatchService.getReports);
|
||||
|
||||
type User = {
|
||||
id: string; email: string; name: string | null; image: string | null;
|
||||
role: string; emailVerified: Date | null; deletedAt: Date | null;
|
||||
stripeCustomerId: string | null;
|
||||
createdAt: Date; updatedAt: Date;
|
||||
};
|
||||
type Ctx = { db: object; user: User | null; apiKey: string | null };
|
||||
|
||||
function createCaller(user: User | null) {
|
||||
const t = initTRPC.context<Ctx>().create();
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
const router = t.router({
|
||||
getWatchlist: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetWatchlistItems(ctx.user.id);
|
||||
}),
|
||||
addWatchlistItem: t.procedure.use(isAuthed)
|
||||
.input(wrap(AddWatchlistItemSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockAddWatchlistItem(ctx.user.id, input.type, input.value);
|
||||
}),
|
||||
removeWatchlistItem: t.procedure.use(isAuthed)
|
||||
.input(wrap(RemoveWatchlistItemSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockRemoveWatchlistItem(ctx.user.id, input.itemId);
|
||||
}),
|
||||
getExposures: t.procedure.use(isAuthed)
|
||||
.input(wrap(ExposureFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetExposures(ctx.user.id, input);
|
||||
}),
|
||||
getExposureDetails: t.procedure.use(isAuthed)
|
||||
.input(wrap(ExposureDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetExposureDetails(ctx.user.id, input.exposureId);
|
||||
}),
|
||||
runScan: t.procedure.use(isAuthed)
|
||||
.input(wrap(RunScanSchema))
|
||||
.mutation(async ({ ctx }) => {
|
||||
return mockRunScan(ctx.user.id);
|
||||
}),
|
||||
getScanStatus: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetScanStatus(ctx.user.id);
|
||||
}),
|
||||
getReports: t.procedure.use(isAuthed)
|
||||
.input(wrap(ReportFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetReports(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = t.createCallerFactory(router);
|
||||
return caller({ db: {} as never, user, apiKey: null });
|
||||
}
|
||||
|
||||
const baseUser: User = {
|
||||
id: "user-1", email: "a@b.com", name: "Test", image: null,
|
||||
role: "user", emailVerified: null, deletedAt: null,
|
||||
stripeCustomerId: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return { ...baseUser, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("darkwatch.getWatchlist", () => {
|
||||
it("returns watchlist items for authenticated user", async () => {
|
||||
const items = [{ id: "w1", type: "email", value: "test@example.com" }];
|
||||
mockGetWatchlistItems.mockResolvedValue(items as never);
|
||||
const api = createCaller(makeUser());
|
||||
expect(await api.getWatchlist()).toEqual(items);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getWatchlist()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.addWatchlistItem", () => {
|
||||
it("adds a watchlist item", async () => {
|
||||
const item = { id: "w1", type: "email", value: "test@example.com" };
|
||||
mockAddWatchlistItem.mockResolvedValue(item as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.addWatchlistItem({ type: "email", value: "test@example.com" });
|
||||
expect(result).toEqual(item);
|
||||
});
|
||||
|
||||
it("rejects invalid type", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.addWatchlistItem({ type: "invalid" as never, value: "test" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.removeWatchlistItem", () => {
|
||||
it("removes a watchlist item", async () => {
|
||||
mockRemoveWatchlistItem.mockResolvedValue({ id: "w1", isActive: false } as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.removeWatchlistItem({ itemId: "w1" });
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.getExposures", () => {
|
||||
it("returns exposures with pagination", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetExposures.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getExposures({ page: 1, limit: 20 });
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it("passes severity filter", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetExposures.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
await api.getExposures({ severity: "critical" });
|
||||
expect(mockGetExposures).toHaveBeenCalledWith("user-1", { severity: "critical", page: 1, limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.getExposureDetails", () => {
|
||||
it("returns exposure details", async () => {
|
||||
const exposure = { id: "e1", identifier: "test@example.com", watchlistItem: null };
|
||||
mockGetExposureDetails.mockResolvedValue(exposure as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getExposureDetails({ exposureId: "e1" });
|
||||
expect(result.id).toBe("e1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.runScan", () => {
|
||||
it("triggers a scan", async () => {
|
||||
mockRunScan.mockResolvedValue({ scanId: "s1" });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.runScan({});
|
||||
expect(result.scanId).toBe("s1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.getScanStatus", () => {
|
||||
it("returns scan status", async () => {
|
||||
mockGetScanStatus.mockResolvedValue({ status: "idle", startedAt: null, completedAt: null, progress: 0, error: null });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getScanStatus();
|
||||
expect(result.status).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("darkwatch.getReports", () => {
|
||||
it("returns reports", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetReports.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getReports({ page: 1, limit: 20 });
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
57
web/src/server/api/routers/darkwatch.ts
Normal file
57
web/src/server/api/routers/darkwatch.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import {
|
||||
AddWatchlistItemSchema,
|
||||
RemoveWatchlistItemSchema,
|
||||
ExposureFilterSchema,
|
||||
ExposureDetailsSchema,
|
||||
RunScanSchema,
|
||||
ReportFilterSchema,
|
||||
} from "../schemas/darkwatch";
|
||||
import * as darkwatchService from "~/server/services/darkwatch.service";
|
||||
|
||||
export const darkwatchRouter = createTRPCRouter({
|
||||
getWatchlist: protectedProcedure.query(async ({ ctx }) => {
|
||||
return darkwatchService.getWatchlistItems(ctx.user.id);
|
||||
}),
|
||||
|
||||
addWatchlistItem: protectedProcedure
|
||||
.input(wrap(AddWatchlistItemSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return darkwatchService.addWatchlistItem(ctx.user.id, input.type, input.value);
|
||||
}),
|
||||
|
||||
removeWatchlistItem: protectedProcedure
|
||||
.input(wrap(RemoveWatchlistItemSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return darkwatchService.removeWatchlistItem(ctx.user.id, input.itemId);
|
||||
}),
|
||||
|
||||
getExposures: protectedProcedure
|
||||
.input(wrap(ExposureFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return darkwatchService.getExposures(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
getExposureDetails: protectedProcedure
|
||||
.input(wrap(ExposureDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return darkwatchService.getExposureDetails(ctx.user.id, input.exposureId);
|
||||
}),
|
||||
|
||||
runScan: protectedProcedure
|
||||
.input(wrap(RunScanSchema))
|
||||
.mutation(async ({ ctx }) => {
|
||||
return darkwatchService.runScan(ctx.user.id);
|
||||
}),
|
||||
|
||||
getScanStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
return darkwatchService.getScanStatus(ctx.user.id);
|
||||
}),
|
||||
|
||||
getReports: protectedProcedure
|
||||
.input(wrap(ReportFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return darkwatchService.getReports(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
28
web/src/server/api/schemas/darkwatch.ts
Normal file
28
web/src/server/api/schemas/darkwatch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { object, string, minLength, optional, number, picklist } from "valibot";
|
||||
|
||||
export const AddWatchlistItemSchema = object({
|
||||
type: picklist(["email", "phoneNumber", "ssn", "address", "domain"]),
|
||||
value: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const RemoveWatchlistItemSchema = object({
|
||||
itemId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ExposureFilterSchema = object({
|
||||
severity: optional(picklist(["info", "warning", "critical"])),
|
||||
source: optional(picklist(["hibp", "securityTrails", "censys", "darkWebForum", "shodan", "honeypot"])),
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
|
||||
export const ExposureDetailsSchema = object({
|
||||
exposureId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const RunScanSchema = object({});
|
||||
|
||||
export const ReportFilterSchema = object({
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
Reference in New Issue
Block a user