Compare commits
11 Commits
e704a9074a
...
baa216d62c
| Author | SHA1 | Date | |
|---|---|---|---|
| baa216d62c | |||
| f2593c1e67 | |||
| a4684e9121 | |||
|
|
91e4985a8e | ||
| 0afdf8b6e8 | |||
| 274afa6335 | |||
| 24bc9c235f | |||
| 93ff4885ee | |||
| 67622a2f11 | |||
| bdf8ad30b6 | |||
| f34adc5e82 |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -84,11 +84,18 @@ jobs:
|
||||
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: shieldai-coverage
|
||||
fail_on_empty: false
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
|
||||
1
.turbo/cache/6abb2efbabfd492c-manifest.json
vendored
Normal file
1
.turbo/cache/6abb2efbabfd492c-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777817946251116749,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1777817946232116132,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777817946270117366,"mode":420,"is_dir":false},"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777817946240116392,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1777817946235116229,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1777817946232116132,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1777817946235116229,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777817946240116392,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777817946251116749,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||
1
.turbo/cache/6abb2efbabfd492c-meta.json
vendored
Normal file
1
.turbo/cache/6abb2efbabfd492c-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"6abb2efbabfd492c","duration":728,"sha":"a4684e912110fdf2702981e23494be96df91b86f","dirty_hash":"85a4cfa756e84c777eeff88ca5a3d970b636968eb72658995bfec15eeba2d9b4"}
|
||||
BIN
.turbo/cache/6abb2efbabfd492c.tar.zst
vendored
Normal file
BIN
.turbo/cache/6abb2efbabfd492c.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/df8d582601d96e8d-manifest.json
vendored
Normal file
1
.turbo/cache/df8d582601d96e8d-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777754191886389843,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1777754191880389688,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777754191897390127,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777754191886389843,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777754191897390127,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1777754191880389688,"mode":420,"is_dir":false},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777754191919390695,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1777754191876389585,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1777754191876389585,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||
1
.turbo/cache/df8d582601d96e8d-meta.json
vendored
Normal file
1
.turbo/cache/df8d582601d96e8d-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"df8d582601d96e8d","duration":684,"sha":"274afa63352200107e5e3ed5a783555fe3c68e37","dirty_hash":"1b22568f1b7a3df274940e36b290211b3251b700c1e1286bc843ed3e00b07e05"}
|
||||
BIN
.turbo/cache/df8d582601d96e8d.tar.zst
vendored
Normal file
BIN
.turbo/cache/df8d582601d96e8d.tar.zst
vendored
Normal file
Binary file not shown.
@@ -10,15 +10,17 @@
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"test:coverage": "turbo run test:coverage",
|
||||
"db:migrate": "turbo run db:migrate",
|
||||
"db:seed": "turbo run db:seed",
|
||||
"lint": "turbo run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.5"
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -19,5 +20,9 @@
|
||||
"fastify": "^5.2.0",
|
||||
"@shieldai/darkwatch": "workspace:*",
|
||||
"@shieldai/voiceprint": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { SMSClassifierService } from '../services/spamshield/spamshield.service';
|
||||
|
||||
// Mock shared-db before anything else (Prisma client is not generated in test env)
|
||||
vi.mock('@shieldsai/shared-db', () => ({
|
||||
vi.mock('@shieldai/db', () => ({
|
||||
prisma: {},
|
||||
SpamFeedback: {},
|
||||
}));
|
||||
@@ -35,6 +35,31 @@ vi.mock('../services/spamshield/spamshield.config', () => ({
|
||||
VERY_HIGH: 'very_high',
|
||||
},
|
||||
spamRateLimits: {},
|
||||
defaultScores: {
|
||||
defaultReputationConfidence: 0.0,
|
||||
defaultReputationLowConfidence: 0.1,
|
||||
defaultBaseConfidence: 0.5,
|
||||
defaultMaxConfidence: 1.0,
|
||||
featureWeights: {
|
||||
urlPresent: 0.1,
|
||||
highEmojiDensity: 0.15,
|
||||
urgencyKeyword: 0.2,
|
||||
excessiveCaps: 0.15,
|
||||
},
|
||||
defaultSpamScore: 0.0,
|
||||
highReputationThreshold: 0.7,
|
||||
reputationWeightInCombinedScore: 0.4,
|
||||
shortDurationScore: 0.2,
|
||||
voipScore: 0.15,
|
||||
unusualHoursScore: 0.1,
|
||||
hiyaWeightInCombinedScore: 0.7,
|
||||
truecallerWeightInCombinedScore: 0.3,
|
||||
},
|
||||
metadataLimits: {
|
||||
maxMetadataSizeBytes: 4096,
|
||||
maxMetadataKeys: 20,
|
||||
maxMetadataValueSizeBytes: 512,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SMSClassifierService', () => {
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { correlationService } from "@shieldai/correlation";
|
||||
|
||||
type AuthUser = { id?: string };
|
||||
|
||||
function getUserId(request: FastifyRequest): string | undefined {
|
||||
return (request.user as AuthUser | undefined)?.id;
|
||||
}
|
||||
|
||||
export function correlationRoutes(fastify: FastifyInstance) {
|
||||
fastify.get("/dashboard", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const timeWindow = parseInt((request.query as any).timeWindow as string) || 60;
|
||||
const timeWindow =
|
||||
parseInt(
|
||||
(request.query as Record<string, string>).timeWindow as string
|
||||
) || 60;
|
||||
const data = await correlationService.getDashboardData(userId, timeWindow);
|
||||
return reply.send(data);
|
||||
});
|
||||
|
||||
fastify.get("/groups", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const query = request.query as Record<string, string>;
|
||||
const result = await correlationService.getCorrelationGroups({
|
||||
userId,
|
||||
status: query.status || undefined,
|
||||
status: (query.status as any) || undefined,
|
||||
timeWindowMinutes: query.timeWindow
|
||||
? parseInt(query.timeWindow)
|
||||
: 60,
|
||||
@@ -34,43 +41,91 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
fastify.get("/groups/:groupId", async (request, reply) => {
|
||||
const groupId = (request.params as any).groupId;
|
||||
const group = await correlationService.getGroupById(groupId);
|
||||
fastify.get(
|
||||
"/groups/:groupId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
groupId: { type: "string", format: "uuid" },
|
||||
},
|
||||
required: ["groupId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
const groupId = (request.params as Record<string, string>).groupId;
|
||||
const group = await correlationService.getGroupById(groupId, userId);
|
||||
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
}
|
||||
|
||||
return reply.send(group);
|
||||
}
|
||||
);
|
||||
|
||||
return reply.send(group);
|
||||
});
|
||||
fastify.patch(
|
||||
"/groups/:groupId/resolve",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
groupId: { type: "string", format: "uuid" },
|
||||
},
|
||||
required: ["groupId"],
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["RESOLVED", "ACTIVE"] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
fastify.patch("/groups/:groupId/resolve", async (request, reply) => {
|
||||
const groupId = (request.params as any).groupId;
|
||||
const body = (request.body as any) || {};
|
||||
const status = body.status || "RESOLVED";
|
||||
const group = await correlationService.resolveGroup(groupId, status);
|
||||
const groupId = (request.params as Record<string, string>).groupId;
|
||||
const body = request.body as Record<string, string> | undefined;
|
||||
const status = body?.status || "RESOLVED";
|
||||
const group = await correlationService.resolveGroup(
|
||||
groupId,
|
||||
userId,
|
||||
status
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
}
|
||||
|
||||
return reply.send(group);
|
||||
}
|
||||
|
||||
return reply.send(group);
|
||||
});
|
||||
);
|
||||
|
||||
fastify.get("/alerts", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const query = request.query as Record<string, string>;
|
||||
const result = await correlationService.getCorrelatedAlerts({
|
||||
userId,
|
||||
source: query.source || undefined,
|
||||
category: query.category || undefined,
|
||||
severity: query.severity || undefined,
|
||||
source: (query.source as any) || undefined,
|
||||
category: (query.category as any) || undefined,
|
||||
severity: (query.severity as any) || undefined,
|
||||
timeWindowMinutes: query.timeWindow
|
||||
? parseInt(query.timeWindow)
|
||||
: 60,
|
||||
@@ -80,72 +135,200 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
fastify.post("/ingest/darkwatch", async (request, reply) => {
|
||||
const body = request.body as any;
|
||||
const alert = await correlationService.ingestDarkWatchAlert(
|
||||
body.userId,
|
||||
body.sourceAlertId,
|
||||
{
|
||||
exposureId: body.exposureId,
|
||||
breachName: body.breachName,
|
||||
severity: body.severity,
|
||||
channel: body.channel,
|
||||
dataType: body.dataType,
|
||||
dataSource: body.dataSource,
|
||||
fastify.post(
|
||||
"/ingest/darkwatch",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
exposureId: { type: "string" },
|
||||
breachName: { type: "string", maxLength: 500 },
|
||||
severity: { type: "string", maxLength: 20 },
|
||||
channel: { type: "string", maxLength: 50 },
|
||||
dataType: { type: "array", items: { type: "string" } },
|
||||
dataSource: { type: "string", maxLength: 100 },
|
||||
},
|
||||
required: ["sourceAlertId", "breachName", "severity", "channel"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
});
|
||||
|
||||
fastify.post("/ingest/spamshield", async (request, reply) => {
|
||||
const body = request.body as any;
|
||||
const alert = await correlationService.ingestSpamShieldAlert(
|
||||
body.userId,
|
||||
body.sourceAlertId,
|
||||
{
|
||||
phoneNumber: body.phoneNumber,
|
||||
decision: body.decision,
|
||||
confidence: body.confidence,
|
||||
reasons: body.reasons,
|
||||
channel: body.channel,
|
||||
hiyaReputationScore: body.hiyaReputationScore,
|
||||
truecallerSpamScore: body.truecallerSpamScore,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
});
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestDarkWatchAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
exposureId: body.exposureId as string,
|
||||
breachName: body.breachName as string,
|
||||
severity: body.severity as string,
|
||||
channel: body.channel as string,
|
||||
dataType: body.dataType as string[] | undefined,
|
||||
dataSource: body.dataSource as string | undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post("/ingest/voiceprint", async (request, reply) => {
|
||||
const body = request.body as any;
|
||||
const alert = await correlationService.ingestVoicePrintAlert(
|
||||
body.userId,
|
||||
body.sourceAlertId,
|
||||
{
|
||||
jobId: body.jobId,
|
||||
verdict: body.verdict,
|
||||
syntheticScore: body.syntheticScore,
|
||||
confidence: body.confidence,
|
||||
matchedEnrollmentId: body.matchedEnrollmentId,
|
||||
matchedSimilarity: body.matchedSimilarity,
|
||||
analysisType: body.analysisType,
|
||||
fastify.post(
|
||||
"/ingest/spamshield",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
phoneNumber: { type: "string", maxLength: 20 },
|
||||
decision: { type: "string", enum: ["BLOCK", "FLAG", "ALLOW"] },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
reasons: { type: "array", items: { type: "string" } },
|
||||
channel: { type: "string", enum: ["call", "sms"] },
|
||||
hiyaReputationScore: { type: "number" },
|
||||
truecallerSpamScore: { type: "number" },
|
||||
},
|
||||
required: ["sourceAlertId", "phoneNumber", "decision", "confidence"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
});
|
||||
|
||||
fastify.post("/ingest/call-analysis", async (request, reply) => {
|
||||
const body = request.body as any;
|
||||
const alert = await correlationService.ingestCallAnalysisAlert(
|
||||
body.userId,
|
||||
body.sourceAlertId,
|
||||
{
|
||||
callId: body.callId,
|
||||
eventType: body.eventType,
|
||||
mosScore: body.mosScore,
|
||||
anomaly: body.anomaly,
|
||||
sentiment: body.sentiment,
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestSpamShieldAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
phoneNumber: body.phoneNumber as string,
|
||||
decision: body.decision as string,
|
||||
confidence: body.confidence as number,
|
||||
reasons: body.reasons as string[] | undefined,
|
||||
channel: body.channel as "call" | "sms" | undefined,
|
||||
hiyaReputationScore: body.hiyaReputationScore as
|
||||
| number
|
||||
| undefined,
|
||||
truecallerSpamScore: body.truecallerSpamScore as
|
||||
| number
|
||||
| undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/ingest/voiceprint",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
jobId: { type: "string" },
|
||||
verdict: {
|
||||
type: "string",
|
||||
enum: ["SYNTHETIC", "NATURAL", "UNCERTAIN"],
|
||||
},
|
||||
syntheticScore: { type: "number", minimum: 0, maximum: 1 },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
matchedEnrollmentId: { type: "string" },
|
||||
matchedSimilarity: { type: "number" },
|
||||
analysisType: { type: "string", maxLength: 50 },
|
||||
},
|
||||
required: [
|
||||
"sourceAlertId",
|
||||
"jobId",
|
||||
"verdict",
|
||||
"syntheticScore",
|
||||
"confidence",
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
});
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestVoicePrintAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
jobId: body.jobId as string,
|
||||
verdict: body.verdict as string,
|
||||
syntheticScore: body.syntheticScore as number,
|
||||
confidence: body.confidence as number,
|
||||
matchedEnrollmentId: body.matchedEnrollmentId as
|
||||
| string
|
||||
| undefined,
|
||||
matchedSimilarity: body.matchedSimilarity as number | undefined,
|
||||
analysisType: body.analysisType as string | undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/ingest/call-analysis",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
callId: { type: "string" },
|
||||
eventType: { type: "string", maxLength: 100 },
|
||||
mosScore: { type: "number", minimum: 1, maximum: 5 },
|
||||
anomaly: { type: "string", maxLength: 500 },
|
||||
sentiment: {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string", maxLength: 50 },
|
||||
score: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["sourceAlertId", "callId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestCallAnalysisAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
callId: body.callId as string,
|
||||
eventType: body.eventType as string | undefined,
|
||||
mosScore: body.mosScore as number | undefined,
|
||||
anomaly: body.anomaly as string | undefined,
|
||||
sentiment: body.sentiment as
|
||||
| { label: string; score: number }
|
||||
| undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
|
||||
import { prisma, SubscriptionTier } from '@shieldai/db';
|
||||
import { tierConfig, SubscriptionTier as BillingTier } from '@shieldsai/shared-billing';
|
||||
import {
|
||||
watchlistService,
|
||||
|
||||
@@ -21,8 +21,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
"/:userId",
|
||||
async (request, reply) => {
|
||||
const userId = (request.params as { userId: string }).userId;
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
const params = request.params as { userId: string };
|
||||
const authedUser = (request.user as { id: string })?.id;
|
||||
if (authedUser !== params.userId) {
|
||||
return reply.code(403).send({ error: "Forbidden" });
|
||||
}
|
||||
const schedule = await scheduler.getSchedule(params.userId);
|
||||
|
||||
if (!schedule) {
|
||||
return reply.code(404).send({ error: "Schedule not found" });
|
||||
@@ -35,8 +39,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
|
||||
fastify.post(
|
||||
"/:userId/pause",
|
||||
async (request, reply) => {
|
||||
const userId = (request.params as { userId: string }).userId;
|
||||
await scheduler.pauseSchedule(userId);
|
||||
const params = request.params as { userId: string };
|
||||
const authedUser = (request.user as { id: string })?.id;
|
||||
if (authedUser !== params.userId) {
|
||||
return reply.code(403).send({ error: "Forbidden" });
|
||||
}
|
||||
await scheduler.pauseSchedule(params.userId);
|
||||
return reply.send({ paused: true });
|
||||
}
|
||||
);
|
||||
@@ -44,8 +52,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
|
||||
fastify.post(
|
||||
"/:userId/resume",
|
||||
async (request, reply) => {
|
||||
const userId = (request.params as { userId: string }).userId;
|
||||
await scheduler.resumeSchedule(userId);
|
||||
const params = request.params as { userId: string };
|
||||
const authedUser = (request.user as { id: string })?.id;
|
||||
if (authedUser !== params.userId) {
|
||||
return reply.code(403).send({ error: "Forbidden" });
|
||||
}
|
||||
await scheduler.resumeSchedule(params.userId);
|
||||
return reply.send({ resumed: true });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -31,13 +31,8 @@ export function webhookRoutes(fastify: FastifyInstance) {
|
||||
scanTriggered: result.scanTriggered,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (message.includes("signature")) {
|
||||
return reply.code(401).send({ error: message });
|
||||
}
|
||||
|
||||
return reply.code(400).send({ error: message });
|
||||
console.error("[Webhook] Event processing error:", err);
|
||||
return reply.code(400).send({ error: "Webhook processing failed" });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -56,11 +51,15 @@ export function webhookRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
"/user/:userId",
|
||||
async (request, reply) => {
|
||||
const userId = (request.params as { userId: string }).userId;
|
||||
const params = request.params as { userId: string };
|
||||
const authedUser = (request.user as { id: string })?.id;
|
||||
if (authedUser !== params.userId) {
|
||||
return reply.code(403).send({ error: "Forbidden" });
|
||||
}
|
||||
const limit = parseInt((request.query as { limit?: string }).limit || "50");
|
||||
const offset = parseInt((request.query as { offset?: string }).offset || "0");
|
||||
|
||||
const events = await handler.getUserEvents(userId, limit, offset);
|
||||
const events = await handler.getUserEvents(params.userId, limit, offset);
|
||||
return reply.send(events);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,7 +3,10 @@ import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||
import { darkwatchRoutes, voiceprintRoutes, correlationRoutes } from "./routes";
|
||||
import { authMiddleware } from "./middleware/auth.middleware";
|
||||
import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
||||
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
||||
import { correlationRoutes } from "./routes/correlation.routes";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -12,10 +15,13 @@ const app = Fastify({
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(cors, { origin: process.env.CORS_ORIGIN || "http://localhost:5173" });
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
|
||||
// Register auth middleware to populate request.user
|
||||
await app.register(authMiddleware);
|
||||
|
||||
app.addHook("onRequest", async (request, _reply) => {
|
||||
const requestId = extractOrGenerateRequestId(request.headers);
|
||||
request.id = requestId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
||||
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
|
||||
import {
|
||||
NotificationService,
|
||||
NotificationPriority,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db';
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldai/db';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
function hashIdentifier(identifier: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldsai/shared-db';
|
||||
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldai/db';
|
||||
import { tierConfig } from '@shieldsai/shared-billing';
|
||||
import { darkwatchScanQueue } from '@shieldsai/jobs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, WatchlistType } from '@shieldsai/shared-db';
|
||||
import { prisma, WatchlistType } from '@shieldai/db';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export function normalizeValue(type: WatchlistType, value: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldai/db';
|
||||
import { createHash } from 'crypto';
|
||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, SpamFeedback } from '@shieldsai/shared-db';
|
||||
import { prisma, SpamFeedback } from '@shieldai/db';
|
||||
import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
|
||||
import { createHash } from 'crypto';
|
||||
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
|
||||
@@ -366,8 +366,27 @@ export class SpamFeedbackService {
|
||||
confidence?: number,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<SpamFeedback> {
|
||||
// Validate metadata
|
||||
const validation = this.validateMetadata(metadata);
|
||||
// Defensive null checks for required fields
|
||||
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
|
||||
throw new Error('Feedback: userId is required');
|
||||
}
|
||||
|
||||
if (!phoneNumber || typeof phoneNumber !== 'string' || phoneNumber.trim().length === 0) {
|
||||
throw new Error('Feedback: phoneNumber is required');
|
||||
}
|
||||
|
||||
if (typeof isSpam !== 'boolean') {
|
||||
throw new Error('Feedback: isSpam must be a boolean');
|
||||
}
|
||||
|
||||
// Validate confidence range if provided
|
||||
const validatedConfidence = confidence !== undefined && confidence !== null
|
||||
? (Number.isFinite(confidence) && confidence >= 0 && confidence <= 1 ? confidence : undefined)
|
||||
: undefined;
|
||||
|
||||
// Treat null metadata as undefined
|
||||
const effectiveMetadata = metadata !== null ? metadata : undefined;
|
||||
const validation = this.validateMetadata(effectiveMetadata);
|
||||
const validatedMetadata = validation.trimmedMetadata;
|
||||
|
||||
// Only enable if feature flag is set
|
||||
@@ -379,7 +398,7 @@ export class SpamFeedbackService {
|
||||
phoneNumber,
|
||||
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
|
||||
isSpam,
|
||||
confidence,
|
||||
confidence: validatedConfidence,
|
||||
feedbackType: 'user_confirmation' as const,
|
||||
metadata: validatedMetadata,
|
||||
createdAt: new Date(),
|
||||
@@ -395,7 +414,7 @@ export class SpamFeedbackService {
|
||||
phoneNumber,
|
||||
phoneNumberHash,
|
||||
isSpam,
|
||||
confidence,
|
||||
confidence: validatedConfidence,
|
||||
feedbackType: 'user_confirmation',
|
||||
metadata: validatedMetadata,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
|
||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
|
||||
import {
|
||||
voicePrintEnv,
|
||||
AnalysisJobStatus,
|
||||
|
||||
26
packages/api/vitest.config.ts
Normal file
26
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'src/**/__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'src/**/__tests__/**/*.test.ts',
|
||||
'**/node_modules/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -282,10 +282,11 @@ export class CorrelationEngine {
|
||||
}
|
||||
|
||||
public async getGroupById(
|
||||
groupId: string
|
||||
groupId: string,
|
||||
userId: string
|
||||
): Promise<CorrelationGroupOutput | null> {
|
||||
const group = await (prisma as any).correlationGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
where: { id: groupId, userId },
|
||||
include: {
|
||||
alerts: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -298,10 +299,11 @@ export class CorrelationEngine {
|
||||
|
||||
public async resolveGroup(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
status: string = CorrelationStatus.RESOLVED
|
||||
): Promise<CorrelationGroupOutput | null> {
|
||||
const group = await (prisma as any).correlationGroup.update({
|
||||
where: { id: groupId },
|
||||
where: { id: groupId, userId },
|
||||
data: {
|
||||
status,
|
||||
resolvedAt: new Date(),
|
||||
|
||||
@@ -8,6 +8,24 @@ import {
|
||||
|
||||
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
|
||||
|
||||
function sanitizePayload(
|
||||
payload: Record<string, unknown>,
|
||||
maxDepth: number = 5
|
||||
): Record<string, unknown> {
|
||||
const seen = new WeakSet<object>();
|
||||
const clone = (obj: unknown, depth: number): unknown => {
|
||||
if (depth > maxDepth) return "[max depth]";
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (seen.has(obj as object)) return "[circular]";
|
||||
seen.add(obj as object);
|
||||
if (Array.isArray(obj)) return obj.map((item) => clone(item, depth + 1));
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, clone(v, depth + 1)])
|
||||
);
|
||||
};
|
||||
return clone(payload, 0) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DarkWatchAlertPayload {
|
||||
exposureId: string;
|
||||
breachName: string;
|
||||
@@ -92,7 +110,7 @@ export class AlertNormalizer {
|
||||
: `Exposure detected in ${payload.breachName}`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
@@ -132,7 +150,7 @@ export class AlertNormalizer {
|
||||
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
@@ -179,7 +197,7 @@ export class AlertNormalizer {
|
||||
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
@@ -237,7 +255,7 @@ export class AlertNormalizer {
|
||||
description,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,12 +126,12 @@ export class CorrelationService {
|
||||
return this.engine.getCorrelationGroups(query);
|
||||
}
|
||||
|
||||
public getGroupById(groupId: string) {
|
||||
return this.engine.getGroupById(groupId);
|
||||
public getGroupById(groupId: string, userId: string) {
|
||||
return this.engine.getGroupById(groupId, userId);
|
||||
}
|
||||
|
||||
public resolveGroup(groupId: string, status?: string) {
|
||||
return this.engine.resolveGroup(groupId, status as any);
|
||||
public resolveGroup(groupId: string, userId: string, status?: string) {
|
||||
return this.engine.resolveGroup(groupId, userId, status as any);
|
||||
}
|
||||
|
||||
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
{
|
||||
"name": "@shieldai/db",
|
||||
"version": "0.1.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.js",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "prisma generate && tsc",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"db:push": "prisma db push",
|
||||
"db:format": "prisma format",
|
||||
"generate": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.2.0",
|
||||
"prisma": "^6.2.0"
|
||||
"prisma": "^6.2.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0"
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Prisma schema for ShieldAI
|
||||
// All models for the multi-service SaaS platform
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@@ -7,337 +10,438 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum SubscriptionTier {
|
||||
BASIC
|
||||
PLUS
|
||||
PREMIUM
|
||||
}
|
||||
|
||||
enum IdentifierType {
|
||||
EMAIL
|
||||
PHONE
|
||||
SSN
|
||||
}
|
||||
|
||||
enum WatchListStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum Severity {
|
||||
LOW
|
||||
INFO
|
||||
MEDIUM
|
||||
WARNING
|
||||
HIGH
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum AlertChannel {
|
||||
EMAIL
|
||||
PUSH
|
||||
SMS
|
||||
}
|
||||
|
||||
enum AlertStatus {
|
||||
PENDING
|
||||
SENT
|
||||
READ
|
||||
}
|
||||
|
||||
enum ScanJobStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum DataSource {
|
||||
HIBP
|
||||
SECURITY_TRAILS
|
||||
CENSYS
|
||||
SHODAN
|
||||
HONEYPOT
|
||||
}
|
||||
|
||||
enum AnalysisJobStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum AnalysisType {
|
||||
SYNTHETIC_DETECTION
|
||||
VOICE_MATCH
|
||||
BATCH
|
||||
}
|
||||
|
||||
enum DetectionVerdict {
|
||||
NATURAL
|
||||
SYNTHETIC
|
||||
UNCERTAIN
|
||||
}
|
||||
// ============================================
|
||||
// User & Authentication Models
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String?
|
||||
subscriptionTier SubscriptionTier @default(BASIC)
|
||||
familyGroupId String?
|
||||
watchListItems WatchListItem[]
|
||||
alerts Alert[]
|
||||
scanJobs ScanJob[]
|
||||
scanSchedules ScanSchedule[]
|
||||
voiceEnrollments VoiceEnrollment[]
|
||||
analysisJobs AnalysisJob[]
|
||||
spamFeedback SpamFeedback[]
|
||||
spamCallAnalyses SpamCallAnalysis[]
|
||||
spamAuditLogs SpamAuditLog[]
|
||||
normalizedAlerts NormalizedAlert[]
|
||||
correlationGroups CorrelationGroup[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
name String?
|
||||
image String?
|
||||
role UserRole @default(user)
|
||||
|
||||
// Relationships
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
familyGroups FamilyGroupMember[]
|
||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||
subscriptions Subscription[]
|
||||
alerts Alert[]
|
||||
voiceEnrollments VoiceEnrollment[]
|
||||
voiceAnalyses VoiceAnalysis[]
|
||||
spamFeedback SpamFeedback[]
|
||||
spamRules SpamRule[]
|
||||
normalizedAlerts NormalizedAlert[]
|
||||
correlationGroups CorrelationGroup[]
|
||||
|
||||
// Audit
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
model WatchListItem {
|
||||
id String @id @default(uuid())
|
||||
enum UserRole {
|
||||
user
|
||||
family_admin
|
||||
family_member
|
||||
support
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
provider String
|
||||
providerAccountId String
|
||||
access_token String?
|
||||
refresh_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, provider, providerAccountId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
sessionToken String @unique
|
||||
expires DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sessionToken])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Family & Subscription Models
|
||||
// ============================================
|
||||
|
||||
model FamilyGroup {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id])
|
||||
members FamilyGroupMember[]
|
||||
subscriptions Subscription[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model FamilyGroupMember {
|
||||
id String @id @default(uuid())
|
||||
groupId String
|
||||
userId String
|
||||
role FamilyMemberRole @default(member)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([groupId, userId])
|
||||
@@index([groupId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum FamilyMemberRole {
|
||||
owner
|
||||
admin
|
||||
member
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
identifierType IdentifierType
|
||||
identifierValue String
|
||||
identifierHash String @unique
|
||||
status WatchListStatus @default(ACTIVE)
|
||||
familyGroupId String?
|
||||
stripeId String? @unique
|
||||
tier SubscriptionTier @default(basic)
|
||||
status SubscriptionStatus @default(active)
|
||||
currentPeriodStart DateTime
|
||||
currentPeriodEnd DateTime
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id])
|
||||
|
||||
watchlistItems WatchlistItem[]
|
||||
exposures Exposure[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
alerts Alert[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([identifierHash])
|
||||
@@index([familyGroupId])
|
||||
@@index([stripeId])
|
||||
@@index([tier])
|
||||
}
|
||||
|
||||
enum SubscriptionTier {
|
||||
basic
|
||||
plus
|
||||
premium
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
active
|
||||
past_due
|
||||
canceled
|
||||
unpaid
|
||||
trialing
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DarkWatch Models (Dark Web Monitoring)
|
||||
// ============================================
|
||||
|
||||
model WatchlistItem {
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String
|
||||
type WatchlistType
|
||||
value String
|
||||
hash String // SHA-256 hash for deduplication
|
||||
isActive Boolean @default(true)
|
||||
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
exposures Exposure[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([subscriptionId, type, hash])
|
||||
@@index([subscriptionId])
|
||||
@@index([type])
|
||||
@@index([hash])
|
||||
}
|
||||
|
||||
enum WatchlistType {
|
||||
email
|
||||
phoneNumber
|
||||
ssn
|
||||
address
|
||||
domain
|
||||
}
|
||||
|
||||
model Exposure {
|
||||
id String @id @default(uuid())
|
||||
watchListItemId String
|
||||
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
|
||||
dataSource DataSource
|
||||
breachName String
|
||||
exposedAt DateTime
|
||||
dataType String[]
|
||||
severity Severity
|
||||
details String?
|
||||
contentHash String @unique
|
||||
alert Alert?
|
||||
subscriptionId String
|
||||
watchlistItemId String?
|
||||
source ExposureSource
|
||||
dataType WatchlistType
|
||||
identifier String
|
||||
identifierHash String
|
||||
severity ExposureSeverity @default(info)
|
||||
metadata Json? // Additional source-specific data
|
||||
isFirstTime Boolean @default(false)
|
||||
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
|
||||
alerts Alert[]
|
||||
|
||||
detectedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([watchListItemId])
|
||||
@@index([contentHash])
|
||||
@@index([dataSource])
|
||||
@@index([subscriptionId])
|
||||
@@index([watchlistItemId])
|
||||
@@index([source])
|
||||
@@index([severity])
|
||||
@@index([detectedAt])
|
||||
}
|
||||
|
||||
enum ExposureSource {
|
||||
hibp // Have I Been Pwned
|
||||
securityTrails
|
||||
censys
|
||||
darkWebForum
|
||||
shodan
|
||||
honeypot
|
||||
}
|
||||
|
||||
enum ExposureSeverity {
|
||||
info
|
||||
warning
|
||||
critical
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Notification & Alert Models
|
||||
// ============================================
|
||||
|
||||
model Alert {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exposureId String @unique
|
||||
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
|
||||
severity Severity
|
||||
channel AlertChannel
|
||||
status AlertStatus @default(PENDING)
|
||||
dedupKey String
|
||||
sentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([dedupKey])
|
||||
}
|
||||
|
||||
model ScanJob {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
status ScanJobStatus @default(PENDING)
|
||||
source DataSource?
|
||||
resultCount Int @default(0)
|
||||
errorMessage String?
|
||||
scheduledBy String?
|
||||
webhookEvents WebhookEvent[]
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
model ScanSchedule {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
intervalMinutes Int // minutes between scans
|
||||
cronExpression String // cron expression for scheduling
|
||||
status ScheduleStatus @default(ACTIVE)
|
||||
lastScanAt DateTime?
|
||||
nextScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
exposureId String?
|
||||
type AlertType
|
||||
title String
|
||||
message String
|
||||
severity AlertSeverity @default(info)
|
||||
isRead Boolean @default(false)
|
||||
readAt DateTime?
|
||||
channel AlertChannel[] // Array of notification channels
|
||||
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exposure Exposure? @relation(fields: [exposureId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
enum WebhookEventType {
|
||||
SCAN_TRIGGER
|
||||
BREACH_DETECTED
|
||||
SUBSCRIPTION_CHANGE
|
||||
}
|
||||
|
||||
model WebhookEvent {
|
||||
id String @id @default(uuid())
|
||||
eventType WebhookEventType
|
||||
payload String
|
||||
source String?
|
||||
signature String?
|
||||
processed Boolean @default(false)
|
||||
processedAt DateTime?
|
||||
scanJobId String?
|
||||
scanJob ScanJob? @relation(fields: [scanJobId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([eventType, processed])
|
||||
@@index([subscriptionId])
|
||||
@@index([userId])
|
||||
@@index([isRead])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
enum AlertType {
|
||||
exposure_detected
|
||||
exposure_resolved
|
||||
scan_complete
|
||||
subscription_changed
|
||||
system_warning
|
||||
}
|
||||
|
||||
enum AlertSeverity {
|
||||
info
|
||||
warning
|
||||
critical
|
||||
}
|
||||
|
||||
enum AlertChannel {
|
||||
email
|
||||
push
|
||||
sms
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VoicePrint Models (Voice Cloning Detection)
|
||||
// ============================================
|
||||
|
||||
model VoiceEnrollment {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
embeddingVector Float[]
|
||||
embeddingDim Int @default(192)
|
||||
audioFilePath String?
|
||||
sampleRate Int @default(16000)
|
||||
durationSec Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
name String
|
||||
voiceHash String // FAISS embedding hash
|
||||
audioMetadata Json? // Sample rate, duration, etc.
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
analyses VoiceAnalysis[]
|
||||
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([embeddingDim])
|
||||
@@index([voiceHash])
|
||||
}
|
||||
|
||||
model AnalysisJob {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
analysisType AnalysisType
|
||||
audioFilePath String
|
||||
status AnalysisJobStatus @default(PENDING)
|
||||
result AnalysisResult?
|
||||
errorMessage String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
model VoiceAnalysis {
|
||||
id String @id @default(uuid())
|
||||
enrollmentId String?
|
||||
userId String
|
||||
audioHash String // Content hash of audio file
|
||||
isSynthetic Boolean
|
||||
confidence Float // 0.0 to 1.0
|
||||
analysisResult Json // Full ML analysis results
|
||||
audioUrl String // S3 storage URL
|
||||
|
||||
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([createdAt])
|
||||
@@index([userId])
|
||||
@@index([enrollmentId])
|
||||
@@index([audioHash])
|
||||
}
|
||||
|
||||
model AnalysisResult {
|
||||
id String @id @default(uuid())
|
||||
analysisJobId String @unique
|
||||
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id], onDelete: Cascade)
|
||||
syntheticScore Float
|
||||
verdict DetectionVerdict
|
||||
matchedEnrollmentId String?
|
||||
matchedSimilarity Float?
|
||||
confidence Float
|
||||
processingTimeMs Int
|
||||
modelVersion String?
|
||||
metadata String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([analysisJobId])
|
||||
@@index([verdict])
|
||||
}
|
||||
|
||||
enum SpamDecision {
|
||||
BLOCK
|
||||
FLAG
|
||||
ALLOW
|
||||
}
|
||||
// ============================================
|
||||
// SpamShield Models (Spam Detection)
|
||||
// ============================================
|
||||
|
||||
model SpamFeedback {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String // AES-256 encrypted PII
|
||||
phoneNumberHash String // SHA-256 hash for anonymized lookup
|
||||
isSpam Boolean
|
||||
label String?
|
||||
metadata String? // Unbounded JSON
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
phoneNumber String
|
||||
phoneNumberHash String // SHA-256 hash
|
||||
isSpam Boolean
|
||||
confidence Float? // ML model confidence
|
||||
feedbackType FeedbackType
|
||||
metadata Json? // Call duration, time, etc.
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([phoneNumberHash])
|
||||
@@index([createdAt])
|
||||
@@index([isSpam])
|
||||
}
|
||||
|
||||
model SpamCallAnalysis {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String
|
||||
callTimestamp DateTime
|
||||
hiyaReputationScore Float?
|
||||
truecallerSpamScore Float?
|
||||
decision SpamDecision
|
||||
confidence Float
|
||||
ruleMatches String[] // IDs of matched rules
|
||||
auditLogs SpamAuditLog[]
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([phoneNumber])
|
||||
@@index([callTimestamp])
|
||||
enum FeedbackType {
|
||||
initial_detection
|
||||
user_confirmation
|
||||
user_rejection
|
||||
auto_learned
|
||||
}
|
||||
|
||||
model SpamRule {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
pattern String @db.VarChar(500) // Regex pattern - validated for ReDoS at application layer
|
||||
decision SpamDecision
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
@@index([priority])
|
||||
}
|
||||
|
||||
model SpamAuditLog {
|
||||
id String @id @default(uuid())
|
||||
analysisId String?
|
||||
analysis SpamCallAnalysis? @relation(fields: [analysisId], references: [id], onDelete: SetNull)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String
|
||||
decision SpamDecision
|
||||
reason String
|
||||
ruleId String?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
isGlobal Boolean @default(false)
|
||||
ruleType RuleType
|
||||
pattern String
|
||||
action RuleAction
|
||||
priority Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@index([decision])
|
||||
@@index([isGlobal])
|
||||
@@index([ruleType])
|
||||
}
|
||||
|
||||
enum RuleType {
|
||||
phoneNumber
|
||||
areaCode
|
||||
prefix
|
||||
pattern
|
||||
reputation
|
||||
}
|
||||
|
||||
enum RuleAction {
|
||||
block
|
||||
flag
|
||||
allow
|
||||
challenge
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Audit & Analytics Models
|
||||
// ============================================
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
action String
|
||||
resource String
|
||||
resourceId String?
|
||||
changes Json? // Before/after values
|
||||
metadata Json?
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([action])
|
||||
@@index([resource])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model KPISnapshot {
|
||||
id String @id @default(uuid())
|
||||
date DateTime @unique
|
||||
metricName String
|
||||
metricValue Float
|
||||
metadata Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([metricName])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Cross-Service Alert Correlation Models
|
||||
// ============================================
|
||||
|
||||
enum AlertSource {
|
||||
DARKWATCH
|
||||
SPAMSHIELD
|
||||
@@ -351,62 +455,69 @@ enum AlertCategory {
|
||||
SPAM_SMS
|
||||
SYNTHETIC_VOICE
|
||||
VOICE_MISMATCH
|
||||
CALL_QUALITY
|
||||
CALL_ANOMALY
|
||||
CALL_QUALITY
|
||||
CALL_EVENT
|
||||
}
|
||||
|
||||
enum NormalizedAlertSeverity {
|
||||
LOW
|
||||
INFO
|
||||
MEDIUM
|
||||
WARNING
|
||||
HIGH
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum CorrelationStatus {
|
||||
ACTIVE
|
||||
RESOLVED
|
||||
FALSE_POSITIVE
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
PHONE_NUMBER
|
||||
EMAIL
|
||||
USER_ID
|
||||
CALL_ID
|
||||
IP_ADDRESS
|
||||
}
|
||||
|
||||
model NormalizedAlert {
|
||||
id String @id @default(uuid())
|
||||
source AlertSource
|
||||
category AlertCategory
|
||||
severity Severity
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
description String
|
||||
entities Json // [{ type: EntityType, value: string }]
|
||||
sourceAlertId String
|
||||
groupId String?
|
||||
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
payload Json
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
source AlertSource
|
||||
category AlertCategory
|
||||
severity NormalizedAlertSeverity
|
||||
userId String
|
||||
title String
|
||||
description String
|
||||
entities Json
|
||||
sourceAlertId String
|
||||
groupId String?
|
||||
payload Json?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([userId, createdAt])
|
||||
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sourceAlertId])
|
||||
@@index([userId])
|
||||
@@index([groupId])
|
||||
@@index([sourceAlertId])
|
||||
@@index([source])
|
||||
@@index([severity])
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model CorrelationGroup {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "corr_user_idx")
|
||||
entities Json // [{ type: EntityType, value: string }]
|
||||
highestSeverity Severity
|
||||
status CorrelationStatus @default(ACTIVE)
|
||||
alertCount Int @default(0)
|
||||
alerts NormalizedAlert[]
|
||||
entities Json
|
||||
highestSeverity NormalizedAlertSeverity
|
||||
status CorrelationStatus @default(ACTIVE)
|
||||
alertCount Int @default(0)
|
||||
summary String?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
alerts NormalizedAlert[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([userId, status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ async function main() {
|
||||
create: {
|
||||
email: "dev@shieldai.local",
|
||||
name: "Dev User",
|
||||
subscriptionTier: "PREMIUM",
|
||||
role: "user",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,71 @@
|
||||
// ============================================
|
||||
// Consolidated @shieldai/db package
|
||||
// ============================================
|
||||
// Merges functionality from:
|
||||
// - @shieldai/db (Prisma v6.2.0, FieldEncryptionService)
|
||||
// - @shieldsai/shared-db (singleton pattern, type exports)
|
||||
// ============================================
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { FieldEncryptionService } from './services/field-encryption.service';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
// ============================================
|
||||
// Singleton Pattern (from shared-db)
|
||||
// ============================================
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
|
||||
// ============================================
|
||||
// Services (from @shieldai/db)
|
||||
// ============================================
|
||||
export { FieldEncryptionService };
|
||||
|
||||
// ============================================
|
||||
// Type Exports (from shared-db)
|
||||
// ============================================
|
||||
export type {
|
||||
User,
|
||||
Account,
|
||||
Session,
|
||||
FamilyGroup,
|
||||
FamilyGroupMember,
|
||||
Subscription,
|
||||
WatchlistItem,
|
||||
Exposure,
|
||||
Alert,
|
||||
VoiceEnrollment,
|
||||
VoiceAnalysis,
|
||||
SpamFeedback,
|
||||
SpamRule,
|
||||
AuditLog,
|
||||
KPISnapshot,
|
||||
UserRole,
|
||||
FamilyMemberRole,
|
||||
SubscriptionTier,
|
||||
SubscriptionStatus,
|
||||
WatchlistType,
|
||||
ExposureSource,
|
||||
ExposureSeverity,
|
||||
AlertType,
|
||||
AlertSeverity,
|
||||
AlertChannel,
|
||||
FeedbackType,
|
||||
RuleType,
|
||||
RuleAction,
|
||||
} from '@prisma/client';
|
||||
|
||||
export * as PrismaModels from '@prisma/client';
|
||||
export type { PrismaClient };
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ENCRYPTION_KEY = process.env.PII_ENCRYPTION_KEY || 'default-32-byte-key-for-aes-256';
|
||||
if (!process.env.PII_ENCRYPTION_KEY) {
|
||||
throw new Error("PII_ENCRYPTION_KEY environment variable is required — set it before starting the server");
|
||||
}
|
||||
const ENCRYPTION_KEY = process.env.PII_ENCRYPTION_KEY;
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
export class FieldEncryptionService {
|
||||
|
||||
131
packages/integration-tests/README.md
Normal file
131
packages/integration-tests/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Notification Service Integration Tests
|
||||
|
||||
This directory contains integration tests for all notification services in the ShieldAI system.
|
||||
|
||||
## Test Files
|
||||
|
||||
### Individual Service Tests
|
||||
|
||||
- `email.service.integration.test.ts` - Integration tests for EmailService (Resend)
|
||||
- `sms.service.integration.test.ts` - Integration tests for SMSService (Twilio)
|
||||
- `push.service.integration.test.ts` - Integration tests for PushService (FCM/APNs)
|
||||
|
||||
### Orchestration Tests
|
||||
|
||||
- `notification.service.integration.test.ts` - Integration tests for NotificationService
|
||||
- Tests rate limiting across all channels
|
||||
- Tests deduplication logic
|
||||
- Tests user preferences
|
||||
- Tests template-based notifications
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
- `notifications.integration.test.ts` - Basic E2E tests for notification flow
|
||||
- `notifications.benchmark.ts` - Performance benchmarks
|
||||
|
||||
## External Provider Mocks
|
||||
|
||||
All external provider API calls are mocked:
|
||||
|
||||
- **Resend (Email)**: Mocked via `vi.mock('resend')`
|
||||
- **Twilio (SMS)**: Mocked via `vi.mock('twilio')`
|
||||
- **Firebase Admin (Push)**: Mocked via `vi.mock('firebase-admin')`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Email Service
|
||||
- ✅ Email validation
|
||||
- ✅ Rate limiting per user
|
||||
- ✅ Template-based sending
|
||||
- ✅ Batch sending
|
||||
- ✅ Attachment handling
|
||||
- ✅ Metadata handling
|
||||
- ✅ Error handling (API errors, network timeouts, invalid emails)
|
||||
|
||||
### SMS Service
|
||||
- ✅ Phone number validation
|
||||
- ✅ Rate limiting per user
|
||||
- ✅ Batch sending
|
||||
- ✅ Metadata handling
|
||||
- ✅ Error handling (API errors, network timeouts, invalid numbers)
|
||||
|
||||
### Push Service
|
||||
- ✅ FCM notification sending
|
||||
- ✅ APNs configuration
|
||||
- ✅ Data payload handling
|
||||
- ✅ Badge/sound/category settings
|
||||
- ✅ Rate limiting per user
|
||||
- ✅ Batch sending
|
||||
- ✅ Error handling
|
||||
|
||||
### Notification Service
|
||||
- ✅ Multi-channel routing
|
||||
- ✅ Deduplication logic
|
||||
- ✅ User preferences
|
||||
- ✅ Rate limiting
|
||||
- ✅ Template resolution
|
||||
- ✅ Error handling and retry logic
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all integration tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run specific test file
|
||||
npm test -- email.service.integration.test.ts
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests are configured to run in CI with the following setup:
|
||||
|
||||
1. Environment variables must be set for all providers
|
||||
2. Redis must be available for rate limiting and deduplication
|
||||
3. Tests use mocked external APIs for reliability
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Resend (Email)
|
||||
RESEND_API_KEY=your_api_key
|
||||
|
||||
# Twilio (SMS)
|
||||
TWILIO_ACCOUNT_SID=your_account_sid
|
||||
TWILIO_AUTH_TOKEN=your_auth_token
|
||||
TWILIO_MESSAGING_SERVICE_SID=your_service_sid
|
||||
|
||||
# Firebase (Push)
|
||||
FCM_PRIVATE_KEY=your_private_key
|
||||
FCM_PROJECT_ID=your_project_id
|
||||
FCM_CLIENT_EMAIL=your_client_email
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
DEDUP_WINDOW_SECONDS=300
|
||||
|
||||
# Rate Limits
|
||||
EMAIL_RATE_LIMIT=60
|
||||
SMS_RATE_LIMIT=30
|
||||
PUSH_RATE_LIMIT=100
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
```
|
||||
|
||||
## Test Strategy
|
||||
|
||||
1. **Unit Tests**: Test individual service methods with mocked dependencies
|
||||
2. **Integration Tests**: Test service interactions and external API mocks
|
||||
3. **E2E Tests**: Test complete notification flows
|
||||
4. **Benchmark Tests**: Measure performance under load
|
||||
|
||||
## Error Scenarios Tested
|
||||
|
||||
- Network timeouts
|
||||
- API rate limits
|
||||
- Invalid input validation
|
||||
- Missing configuration
|
||||
- Provider authentication failures
|
||||
- Partial batch failures
|
||||
@@ -16,6 +16,7 @@
|
||||
"@shieldai/shared-notifications": "workspace:*",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from '@jest/globals';
|
||||
import { EmailService } from '@shieldai/shared-notifications';
|
||||
import type { EmailNotification } from '@shieldai/shared-notifications';
|
||||
|
||||
// Mock Resend
|
||||
vi.mock('resend', () => {
|
||||
return {
|
||||
Resend: vi.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
data: { id: 'resend-mock-123' },
|
||||
error: undefined,
|
||||
}),
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('EmailService Integration Tests', () => {
|
||||
let emailService: EmailService;
|
||||
let mockResend: any;
|
||||
|
||||
beforeAll(() => {
|
||||
emailService = EmailService.getInstance();
|
||||
mockResend = (require('resend').Resend as any).mock.instances[0];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should successfully send email notification', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'test-email-123' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
htmlBody: '<h1>Test</h1>',
|
||||
textBody: 'Test',
|
||||
};
|
||||
|
||||
const result = await emailService.send(notification);
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.channel).toBe('email');
|
||||
expect(result.externalId).toBe('test-email-123');
|
||||
expect(result.notificationId).toContain('email-');
|
||||
expect(result.deliveredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should handle invalid email format', async () => {
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'invalid-email',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const result = await emailService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toContain('Invalid email format');
|
||||
});
|
||||
|
||||
it('should handle Resend API error', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'error-email-456' },
|
||||
error: { message: 'API rate limit exceeded' },
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const result = await emailService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('API rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should handle network error', async () => {
|
||||
mockResend.emails.send.mockRejectedValueOnce(
|
||||
new Error('Network timeout')
|
||||
);
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const result = await emailService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('Network timeout');
|
||||
});
|
||||
|
||||
it('should include metadata in email', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'meta-email-789' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
metadata: { userId: 'user-123', campaign: 'welcome' },
|
||||
};
|
||||
|
||||
await emailService.send(notification);
|
||||
|
||||
expect(mockResend.emails.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { userId: 'user-123', campaign: 'welcome' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include attachments in email', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'attach-email-101' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'report.pdf',
|
||||
content: Buffer.from('PDF content'),
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await emailService.send(notification);
|
||||
|
||||
expect(mockResend.emails.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filename: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default from address when not provided', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'default-from-202' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
await emailService.send(notification);
|
||||
|
||||
expect(mockResend.emails.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: 'ShieldAI <noreply@shieldai.com>',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom from address when provided', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'custom-from-303' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
from: 'custom@shieldai.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
await emailService.send(notification);
|
||||
|
||||
expect(mockResend.emails.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: 'custom@shieldai.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle both html and text body', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'both-body-404' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<h1>HTML</h1>',
|
||||
textBody: 'Plain text',
|
||||
};
|
||||
|
||||
await emailService.send(notification);
|
||||
|
||||
expect(mockResend.emails.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
html: '<h1>HTML</h1>',
|
||||
text: 'Plain text',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should enforce email rate limiting', async () => {
|
||||
// Set rate limit to 2 for testing
|
||||
process.env.EMAIL_RATE_LIMIT = '2';
|
||||
|
||||
// Clear the service instance to pick up new config
|
||||
vi.clearAllMocks();
|
||||
emailService = EmailService.getInstance();
|
||||
|
||||
const notification: EmailNotification = {
|
||||
channel: 'email',
|
||||
to: 'rate-test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
// First two should succeed
|
||||
const result1 = await emailService.send(notification);
|
||||
const result2 = await emailService.send(notification);
|
||||
|
||||
expect(result1.status).toBe('sent');
|
||||
expect(result2.status).toBe('sent');
|
||||
|
||||
// Third should throw due to rate limit
|
||||
await expect(emailService.send(notification)).rejects.toThrow(
|
||||
'Email rate limit exceeded'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWithTemplate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
emailService = EmailService.getInstance();
|
||||
});
|
||||
|
||||
it('should send email with resolved template', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'template-email-505' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const result = await emailService.sendWithTemplate('test@example.com', {
|
||||
templateId: 'welcome-email',
|
||||
locale: 'en',
|
||||
variables: { name: 'John' },
|
||||
});
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.channel).toBe('email');
|
||||
});
|
||||
|
||||
it('should handle missing template', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'missing-template-606' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const result = await emailService.sendWithTemplate('test@example.com', {
|
||||
templateId: 'non-existent-template',
|
||||
locale: 'en',
|
||||
variables: {},
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toContain('Template not found');
|
||||
});
|
||||
|
||||
it('should handle template channel mismatch', async () => {
|
||||
mockResend.emails.send.mockResolvedValueOnce({
|
||||
data: { id: 'channel-mismatch-707' },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const result = await emailService.sendWithTemplate('test@example.com', {
|
||||
templateId: 'sms-template',
|
||||
locale: 'en',
|
||||
variables: {},
|
||||
channel: 'email',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toContain('is for channel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
emailService = EmailService.getInstance();
|
||||
});
|
||||
|
||||
it('should send multiple emails successfully', async () => {
|
||||
mockResend.emails.send
|
||||
.mockResolvedValueOnce({ data: { id: 'batch-1' }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { id: 'batch-2' }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { id: 'batch-3' }, error: undefined });
|
||||
|
||||
const notifications: EmailNotification[] = [
|
||||
{
|
||||
channel: 'email',
|
||||
to: 'user1@example.com',
|
||||
subject: 'Batch 1',
|
||||
htmlBody: '<p>Test 1</p>',
|
||||
},
|
||||
{
|
||||
channel: 'email',
|
||||
to: 'user2@example.com',
|
||||
subject: 'Batch 2',
|
||||
htmlBody: '<p>Test 2</p>',
|
||||
},
|
||||
{
|
||||
channel: 'email',
|
||||
to: 'user3@example.com',
|
||||
subject: 'Batch 3',
|
||||
htmlBody: '<p>Test 3</p>',
|
||||
},
|
||||
];
|
||||
|
||||
const results = await emailService.sendBatch(notifications);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every(r => r.status === 'sent')).toBe(true);
|
||||
expect(results.map(r => r.externalId)).toEqual(['batch-1', 'batch-2', 'batch-3']);
|
||||
});
|
||||
|
||||
it('should handle partial failures in batch', async () => {
|
||||
mockResend.emails.send
|
||||
.mockResolvedValueOnce({ data: { id: 'partial-1' }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { id: 'partial-2' }, error: undefined });
|
||||
|
||||
const notifications: EmailNotification[] = [
|
||||
{
|
||||
channel: 'email',
|
||||
to: 'valid@example.com',
|
||||
subject: 'Valid',
|
||||
htmlBody: '<p>Valid</p>',
|
||||
},
|
||||
{
|
||||
channel: 'email',
|
||||
to: 'invalid-email',
|
||||
subject: 'Invalid',
|
||||
htmlBody: '<p>Invalid</p>',
|
||||
},
|
||||
];
|
||||
|
||||
const results = await emailService.sendBatch(notifications);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].status).toBe('sent');
|
||||
expect(results[1].status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRateLimitStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
emailService = EmailService.getInstance();
|
||||
});
|
||||
|
||||
it('should return rate limit status', () => {
|
||||
const status = emailService.getRateLimitStatus();
|
||||
|
||||
expect(status).toHaveProperty('remaining');
|
||||
expect(status).toHaveProperty('limit');
|
||||
expect(status.limit).toBeGreaterThan(0);
|
||||
expect(status.remaining).toBeLessThanOrEqual(status.limit);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from '@jest/globals';
|
||||
import { NotificationService } from '@shieldai/shared-notifications';
|
||||
import { EmailService } from '@shieldai/shared-notifications';
|
||||
import { SMSService } from '@shieldai/shared-notifications';
|
||||
import { PushService } from '@shieldai/shared-notifications';
|
||||
import type { Notification, DeduplicationKey } from '@shieldai/shared-notifications';
|
||||
|
||||
// Mock individual services
|
||||
vi.mock('@shieldai/shared-notifications', async () => {
|
||||
const actual = await vi.importActual('@shieldai/shared-notifications');
|
||||
|
||||
return {
|
||||
...(actual as object),
|
||||
EmailService: {
|
||||
getInstance: vi.fn(() => ({
|
||||
send: vi.fn(async (notification: any) => ({
|
||||
notificationId: `email-${Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'sent',
|
||||
externalId: 'resend-mock-id',
|
||||
})),
|
||||
})),
|
||||
},
|
||||
SMSService: {
|
||||
getInstance: vi.fn(() => ({
|
||||
send: vi.fn(async (notification: any) => ({
|
||||
notificationId: `sms-${Date.now()}`,
|
||||
channel: 'sms',
|
||||
status: 'sent',
|
||||
externalId: 'twilio-mock-id',
|
||||
})),
|
||||
})),
|
||||
},
|
||||
PushService: {
|
||||
getInstance: vi.fn(() => ({
|
||||
send: vi.fn(async (notification: any) => ({
|
||||
notificationId: `push-${Date.now()}`,
|
||||
channel: 'push',
|
||||
status: 'sent',
|
||||
externalId: 'fcm-mock-id',
|
||||
})),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('NotificationService Integration Tests', () => {
|
||||
let notificationService: NotificationService;
|
||||
|
||||
beforeAll(() => {
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should send email notification', async () => {
|
||||
const notification: Notification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const result = await notificationService.send(notification);
|
||||
|
||||
expect(result.channel).toBe('email');
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.notificationId).toContain('email-');
|
||||
});
|
||||
|
||||
it('should send SMS notification', async () => {
|
||||
const notification: Notification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test SMS',
|
||||
};
|
||||
|
||||
const result = await notificationService.send(notification);
|
||||
|
||||
expect(result.channel).toBe('sms');
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.notificationId).toContain('sms-');
|
||||
});
|
||||
|
||||
it('should send push notification', async () => {
|
||||
const notification: Notification = {
|
||||
channel: 'push',
|
||||
userId: 'user-device-token',
|
||||
title: 'Test Title',
|
||||
body: 'Test Body',
|
||||
};
|
||||
|
||||
const result = await notificationService.send(notification);
|
||||
|
||||
expect(result.channel).toBe('push');
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.notificationId).toContain('push-');
|
||||
});
|
||||
|
||||
it('should throw for unknown channel', async () => {
|
||||
const notification = {
|
||||
channel: 'unknown' as any,
|
||||
to: 'test@example.com',
|
||||
} as Notification;
|
||||
|
||||
await expect(notificationService.send(notification)).rejects.toThrow(
|
||||
'Unknown notification channel'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWithDeduplication', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should allow first notification', async () => {
|
||||
const notification: Notification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const dedupKey: DeduplicationKey = {
|
||||
userId: 'user-123',
|
||||
templateId: 'welcome-email',
|
||||
key: 'initial',
|
||||
};
|
||||
|
||||
const result = await notificationService.sendWithDeduplication(
|
||||
notification,
|
||||
dedupKey
|
||||
);
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
});
|
||||
|
||||
it('should mark duplicate as pending', async () => {
|
||||
const notification: Notification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const dedupKey: DeduplicationKey = {
|
||||
userId: 'user-456',
|
||||
templateId: 'alert-email',
|
||||
key: 'same-key',
|
||||
};
|
||||
|
||||
// First call
|
||||
await notificationService.sendWithDeduplication(notification, dedupKey);
|
||||
|
||||
// Second call with same key - should be pending
|
||||
const result = await notificationService.sendWithDeduplication(
|
||||
{ ...notification, subject: 'Updated' },
|
||||
dedupKey
|
||||
);
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.error).toContain('Duplicate notification');
|
||||
});
|
||||
|
||||
it('should use custom deduplication window', async () => {
|
||||
const notification: Notification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const dedupKey: DeduplicationKey = {
|
||||
userId: 'user-789',
|
||||
templateId: 'sms-template',
|
||||
key: 'custom-window',
|
||||
windowSeconds: 60,
|
||||
};
|
||||
|
||||
const result = await notificationService.sendWithDeduplication(
|
||||
notification,
|
||||
dedupKey
|
||||
);
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPreference and getPreference', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should set notification preference', async () => {
|
||||
const userId = 'user-pref-123';
|
||||
const channel = 'email' as const;
|
||||
const enabled = true;
|
||||
const categories = ['alerts', 'updates'];
|
||||
|
||||
const preference = await notificationService.setPreference(
|
||||
userId,
|
||||
channel,
|
||||
enabled,
|
||||
categories
|
||||
);
|
||||
|
||||
expect(preference.userId).toBe(userId);
|
||||
expect(preference.channel).toBe(channel);
|
||||
expect(preference.enabled).toBe(enabled);
|
||||
expect(preference.categories).toEqual(categories);
|
||||
});
|
||||
|
||||
it('should get notification preference', async () => {
|
||||
const userId = 'user-pref-456';
|
||||
const channel = 'push' as const;
|
||||
|
||||
await notificationService.setPreference(userId, channel, true, ['notifications']);
|
||||
|
||||
const preference = await notificationService.getPreference(userId, channel);
|
||||
|
||||
expect(preference).not.toBeNull();
|
||||
expect(preference?.userId).toBe(userId);
|
||||
expect(preference?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null for non-existent preference', async () => {
|
||||
const preference = await notificationService.getPreference(
|
||||
'non-existent-user',
|
||||
'email'
|
||||
);
|
||||
|
||||
expect(preference).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should allow send when no preference set', async () => {
|
||||
const result = await notificationService.shouldSend(
|
||||
'new-user',
|
||||
'email',
|
||||
'alerts'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow send when preference enabled', async () => {
|
||||
await notificationService.setPreference('enabled-user', 'sms', true, ['marketing']);
|
||||
|
||||
const result = await notificationService.shouldSend(
|
||||
'enabled-user',
|
||||
'sms',
|
||||
'marketing'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should block send when preference disabled', async () => {
|
||||
await notificationService.setPreference('disabled-user', 'push', false, ['alerts']);
|
||||
|
||||
const result = await notificationService.shouldSend(
|
||||
'disabled-user',
|
||||
'push',
|
||||
'alerts'
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should block send when category not in allowed list', async () => {
|
||||
await notificationService.setPreference(
|
||||
'category-user',
|
||||
'email',
|
||||
true,
|
||||
['alerts', 'updates']
|
||||
);
|
||||
|
||||
const result = await notificationService.shouldSend(
|
||||
'category-user',
|
||||
'email',
|
||||
'marketing'
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow send when categories list is empty', async () => {
|
||||
await notificationService.setPreference('empty-cats-user', 'sms', true, []);
|
||||
|
||||
const result = await notificationService.shouldSend(
|
||||
'empty-cats-user',
|
||||
'sms',
|
||||
'any-category'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWithPreferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should send when preference allows', async () => {
|
||||
await notificationService.setPreference(
|
||||
'pref-user-1',
|
||||
'email',
|
||||
true,
|
||||
['alerts']
|
||||
);
|
||||
|
||||
const notification: Notification = {
|
||||
channel: 'email',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
htmlBody: '<p>Test</p>',
|
||||
};
|
||||
|
||||
const result = await notificationService.sendWithPreferences(
|
||||
notification,
|
||||
'alerts'
|
||||
);
|
||||
|
||||
expect(result?.status).toBe('sent');
|
||||
});
|
||||
|
||||
it('should return pending when preference disabled', async () => {
|
||||
await notificationService.setPreference(
|
||||
'pref-user-2',
|
||||
'sms',
|
||||
false,
|
||||
['marketing']
|
||||
);
|
||||
|
||||
const notification: Notification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const result = await notificationService.sendWithPreferences(
|
||||
notification,
|
||||
'marketing'
|
||||
);
|
||||
|
||||
expect(result?.status).toBe('pending');
|
||||
expect(result?.error).toContain('Notification disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should allow within rate limit', async () => {
|
||||
const result = await notificationService.checkRateLimit(
|
||||
'rate-user-1',
|
||||
'email'
|
||||
);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.limit).toBeGreaterThan(0);
|
||||
expect(result.remaining).toBeLessThan(result.limit);
|
||||
});
|
||||
|
||||
it('should track multiple identifiers independently', async () => {
|
||||
await notificationService.checkRateLimit('rate-user-2a', 'email');
|
||||
await notificationService.checkRateLimit('rate-user-2b', 'email');
|
||||
|
||||
const resultA = await notificationService.checkRateLimit('rate-user-2a', 'email');
|
||||
const resultB = await notificationService.checkRateLimit('rate-user-2b', 'email');
|
||||
|
||||
expect(resultA.currentCount).toBe(2);
|
||||
expect(resultB.currentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should track different channels independently', async () => {
|
||||
await notificationService.checkRateLimit('rate-user-3', 'email');
|
||||
await notificationService.checkRateLimit('rate-user-3', 'sms');
|
||||
|
||||
const emailResult = await notificationService.checkRateLimit('rate-user-3', 'email');
|
||||
const smsResult = await notificationService.checkRateLimit('rate-user-3', 'sms');
|
||||
|
||||
expect(emailResult.currentCount).toBe(2);
|
||||
expect(smsResult.currentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should use custom limit', async () => {
|
||||
const result = await notificationService.checkRateLimit(
|
||||
'rate-user-4',
|
||||
'email',
|
||||
5
|
||||
);
|
||||
|
||||
expect(result.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('should use custom window', async () => {
|
||||
const result = await notificationService.checkRateLimit(
|
||||
'rate-user-5',
|
||||
'email',
|
||||
10,
|
||||
120
|
||||
);
|
||||
|
||||
expect(result.resetInSeconds).toBeLessThanOrEqual(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplicateNotification', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should return true for first notification', async () => {
|
||||
const wasSet = await notificationService.deduplicateNotification({
|
||||
userId: 'dedup-user-1',
|
||||
templateId: 'test-template',
|
||||
key: 'unique-key',
|
||||
});
|
||||
|
||||
expect(wasSet).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for duplicate', async () => {
|
||||
await notificationService.deduplicateNotification({
|
||||
userId: 'dedup-user-2',
|
||||
templateId: 'test-template',
|
||||
key: 'duplicate-key',
|
||||
});
|
||||
|
||||
const wasSet = await notificationService.deduplicateNotification({
|
||||
userId: 'dedup-user-2',
|
||||
templateId: 'test-template',
|
||||
key: 'duplicate-key',
|
||||
});
|
||||
|
||||
expect(wasSet).toBe(false);
|
||||
});
|
||||
|
||||
it('should use custom window', async () => {
|
||||
const wasSet = await notificationService.deduplicateNotification(
|
||||
{
|
||||
userId: 'dedup-user-3',
|
||||
templateId: 'test-template',
|
||||
key: 'custom-window-key',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
expect(wasSet).toBe(true);
|
||||
});
|
||||
|
||||
it('should use windowSeconds from key', async () => {
|
||||
const wasSet = await notificationService.deduplicateNotification({
|
||||
userId: 'dedup-user-4',
|
||||
templateId: 'test-template',
|
||||
key: 'key-window',
|
||||
windowSeconds: 120,
|
||||
});
|
||||
|
||||
expect(wasSet).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRateLimitConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should return rate limit configuration', () => {
|
||||
const config = notificationService.getRateLimitConfig();
|
||||
|
||||
expect(config).toHaveProperty('emailPerMinute');
|
||||
expect(config).toHaveProperty('smsPerMinute');
|
||||
expect(config).toHaveProperty('pushPerMinute');
|
||||
expect(config).toHaveProperty('windowSeconds');
|
||||
expect(config.emailPerMinute).toBeGreaterThan(0);
|
||||
expect(config.smsPerMinute).toBeGreaterThan(0);
|
||||
expect(config.pushPerMinute).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplateService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
notificationService = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('should return template service instance', () => {
|
||||
const templateService = notificationService.getTemplateService();
|
||||
|
||||
expect(templateService).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from '@jest/globals';
|
||||
import { PushService } from '@shieldai/shared-notifications';
|
||||
import type { PushNotification } from '@shieldai/shared-notifications';
|
||||
import * as admin from 'firebase-admin';
|
||||
|
||||
// Mock firebase-admin
|
||||
vi.mock('firebase-admin', () => {
|
||||
const mockMessaging = {
|
||||
send: vi.fn().mockResolvedValue('push-token-123'),
|
||||
};
|
||||
|
||||
const mockCredential = {
|
||||
cert: vi.fn().mockReturnValue({
|
||||
projectId: 'test-project',
|
||||
clientEmail: 'test@test-project.iam.gserviceaccount.com',
|
||||
privateKey: 'test-key',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
options: {},
|
||||
};
|
||||
|
||||
return {
|
||||
default: {
|
||||
initializeApp: vi.fn().mockReturnValue(mockApp),
|
||||
credential: {
|
||||
cert: mockCredential,
|
||||
},
|
||||
messaging: vi.fn().mockReturnValue(mockMessaging),
|
||||
},
|
||||
messaging: vi.fn().mockReturnValue(mockMessaging),
|
||||
app: {
|
||||
App: Object,
|
||||
},
|
||||
credential: {
|
||||
cert: mockCredential,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('PushService Integration Tests', () => {
|
||||
let pushService: PushService;
|
||||
let mockMessaging: any;
|
||||
|
||||
beforeAll(() => {
|
||||
pushService = PushService.getInstance();
|
||||
mockMessaging = (require('firebase-admin').messaging as any).mock.instances[0];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should successfully send push notification', async () => {
|
||||
const mockResponse = 'fcm-message-id-123';
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'user-device-token-123',
|
||||
title: 'Test Title',
|
||||
body: 'Test Body',
|
||||
};
|
||||
|
||||
const result = await pushService.send(notification);
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.channel).toBe('push');
|
||||
expect(result.externalId).toBe(mockResponse);
|
||||
expect(result.notificationId).toContain('push-');
|
||||
expect(result.deliveredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should include notification data', async () => {
|
||||
const mockResponse = 'data-push-456';
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'user-device-token-456',
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
data: { key1: 'value1', key2: 'value2' },
|
||||
};
|
||||
|
||||
await pushService.send(notification);
|
||||
|
||||
expect((require('firebase-admin').messaging as any).mock.instances[0].send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include badge and sound settings', async () => {
|
||||
const mockResponse = 'apns-push-789';
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'user-device-token-789',
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
badge: 5,
|
||||
sound: 'custom-sound.caf',
|
||||
category: 'ALERT_CATEGORY',
|
||||
};
|
||||
|
||||
await pushService.send(notification);
|
||||
|
||||
expect((require('firebase-admin').messaging as any).mock.instances[0].send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apns: expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
aps: expect.objectContaining({
|
||||
badge: 5,
|
||||
sound: 'custom-sound.caf',
|
||||
category: 'ALERT_CATEGORY',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined data gracefully', async () => {
|
||||
const mockResponse = 'no-data-push-101';
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'user-device-token-101',
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
await pushService.send(notification);
|
||||
|
||||
expect((require('firebase-admin').messaging as any).mock.instances[0].send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle FCM API error', async () => {
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockRejectedValueOnce(
|
||||
new Error('Invalid registration token')
|
||||
);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'invalid-token',
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const result = await pushService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('Invalid registration token');
|
||||
});
|
||||
|
||||
it('should enforce push rate limiting', async () => {
|
||||
process.env.PUSH_RATE_LIMIT = '2';
|
||||
vi.clearAllMocks();
|
||||
pushService = PushService.getInstance();
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'rate-test-user',
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
// First two should succeed
|
||||
const result1 = await pushService.send(notification);
|
||||
const result2 = await pushService.send(notification);
|
||||
|
||||
expect(result1.status).toBe('sent');
|
||||
expect(result2.status).toBe('sent');
|
||||
|
||||
// Third should throw due to rate limit
|
||||
await expect(pushService.send(notification)).rejects.toThrow(
|
||||
'Push rate limit exceeded'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle network timeout', async () => {
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockRejectedValueOnce(
|
||||
new Error('Network timeout')
|
||||
);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'timeout-user',
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const result = await pushService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('Network timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pushService = PushService.getInstance();
|
||||
});
|
||||
|
||||
it('should send multiple push notifications successfully', async () => {
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send
|
||||
.mockResolvedValueOnce('batch-push-1')
|
||||
.mockResolvedValueOnce('batch-push-2')
|
||||
.mockResolvedValueOnce('batch-push-3');
|
||||
|
||||
const notifications: PushNotification[] = [
|
||||
{
|
||||
channel: 'push',
|
||||
userId: 'user-token-1',
|
||||
title: 'Batch 1',
|
||||
body: 'Test 1',
|
||||
},
|
||||
{
|
||||
channel: 'push',
|
||||
userId: 'user-token-2',
|
||||
title: 'Batch 2',
|
||||
body: 'Test 2',
|
||||
},
|
||||
{
|
||||
channel: 'push',
|
||||
userId: 'user-token-3',
|
||||
title: 'Batch 3',
|
||||
body: 'Test 3',
|
||||
},
|
||||
];
|
||||
|
||||
const results = await pushService.sendBatch(notifications);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every(r => r.status === 'sent')).toBe(true);
|
||||
expect(results.map(r => r.externalId)).toEqual(['batch-push-1', 'batch-push-2', 'batch-push-3']);
|
||||
});
|
||||
|
||||
it('should handle partial failures in batch', async () => {
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send
|
||||
.mockResolvedValueOnce('partial-push-1')
|
||||
.mockRejectedValueOnce(new Error('Invalid token'));
|
||||
|
||||
const notifications: PushNotification[] = [
|
||||
{
|
||||
channel: 'push',
|
||||
userId: 'valid-token',
|
||||
title: 'Valid',
|
||||
body: 'Valid',
|
||||
},
|
||||
{
|
||||
channel: 'push',
|
||||
userId: 'invalid-token',
|
||||
title: 'Invalid',
|
||||
body: 'Invalid',
|
||||
},
|
||||
];
|
||||
|
||||
const results = await pushService.sendBatch(notifications);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].status).toBe('sent');
|
||||
expect(results[1].status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRateLimitStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pushService = PushService.getInstance();
|
||||
});
|
||||
|
||||
it('should return rate limit status', () => {
|
||||
const status = pushService.getRateLimitStatus();
|
||||
|
||||
expect(status).toHaveProperty('remaining');
|
||||
expect(status).toHaveProperty('limit');
|
||||
expect(status.limit).toBeGreaterThan(0);
|
||||
expect(status.remaining).toBeLessThanOrEqual(status.limit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('APNs configuration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pushService = PushService.getInstance();
|
||||
});
|
||||
|
||||
it('should configure APNs payload correctly', async () => {
|
||||
const mockResponse = 'apns-config-test';
|
||||
(require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const notification: PushNotification = {
|
||||
channel: 'push',
|
||||
userId: 'ios-device-token',
|
||||
title: 'iOS Test',
|
||||
body: 'iOS Body',
|
||||
badge: 10,
|
||||
sound: 'notification.caf',
|
||||
category: 'MESSAGE_CATEGORY',
|
||||
};
|
||||
|
||||
await pushService.send(notification);
|
||||
|
||||
const callArg = (require('firebase-admin').messaging as any).mock.instances[0].send.mock.calls[0][0];
|
||||
|
||||
expect(call_arg.apns).toEqual(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
aps: expect.objectContaining({
|
||||
badge: 10,
|
||||
sound: 'notification.caf',
|
||||
category: 'MESSAGE_CATEGORY',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from '@jest/globals';
|
||||
import { SMSService } from '@shieldai/shared-notifications';
|
||||
import type { SMSNotification } from '@shieldai/shared-notifications';
|
||||
import twilio from 'twilio';
|
||||
|
||||
// Mock twilio
|
||||
vi.mock('twilio', () => {
|
||||
const mockMessages = {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
sid: 'SM1234567890abcdef1234567890abcdef',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test message',
|
||||
status: 'sent',
|
||||
}),
|
||||
};
|
||||
|
||||
return vi.fn().mockReturnValue({
|
||||
messages: mockMessages,
|
||||
});
|
||||
});
|
||||
|
||||
describe('SMSService Integration Tests', () => {
|
||||
let smsService: SMSService;
|
||||
let mockTwilio: any;
|
||||
|
||||
beforeAll(() => {
|
||||
smsService = SMSService.getInstance();
|
||||
mockTwilio = (require('twilio') as any).mock.results[0].value;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should successfully send SMS notification', async () => {
|
||||
mockTwilio.messages.create.mockResolvedValueOnce({
|
||||
sid: 'SM1234567890abcdef',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test SMS',
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test SMS',
|
||||
};
|
||||
|
||||
const result = await smsService.send(notification);
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
expect(result.channel).toBe('sms');
|
||||
expect(result.externalId).toBe('SM1234567890abcdef');
|
||||
expect(result.notificationId).toContain('sms-');
|
||||
expect(result.deliveredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should use default from number when not provided', async () => {
|
||||
mockTwilio.messages.create.mockResolvedValueOnce({
|
||||
sid: 'SM-default-from',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
await smsService.send(notification);
|
||||
|
||||
expect(mockTwilio.messages.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom from number when provided', async () => {
|
||||
mockTwilio.messages.create.mockResolvedValueOnce({
|
||||
sid: 'SM-custom-from',
|
||||
from: '+14155559999',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
from: '+14155559999',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
await smsService.send(notification);
|
||||
|
||||
expect(mockTwilio.messages.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: '+14155559999',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include metadata in SMS', async () => {
|
||||
mockTwilio.messages.create.mockResolvedValueOnce({
|
||||
sid: 'SM-metadata-test',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
metadata: { userId: 'user-123', campaign: 'promo' },
|
||||
};
|
||||
|
||||
await smsService.send(notification);
|
||||
|
||||
expect(mockTwilio.messages.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { userId: 'user-123', campaign: 'promo' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Twilio API error', async () => {
|
||||
mockTwilio.messages.create.mockRejectedValueOnce(
|
||||
new Error('Invalid phone number')
|
||||
);
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: 'invalid-number',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const result = await smsService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('Invalid phone number');
|
||||
});
|
||||
|
||||
it('should handle rate limiting', async () => {
|
||||
process.env.SMS_RATE_LIMIT = '2';
|
||||
vi.clearAllMocks();
|
||||
smsService = SMSService.getInstance();
|
||||
|
||||
mockTwilio.messages.create
|
||||
.mockResolvedValueOnce({
|
||||
sid: 'SM-rate-1',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
status: 'sent',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sid: 'SM-rate-2',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
// First two should succeed
|
||||
const result1 = await smsService.send(notification);
|
||||
const result2 = await smsService.send(notification);
|
||||
|
||||
expect(result1.status).toBe('sent');
|
||||
expect(result2.status).toBe('sent');
|
||||
|
||||
// Third should throw due to rate limit
|
||||
await expect(smsService.send(notification)).rejects.toThrow(
|
||||
'SMS rate limit exceeded'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle network timeout', async () => {
|
||||
mockTwilio.messages.create.mockRejectedValueOnce(
|
||||
new Error('Network timeout')
|
||||
);
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const result = await smsService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('Network timeout');
|
||||
});
|
||||
|
||||
it('should handle invalid phone number format', async () => {
|
||||
mockTwilio.messages.create.mockRejectedValueOnce(
|
||||
new Error('Number not in E.164 format')
|
||||
);
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '12345',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
const result = await smsService.send(notification);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toContain('Number not in E.164 format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
smsService = SMSService.getInstance();
|
||||
mockTwilio.messages.create
|
||||
.mockResolvedValueOnce({
|
||||
sid: 'batch-sms-1',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test 1',
|
||||
status: 'sent',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sid: 'batch-sms-2',
|
||||
from: '+14155552671',
|
||||
to: '+14155552673',
|
||||
body: 'Test 2',
|
||||
status: 'sent',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sid: 'batch-sms-3',
|
||||
from: '+14155552671',
|
||||
to: '+14155552674',
|
||||
body: 'Test 3',
|
||||
status: 'sent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should send multiple SMS successfully', async () => {
|
||||
const notifications: SMSNotification[] = [
|
||||
{
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Batch 1',
|
||||
},
|
||||
{
|
||||
channel: 'sms',
|
||||
to: '+14155552673',
|
||||
body: 'Batch 2',
|
||||
},
|
||||
{
|
||||
channel: 'sms',
|
||||
to: '+14155552674',
|
||||
body: 'Batch 3',
|
||||
},
|
||||
];
|
||||
|
||||
const results = await smsService.sendBatch(notifications);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every(r => r.status === 'sent')).toBe(true);
|
||||
expect(results.map(r => r.externalId)).toEqual(['batch-sms-1', 'batch-sms-2', 'batch-sms-3']);
|
||||
});
|
||||
|
||||
it('should handle partial failures in batch', async () => {
|
||||
mockTwilio.messages.create
|
||||
.mockResolvedValueOnce({
|
||||
sid: 'partial-sms-1',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Valid',
|
||||
status: 'sent',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Invalid number'));
|
||||
|
||||
const notifications: SMSNotification[] = [
|
||||
{
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Valid',
|
||||
},
|
||||
{
|
||||
channel: 'sms',
|
||||
to: 'invalid',
|
||||
body: 'Invalid',
|
||||
},
|
||||
];
|
||||
|
||||
const results = await smsService.sendBatch(notifications);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].status).toBe('sent');
|
||||
expect(results[1].status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRateLimitStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
smsService = SMSService.getInstance();
|
||||
});
|
||||
|
||||
it('should return rate limit status', () => {
|
||||
const status = smsService.getRateLimitStatus();
|
||||
|
||||
expect(status).toHaveProperty('remaining');
|
||||
expect(status).toHaveProperty('limit');
|
||||
expect(status.limit).toBeGreaterThan(0);
|
||||
expect(status.remaining).toBeLessThanOrEqual(status.limit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Twilio configuration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
smsService = SMSService.getInstance();
|
||||
});
|
||||
|
||||
it('should use configured account SID and auth token', () => {
|
||||
expect(require('twilio')).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it('should use configured messaging service SID', async () => {
|
||||
mockTwilio.messages.create.mockResolvedValueOnce({
|
||||
sid: 'SM-config-test',
|
||||
from: '+14155552671',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
const notification: SMSNotification = {
|
||||
channel: 'sms',
|
||||
to: '+14155552672',
|
||||
body: 'Test',
|
||||
};
|
||||
|
||||
await smsService.send(notification);
|
||||
|
||||
expect(mockTwilio.messages.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,5 +15,9 @@
|
||||
"@shieldai/types": "workspace:*",
|
||||
"@shieldai/darkwatch": "workspace:*",
|
||||
"ioredis": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
|
||||
import { prisma, SubscriptionTier } from '@shieldai/db';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { Redis } from 'ioredis';
|
||||
import { tierConfig, getTierFeatures } from '@shieldsai/shared-billing';
|
||||
|
||||
26
packages/jobs/vitest.config.ts
Normal file
26
packages/jobs/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'**/node_modules/**',
|
||||
'**/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Hash a phone number for analytics purposes
|
||||
* Uses a consistent hashing algorithm to create a deterministic hash
|
||||
* Uses SHA-256 for consistent, cryptographically strong hashing
|
||||
*/
|
||||
export function hashPhoneNumber(phoneNumber: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < phoneNumber.length; i++) {
|
||||
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return `hash_${Math.abs(hash)}`;
|
||||
const hash = crypto.createHash('sha256').update(phoneNumber).digest('hex');
|
||||
return `sha256_${hash}`;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,7 +21,8 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.5"
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
||||
@@ -5,5 +5,18 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', '**/node_modules/**', '**/test/**'],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.3.1"
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
26
packages/shared-utils/vitest.config.ts
Normal file
26
packages/shared-utils/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'**/node_modules/**',
|
||||
'**/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,6 +15,10 @@
|
||||
"@shieldai/correlation": "workspace:*",
|
||||
"node-cache": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
||||
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
|
||||
import {
|
||||
NotificationService,
|
||||
NotificationPriority,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db';
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldai/db';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
function hashIdentifier(identifier: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldsai/shared-db';
|
||||
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldai/db';
|
||||
import { tierConfig } from '@shieldsai/shared-billing';
|
||||
import { darkwatchScanQueue } from '@shieldsai/jobs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, WatchlistType } from '@shieldsai/shared-db';
|
||||
import { prisma, WatchlistType } from '@shieldai/db';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export function normalizeValue(type: WatchlistType, value: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldai/db';
|
||||
import { createHash } from 'crypto';
|
||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||
|
||||
|
||||
@@ -6,13 +6,21 @@ export class WebhookHandler {
|
||||
private secret: string;
|
||||
|
||||
constructor(secret?: string) {
|
||||
this.secret = secret || process.env.WEBHOOK_SECRET || "default-webhook-secret";
|
||||
if (secret) {
|
||||
this.secret = secret;
|
||||
} else if (process.env.DARKWATCH_WEBHOOK_SECRET) {
|
||||
this.secret = process.env.DARKWATCH_WEBHOOK_SECRET;
|
||||
} else {
|
||||
console.warn("[Webhook] DARKWATCH_WEBHOOK_SECRET not set — signature verification will fail");
|
||||
this.secret = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature of incoming webhook payload.
|
||||
*/
|
||||
verifySignature(payload: string, signature: string | string[]): boolean {
|
||||
if (!this.secret) return false;
|
||||
if (!signature) return false;
|
||||
|
||||
const sigArray = Array.isArray(signature) ? signature : [signature];
|
||||
@@ -39,7 +47,7 @@ export class WebhookHandler {
|
||||
): Promise<{ eventId: string; scanTriggered: boolean }> {
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
|
||||
if (signature && !this.verifySignature(payloadStr, signature)) {
|
||||
if (!signature || !this.verifySignature(payloadStr, signature)) {
|
||||
throw new Error("Webhook signature verification failed");
|
||||
}
|
||||
|
||||
@@ -188,6 +196,9 @@ export class WebhookHandler {
|
||||
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;
|
||||
if (!validTypes.includes(upper as WebhookEventType)) {
|
||||
throw new Error(`Unknown event type: ${eventType}`);
|
||||
}
|
||||
return upper as WebhookEventType;
|
||||
}
|
||||
}
|
||||
|
||||
26
services/darkwatch/vitest.config.ts
Normal file
26
services/darkwatch/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'**/node_modules/**',
|
||||
'**/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22,6 +23,8 @@
|
||||
"typescript": "^5.3.3",
|
||||
"tsx": "^4.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@types/ws": "^8.5.10"
|
||||
"@types/ws": "^8.5.10",
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,13 +90,14 @@ export class CarrierFactory {
|
||||
}
|
||||
}
|
||||
|
||||
getAllCarriers(): Array<{ type: CarrierType; healthy: boolean }> {
|
||||
async getAllCarriers(): Promise<Array<{ type: CarrierType; healthy: boolean }>> {
|
||||
const results: Array<{ type: CarrierType; healthy: boolean }> = [];
|
||||
|
||||
|
||||
for (const [type, carrier] of this.carriers.entries()) {
|
||||
const healthy = await carrier.isHealthy();
|
||||
results.push({
|
||||
type,
|
||||
healthy: carrier.isHealthy(),
|
||||
healthy,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,13 @@ export interface SmsClassificationResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SmsClassificationContext {
|
||||
text: string;
|
||||
senderPhoneNumber?: string;
|
||||
}
|
||||
|
||||
export interface SmsClassifier {
|
||||
classify(text: string): Promise<SmsClassificationResult>;
|
||||
classify(textOrContext: string | SmsClassificationContext): Promise<SmsClassificationResult>;
|
||||
getMetrics(): {
|
||||
totalClassified: number;
|
||||
spamDetected: number;
|
||||
@@ -44,7 +49,10 @@ export class BertSmsClassifier implements SmsClassifier {
|
||||
this.spamShield = spamShield;
|
||||
}
|
||||
|
||||
async classify(text: string): Promise<SmsClassificationResult> {
|
||||
async classify(textOrContext: string | SmsClassificationContext): Promise<SmsClassificationResult> {
|
||||
const text = typeof textOrContext === 'string' ? textOrContext : textOrContext.text;
|
||||
const senderPhoneNumber = typeof textOrContext === 'string' ? undefined : textOrContext.senderPhoneNumber;
|
||||
|
||||
// Feature 1: Language Analysis
|
||||
const language = this.analyzeLanguage(text);
|
||||
|
||||
@@ -85,9 +93,11 @@ export class BertSmsClassifier implements SmsClassifier {
|
||||
}
|
||||
|
||||
// Combine with reputation score if available
|
||||
const reputation = await this.spamShield.checkReputation('placeholder');
|
||||
if (reputation.isSpam) {
|
||||
spamScore += REPUTATION_SCORE_WEIGHT;
|
||||
if (senderPhoneNumber) {
|
||||
const reputation = await this.spamShield.checkReputation(senderPhoneNumber);
|
||||
if (reputation.isSpam) {
|
||||
spamScore += REPUTATION_SCORE_WEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
const isSpam = spamScore > SMS_SPAM_THRESHOLD;
|
||||
|
||||
@@ -116,8 +116,23 @@ export class DecisionEngine {
|
||||
async evaluate(context: DecisionContext): Promise<DecisionResult> {
|
||||
const startTime = Date.now();
|
||||
const reqId = context.requestId ?? 'unknown';
|
||||
|
||||
try {
|
||||
const fallback: DecisionResult = {
|
||||
decision: this.config.fallbackDecision,
|
||||
confidence: 0.5,
|
||||
reasons: ['Fallback decision due to evaluation timeout'],
|
||||
fallbackDecision: this.config.fallbackDecision,
|
||||
scoring: {
|
||||
reputationScore: 0.5,
|
||||
ruleScore: 0.5,
|
||||
behavioralScore: 0.5,
|
||||
userHistoryScore: 0.5,
|
||||
totalScore: 0.5,
|
||||
},
|
||||
executedAt: new Date(),
|
||||
requestId: reqId,
|
||||
};
|
||||
|
||||
const evaluation = (async () => {
|
||||
const [reputationScore, ruleScore, behavioralScore, userHistoryScore] = await Promise.all([
|
||||
this.calculateReputationScore(context.cachedReputation),
|
||||
this.calculateRuleScore(context.ruleMatches),
|
||||
@@ -151,25 +166,25 @@ export class DecisionEngine {
|
||||
executedAt: new Date(),
|
||||
requestId: reqId,
|
||||
};
|
||||
})();
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
evaluation,
|
||||
new Promise<DecisionResult>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[DecisionEngine] [${reqId}] Evaluation timeout after ${this.config.evaluationTimeout}ms`);
|
||||
resolve(fallback);
|
||||
}, this.config.evaluationTimeout);
|
||||
}),
|
||||
]);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[DecisionEngine] [${reqId}] Evaluation error:`, error);
|
||||
|
||||
if (this.config.fallbackOnTimeout) {
|
||||
return {
|
||||
decision: this.config.fallbackDecision,
|
||||
confidence: 0.5,
|
||||
reasons: ['Fallback decision due to evaluation error'],
|
||||
fallbackDecision: this.config.fallbackDecision,
|
||||
scoring: {
|
||||
reputationScore: 0.5,
|
||||
ruleScore: 0.5,
|
||||
behavioralScore: 0.5,
|
||||
userHistoryScore: 0.5,
|
||||
totalScore: 0.5,
|
||||
},
|
||||
executedAt: new Date(),
|
||||
requestId: reqId,
|
||||
};
|
||||
return { ...fallback, reasons: ['Fallback decision due to evaluation error'] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { PrismaClient, SpamRule } from '@prisma/client';
|
||||
import { generateRequestId } from '@shieldai/types';
|
||||
import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation';
|
||||
|
||||
export interface CompiledRule {
|
||||
rule: SpamRule;
|
||||
compiledPattern: RegExp;
|
||||
compiledCaseInsensitive?: RegExp;
|
||||
}
|
||||
|
||||
export interface RuleMatch {
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
@@ -25,10 +31,10 @@ const DEFAULT_CONFIG: Required<RuleEngineConfig> = {
|
||||
|
||||
export class RuleEngine {
|
||||
private readonly config: Required<RuleEngineConfig>;
|
||||
private numberPatternRules: SpamRule[] = [];
|
||||
private behavioralRules: SpamRule[] = [];
|
||||
private contentRules: SpamRule[] = [];
|
||||
private allRules: SpamRule[] = [];
|
||||
private numberPatternRules: CompiledRule[] = [];
|
||||
private behavioralRules: CompiledRule[] = [];
|
||||
private contentRules: CompiledRule[] = [];
|
||||
private allRules: CompiledRule[] = [];
|
||||
private lastLoadTime: Date | null = null;
|
||||
private readonly prisma: PrismaClient;
|
||||
|
||||
@@ -52,11 +58,17 @@ export class RuleEngine {
|
||||
orderBy: { priority: 'desc' },
|
||||
});
|
||||
|
||||
const validatedRules: SpamRule[] = [];
|
||||
const compiledRules: CompiledRule[] = [];
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
validateRegexPattern(rule.pattern);
|
||||
validatedRules.push(rule);
|
||||
const compiledPattern = new RegExp(rule.pattern);
|
||||
const compiledCaseInsensitive = new RegExp(rule.pattern, 'i');
|
||||
compiledRules.push({
|
||||
rule,
|
||||
compiledPattern,
|
||||
compiledCaseInsensitive,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RegexValidationError) {
|
||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk: ${error.reason}, skipping`);
|
||||
@@ -66,10 +78,10 @@ export class RuleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
this.allRules = validatedRules;
|
||||
this.numberPatternRules = validatedRules.filter(r => (r as any).category === 'number_pattern');
|
||||
this.behavioralRules = validatedRules.filter(r => (r as any).category === 'behavioral');
|
||||
this.contentRules = validatedRules.filter(r => (r as any).category === 'content');
|
||||
this.allRules = compiledRules;
|
||||
this.numberPatternRules = compiledRules.filter(r => (r.rule as any).category === 'number_pattern');
|
||||
this.behavioralRules = compiledRules.filter(r => (r.rule as any).category === 'behavioral');
|
||||
this.contentRules = compiledRules.filter(r => (r.rule as any).category === 'content');
|
||||
this.lastLoadTime = now;
|
||||
}
|
||||
|
||||
@@ -80,26 +92,20 @@ export class RuleEngine {
|
||||
|
||||
const matches: RuleMatch[] = [];
|
||||
|
||||
for (const rule of this.allRules) {
|
||||
for (const compiled of this.allRules) {
|
||||
try {
|
||||
validateRegexPattern(rule.pattern);
|
||||
const pattern = new RegExp(rule.pattern);
|
||||
if (pattern.test(phoneNumber)) {
|
||||
if (compiled.compiledPattern.test(phoneNumber)) {
|
||||
matches.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
pattern: rule.pattern,
|
||||
score: (rule as any).score,
|
||||
priority: (rule as any).priority as 'high' | 'medium' | 'low',
|
||||
ruleId: compiled.rule.id,
|
||||
ruleName: compiled.rule.name,
|
||||
pattern: compiled.rule.pattern,
|
||||
score: (compiled.rule as any).score,
|
||||
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
|
||||
matchedAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RegexValidationError) {
|
||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
|
||||
} else {
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
|
||||
}
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] Evaluation error for rule ${compiled.rule.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,26 +119,20 @@ export class RuleEngine {
|
||||
|
||||
const matches: RuleMatch[] = [];
|
||||
|
||||
for (const rule of this.contentRules) {
|
||||
for (const compiled of this.contentRules) {
|
||||
try {
|
||||
validateRegexPattern(rule.pattern);
|
||||
const pattern = new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(smsBody)) {
|
||||
if (compiled.compiledCaseInsensitive!.test(smsBody)) {
|
||||
matches.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
pattern: rule.pattern,
|
||||
score: (rule as any).score,
|
||||
priority: (rule as any).priority as 'high' | 'medium' | 'low',
|
||||
ruleId: compiled.rule.id,
|
||||
ruleName: compiled.rule.name,
|
||||
pattern: compiled.rule.pattern,
|
||||
score: (compiled.rule as any).score,
|
||||
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
|
||||
matchedAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RegexValidationError) {
|
||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
|
||||
} else {
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
|
||||
}
|
||||
console.error(`[RuleEngine] [req:${generateRequestId()}] SMS evaluation error for rule ${compiled.rule.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,19 +140,19 @@ export class RuleEngine {
|
||||
}
|
||||
|
||||
getNumberPatternRules(): SpamRule[] {
|
||||
return [...this.numberPatternRules];
|
||||
return this.numberPatternRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
getBehavioralRules(): SpamRule[] {
|
||||
return [...this.behavioralRules];
|
||||
return this.behavioralRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
getContentRules(): SpamRule[] {
|
||||
return [...this.contentRules];
|
||||
return this.contentRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
getAllRules(): SpamRule[] {
|
||||
return [...this.allRules];
|
||||
return this.allRules.map(r => r.rule);
|
||||
}
|
||||
|
||||
async refreshRules(): Promise<void> {
|
||||
|
||||
@@ -202,29 +202,23 @@ export class SpamShieldService {
|
||||
}
|
||||
|
||||
const validated = this.validatePhoneNumber(phoneNumber);
|
||||
const rules = await this.getActiveRules();
|
||||
const matches = this.ruleEngine
|
||||
? await this.ruleEngine.evaluate(validated)
|
||||
: [];
|
||||
|
||||
const ruleMatches: string[] = [];
|
||||
let confidence = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const pattern = new RegExp(rule.pattern);
|
||||
if (pattern.test(validated)) {
|
||||
ruleMatches.push(rule.id);
|
||||
confidence += 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
confidence = Math.min(confidence, 1.0);
|
||||
const ruleMatchIds = matches.map(m => m.ruleId);
|
||||
const confidence = Math.min(matches.reduce((sum, m) => sum + m.score, 0), 1.0);
|
||||
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
||||
|
||||
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||
|
||||
const auditLog = await prisma.spamAuditLog.create({
|
||||
data: {
|
||||
userId: 'system',
|
||||
phoneNumber: validated,
|
||||
phoneNumber: encrypted,
|
||||
decision: decision as any,
|
||||
reason: `Rule-based analysis`,
|
||||
ruleId: ruleMatches[0],
|
||||
ruleId: ruleMatchIds[0],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,11 +229,11 @@ export class SpamShieldService {
|
||||
validated,
|
||||
decision,
|
||||
confidence,
|
||||
ruleMatches
|
||||
ruleMatchIds
|
||||
).catch((err) => console.error(`[Correlation] SpamShield emit failed:`, err));
|
||||
}
|
||||
|
||||
return { decision, confidence, ruleMatches };
|
||||
return { decision, confidence, ruleMatches: ruleMatchIds };
|
||||
}
|
||||
|
||||
async recordFeedback(
|
||||
@@ -253,6 +247,18 @@ export class SpamShieldService {
|
||||
throw new Error('Feedback loop disabled via feature flag');
|
||||
}
|
||||
|
||||
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
|
||||
throw new Error('Feedback: userId is required');
|
||||
}
|
||||
|
||||
if (!phoneNumber || typeof phoneNumber !== 'string') {
|
||||
throw new Error('Feedback: phoneNumber must be a non-empty string');
|
||||
}
|
||||
|
||||
if (typeof isSpam !== 'boolean') {
|
||||
throw new Error('Feedback: isSpam must be a boolean');
|
||||
}
|
||||
|
||||
const validated = this.validatePhoneNumber(phoneNumber);
|
||||
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createHash } from 'crypto';
|
||||
import { DecisionResult } from '../engine/decision-engine';
|
||||
|
||||
export interface AlertEvent {
|
||||
@@ -29,14 +30,20 @@ export interface AlertServerConfig {
|
||||
heartbeatIntervalMs?: number;
|
||||
maxClients?: number;
|
||||
enableLogging?: boolean;
|
||||
enableAuth?: boolean;
|
||||
jwtSecret?: string;
|
||||
allowedOrigins?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Required<AlertServerConfig> = {
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
heartbeatIntervalMs: 30000,
|
||||
maxClients: 1000,
|
||||
maxClients: 100,
|
||||
enableLogging: true,
|
||||
enableAuth: true,
|
||||
jwtSecret: process.env.SPAMSHIELD_JWT_SECRET || '',
|
||||
allowedOrigins: ['http://localhost:3000'],
|
||||
};
|
||||
|
||||
export class AlertServer {
|
||||
@@ -57,9 +64,34 @@ export class AlertServer {
|
||||
}
|
||||
|
||||
private setupWebSocketHandlers(): void {
|
||||
this.wss.on('connection', (ws: WebSocket, req: any) => {
|
||||
this.wss.on('connection', async (ws: WebSocket, req: any) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && this.config.allowedOrigins.length > 0 && !this.config.allowedOrigins.includes(origin)) {
|
||||
ws.close(1008, 'Origin not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.enableAuth && this.config.jwtSecret) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
ws.close(4001, 'Missing or invalid JWT token');
|
||||
return;
|
||||
}
|
||||
const token = authHeader.substring(7);
|
||||
const valid = await this.verifyJWT(token);
|
||||
if (!valid) {
|
||||
ws.close(4002, 'Invalid or expired JWT token');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clients.size >= this.config.maxClients) {
|
||||
ws.close(1013, 'Too many clients');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = req.headers['x-client-id'] as string || `client-${Date.now()}-${Math.random()}`;
|
||||
|
||||
|
||||
const subscription: ClientSubscription = {
|
||||
clientId,
|
||||
subscribedEvents: ['decision', 'flag', 'block', 'user_feedback', 'carrier_action'],
|
||||
@@ -281,6 +313,21 @@ export class AlertServer {
|
||||
}
|
||||
|
||||
private hashPhoneNumber(phoneNumber: string): string {
|
||||
return Buffer.from(phoneNumber).toString('hex');
|
||||
return createHash('sha256').update(phoneNumber).digest('hex');
|
||||
}
|
||||
|
||||
private async verifyJWT(token: string): Promise<boolean> {
|
||||
try {
|
||||
const { jwtVerify } = await import('jose');
|
||||
await jwtVerify(token, new TextEncoder().encode(this.config.jwtSecret), {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[AlertServer] JWT verification failed');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,61 @@ describe('SpamShieldService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordFeedback null checks', () => {
|
||||
it('throws when userId is null', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback(null as any, '+14155552671', true);
|
||||
await expect(result).rejects.toThrow('Feedback: userId is required');
|
||||
});
|
||||
|
||||
it('throws when userId is empty string', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback('', '+14155552671', true);
|
||||
await expect(result).rejects.toThrow('Feedback: userId is required');
|
||||
});
|
||||
|
||||
it('throws when phoneNumber is null', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback('user123', null as any, true);
|
||||
await expect(result).rejects.toThrow('Feedback: phoneNumber must be a non-empty string');
|
||||
});
|
||||
|
||||
it('throws when isSpam is null', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback('user123', '+14155552671', null as any);
|
||||
await expect(result).rejects.toThrow('Feedback: isSpam must be a boolean');
|
||||
});
|
||||
|
||||
it('throws when isSpam is undefined', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback('user123', '+14155552671', undefined as any);
|
||||
await expect(result).rejects.toThrow('Feedback: isSpam must be a boolean');
|
||||
});
|
||||
|
||||
it('throws when userId is undefined', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback(undefined as any, '+14155552671', true);
|
||||
await expect(result).rejects.toThrow('Feedback: userId is required');
|
||||
});
|
||||
|
||||
it('throws when phoneNumber is undefined', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback('user123', undefined as any, true);
|
||||
await expect(result).rejects.toThrow('Feedback: phoneNumber must be a non-empty string');
|
||||
});
|
||||
|
||||
it('handles null metadata gracefully (falls back to default)', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
|
||||
const result = service.recordFeedback('user123', '+14155552671', true, undefined, null as any);
|
||||
try {
|
||||
await result;
|
||||
} catch (e) {
|
||||
expect((e as Error).message).not.toContain('userId is required');
|
||||
expect((e as Error).message).not.toContain('isSpam must be a boolean');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableFeedbackLoop flag', () => {
|
||||
it('throws when feedback loop is disabled in recordFeedback', async () => {
|
||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'false';
|
||||
|
||||
@@ -5,5 +5,22 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'**/node_modules/**',
|
||||
'**/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,6 +15,10 @@
|
||||
"@shieldai/correlation": "workspace:*",
|
||||
"node-cache": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
|
||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
|
||||
import {
|
||||
voicePrintEnv,
|
||||
AnalysisJobStatus,
|
||||
|
||||
26
services/voiceprint/vitest.config.ts
Normal file
26
services/voiceprint/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'**/node_modules/**',
|
||||
'**/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,10 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user