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
|
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||||
- name: Run tests
|
- name: Run tests with coverage
|
||||||
run: npm run test
|
run: npm run test:coverage
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||||
REDIS_URL: "redis://localhost:6379"
|
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:
|
docker-build:
|
||||||
name: 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",
|
"dev": "turbo run dev",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
|
"test:coverage": "turbo run test:coverage",
|
||||||
"db:migrate": "turbo run db:migrate",
|
"db:migrate": "turbo run db:migrate",
|
||||||
"db:seed": "turbo run db:seed",
|
"db:seed": "turbo run db:seed",
|
||||||
"lint": "turbo run lint"
|
"lint": "turbo run lint"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"turbo": "^2.3.0",
|
"turbo": "^2.3.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0"
|
||||||
"vitest": "^4.1.5"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,5 +20,9 @@
|
|||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"@shieldai/voiceprint": "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';
|
import { SMSClassifierService } from '../services/spamshield/spamshield.service';
|
||||||
|
|
||||||
// Mock shared-db before anything else (Prisma client is not generated in test env)
|
// Mock shared-db before anything else (Prisma client is not generated in test env)
|
||||||
vi.mock('@shieldsai/shared-db', () => ({
|
vi.mock('@shieldai/db', () => ({
|
||||||
prisma: {},
|
prisma: {},
|
||||||
SpamFeedback: {},
|
SpamFeedback: {},
|
||||||
}));
|
}));
|
||||||
@@ -35,6 +35,31 @@ vi.mock('../services/spamshield/spamshield.config', () => ({
|
|||||||
VERY_HIGH: 'very_high',
|
VERY_HIGH: 'very_high',
|
||||||
},
|
},
|
||||||
spamRateLimits: {},
|
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', () => {
|
describe('SMSClassifierService', () => {
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
import { correlationService } from "@shieldai/correlation";
|
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) {
|
export function correlationRoutes(fastify: FastifyInstance) {
|
||||||
fastify.get("/dashboard", async (request, reply) => {
|
fastify.get("/dashboard", async (request, reply) => {
|
||||||
const userId = (request.user as { id: string })?.id;
|
const userId = getUserId(request);
|
||||||
|
if (!userId || userId === "anonymous") {
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
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);
|
const data = await correlationService.getDashboardData(userId, timeWindow);
|
||||||
return reply.send(data);
|
return reply.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/groups", async (request, reply) => {
|
fastify.get("/groups", async (request, reply) => {
|
||||||
const userId = (request.user as { id: string })?.id;
|
const userId = getUserId(request);
|
||||||
|
if (!userId || userId === "anonymous") {
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = request.query as Record<string, string>;
|
const query = request.query as Record<string, string>;
|
||||||
const result = await correlationService.getCorrelationGroups({
|
const result = await correlationService.getCorrelationGroups({
|
||||||
userId,
|
userId,
|
||||||
status: query.status || undefined,
|
status: (query.status as any) || undefined,
|
||||||
timeWindowMinutes: query.timeWindow
|
timeWindowMinutes: query.timeWindow
|
||||||
? parseInt(query.timeWindow)
|
? parseInt(query.timeWindow)
|
||||||
: 60,
|
: 60,
|
||||||
@@ -34,43 +41,91 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/groups/:groupId", async (request, reply) => {
|
fastify.get(
|
||||||
const groupId = (request.params as any).groupId;
|
"/groups/:groupId",
|
||||||
const group = await correlationService.getGroupById(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) {
|
const groupId = (request.params as Record<string, string>).groupId;
|
||||||
return reply.code(404).send({ error: "Correlation group not found" });
|
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 Record<string, string>).groupId;
|
||||||
const groupId = (request.params as any).groupId;
|
const body = request.body as Record<string, string> | undefined;
|
||||||
const body = (request.body as any) || {};
|
const status = body?.status || "RESOLVED";
|
||||||
const status = body.status || "RESOLVED";
|
const group = await correlationService.resolveGroup(
|
||||||
const group = await correlationService.resolveGroup(groupId, status);
|
groupId,
|
||||||
|
userId,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return reply.code(404).send({ error: "Correlation group not found" });
|
return reply.code(404).send({ error: "Correlation group not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(group);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return reply.send(group);
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.get("/alerts", async (request, reply) => {
|
fastify.get("/alerts", async (request, reply) => {
|
||||||
const userId = (request.user as { id: string })?.id;
|
const userId = getUserId(request);
|
||||||
|
if (!userId || userId === "anonymous") {
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = request.query as Record<string, string>;
|
const query = request.query as Record<string, string>;
|
||||||
const result = await correlationService.getCorrelatedAlerts({
|
const result = await correlationService.getCorrelatedAlerts({
|
||||||
userId,
|
userId,
|
||||||
source: query.source || undefined,
|
source: (query.source as any) || undefined,
|
||||||
category: query.category || undefined,
|
category: (query.category as any) || undefined,
|
||||||
severity: query.severity || undefined,
|
severity: (query.severity as any) || undefined,
|
||||||
timeWindowMinutes: query.timeWindow
|
timeWindowMinutes: query.timeWindow
|
||||||
? parseInt(query.timeWindow)
|
? parseInt(query.timeWindow)
|
||||||
: 60,
|
: 60,
|
||||||
@@ -80,72 +135,200 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/ingest/darkwatch", async (request, reply) => {
|
fastify.post(
|
||||||
const body = request.body as any;
|
"/ingest/darkwatch",
|
||||||
const alert = await correlationService.ingestDarkWatchAlert(
|
{
|
||||||
body.userId,
|
schema: {
|
||||||
body.sourceAlertId,
|
body: {
|
||||||
{
|
type: "object",
|
||||||
exposureId: body.exposureId,
|
properties: {
|
||||||
breachName: body.breachName,
|
sourceAlertId: { type: "string" },
|
||||||
severity: body.severity,
|
exposureId: { type: "string" },
|
||||||
channel: body.channel,
|
breachName: { type: "string", maxLength: 500 },
|
||||||
dataType: body.dataType,
|
severity: { type: "string", maxLength: 20 },
|
||||||
dataSource: body.dataSource,
|
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 Record<string, unknown>;
|
||||||
const body = request.body as any;
|
const alert = await correlationService.ingestDarkWatchAlert(
|
||||||
const alert = await correlationService.ingestSpamShieldAlert(
|
userId,
|
||||||
body.userId,
|
body.sourceAlertId as string,
|
||||||
body.sourceAlertId,
|
{
|
||||||
{
|
exposureId: body.exposureId as string,
|
||||||
phoneNumber: body.phoneNumber,
|
breachName: body.breachName as string,
|
||||||
decision: body.decision,
|
severity: body.severity as string,
|
||||||
confidence: body.confidence,
|
channel: body.channel as string,
|
||||||
reasons: body.reasons,
|
dataType: body.dataType as string[] | undefined,
|
||||||
channel: body.channel,
|
dataSource: body.dataSource as string | undefined,
|
||||||
hiyaReputationScore: body.hiyaReputationScore,
|
}
|
||||||
truecallerSpamScore: body.truecallerSpamScore,
|
);
|
||||||
}
|
return reply.code(201).send(alert);
|
||||||
);
|
}
|
||||||
return reply.code(201).send(alert);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
fastify.post("/ingest/voiceprint", async (request, reply) => {
|
fastify.post(
|
||||||
const body = request.body as any;
|
"/ingest/spamshield",
|
||||||
const alert = await correlationService.ingestVoicePrintAlert(
|
{
|
||||||
body.userId,
|
schema: {
|
||||||
body.sourceAlertId,
|
body: {
|
||||||
{
|
type: "object",
|
||||||
jobId: body.jobId,
|
properties: {
|
||||||
verdict: body.verdict,
|
sourceAlertId: { type: "string" },
|
||||||
syntheticScore: body.syntheticScore,
|
phoneNumber: { type: "string", maxLength: 20 },
|
||||||
confidence: body.confidence,
|
decision: { type: "string", enum: ["BLOCK", "FLAG", "ALLOW"] },
|
||||||
matchedEnrollmentId: body.matchedEnrollmentId,
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
matchedSimilarity: body.matchedSimilarity,
|
reasons: { type: "array", items: { type: "string" } },
|
||||||
analysisType: body.analysisType,
|
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 Record<string, unknown>;
|
||||||
const body = request.body as any;
|
const alert = await correlationService.ingestSpamShieldAlert(
|
||||||
const alert = await correlationService.ingestCallAnalysisAlert(
|
userId,
|
||||||
body.userId,
|
body.sourceAlertId as string,
|
||||||
body.sourceAlertId,
|
{
|
||||||
{
|
phoneNumber: body.phoneNumber as string,
|
||||||
callId: body.callId,
|
decision: body.decision as string,
|
||||||
eventType: body.eventType,
|
confidence: body.confidence as number,
|
||||||
mosScore: body.mosScore,
|
reasons: body.reasons as string[] | undefined,
|
||||||
anomaly: body.anomaly,
|
channel: body.channel as "call" | "sms" | undefined,
|
||||||
sentiment: body.sentiment,
|
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 { 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 { tierConfig, SubscriptionTier as BillingTier } from '@shieldsai/shared-billing';
|
||||||
import {
|
import {
|
||||||
watchlistService,
|
watchlistService,
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.get(
|
fastify.get(
|
||||||
"/:userId",
|
"/:userId",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = (request.params as { userId: string }).userId;
|
const params = request.params as { userId: string };
|
||||||
const schedule = await scheduler.getSchedule(userId);
|
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) {
|
if (!schedule) {
|
||||||
return reply.code(404).send({ error: "Schedule not found" });
|
return reply.code(404).send({ error: "Schedule not found" });
|
||||||
@@ -35,8 +39,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.post(
|
fastify.post(
|
||||||
"/:userId/pause",
|
"/:userId/pause",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = (request.params as { userId: string }).userId;
|
const params = request.params as { userId: string };
|
||||||
await scheduler.pauseSchedule(userId);
|
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 });
|
return reply.send({ paused: true });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -44,8 +52,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.post(
|
fastify.post(
|
||||||
"/:userId/resume",
|
"/:userId/resume",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = (request.params as { userId: string }).userId;
|
const params = request.params as { userId: string };
|
||||||
await scheduler.resumeSchedule(userId);
|
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 });
|
return reply.send({ resumed: true });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,13 +31,8 @@ export function webhookRoutes(fastify: FastifyInstance) {
|
|||||||
scanTriggered: result.scanTriggered,
|
scanTriggered: result.scanTriggered,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
console.error("[Webhook] Event processing error:", err);
|
||||||
|
return reply.code(400).send({ error: "Webhook processing failed" });
|
||||||
if (message.includes("signature")) {
|
|
||||||
return reply.code(401).send({ error: message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(400).send({ error: message });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -56,11 +51,15 @@ export function webhookRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.get(
|
fastify.get(
|
||||||
"/user/:userId",
|
"/user/:userId",
|
||||||
async (request, reply) => {
|
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 limit = parseInt((request.query as { limit?: string }).limit || "50");
|
||||||
const offset = parseInt((request.query as { offset?: string }).offset || "0");
|
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);
|
return reply.send(events);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import cors from "@fastify/cors";
|
|||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
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({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -12,10 +15,13 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
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(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
|
||||||
|
// Register auth middleware to populate request.user
|
||||||
|
await app.register(authMiddleware);
|
||||||
|
|
||||||
app.addHook("onRequest", async (request, _reply) => {
|
app.addHook("onRequest", async (request, _reply) => {
|
||||||
const requestId = extractOrGenerateRequestId(request.headers);
|
const requestId = extractOrGenerateRequestId(request.headers);
|
||||||
request.id = requestId;
|
request.id = requestId;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
|
||||||
import {
|
import {
|
||||||
NotificationService,
|
NotificationService,
|
||||||
NotificationPriority,
|
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';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
function hashIdentifier(identifier: string): string {
|
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 { tierConfig } from '@shieldsai/shared-billing';
|
||||||
import { darkwatchScanQueue } from '@shieldsai/jobs';
|
import { darkwatchScanQueue } from '@shieldsai/jobs';
|
||||||
import { randomUUID } from 'crypto';
|
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';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
export function normalizeValue(type: WatchlistType, value: string): string {
|
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 { createHash } from 'crypto';
|
||||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
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 { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
|
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
|
||||||
@@ -366,8 +366,27 @@ export class SpamFeedbackService {
|
|||||||
confidence?: number,
|
confidence?: number,
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, any>
|
||||||
): Promise<SpamFeedback> {
|
): Promise<SpamFeedback> {
|
||||||
// Validate metadata
|
// Defensive null checks for required fields
|
||||||
const validation = this.validateMetadata(metadata);
|
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;
|
const validatedMetadata = validation.trimmedMetadata;
|
||||||
|
|
||||||
// Only enable if feature flag is set
|
// Only enable if feature flag is set
|
||||||
@@ -379,7 +398,7 @@ export class SpamFeedbackService {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
|
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
|
||||||
isSpam,
|
isSpam,
|
||||||
confidence,
|
confidence: validatedConfidence,
|
||||||
feedbackType: 'user_confirmation' as const,
|
feedbackType: 'user_confirmation' as const,
|
||||||
metadata: validatedMetadata,
|
metadata: validatedMetadata,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -395,7 +414,7 @@ export class SpamFeedbackService {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
phoneNumberHash,
|
phoneNumberHash,
|
||||||
isSpam,
|
isSpam,
|
||||||
confidence,
|
confidence: validatedConfidence,
|
||||||
feedbackType: 'user_confirmation',
|
feedbackType: 'user_confirmation',
|
||||||
metadata: validatedMetadata,
|
metadata: validatedMetadata,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
|
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
|
||||||
import {
|
import {
|
||||||
voicePrintEnv,
|
voicePrintEnv,
|
||||||
AnalysisJobStatus,
|
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(
|
public async getGroupById(
|
||||||
groupId: string
|
groupId: string,
|
||||||
|
userId: string
|
||||||
): Promise<CorrelationGroupOutput | null> {
|
): Promise<CorrelationGroupOutput | null> {
|
||||||
const group = await (prisma as any).correlationGroup.findUnique({
|
const group = await (prisma as any).correlationGroup.findUnique({
|
||||||
where: { id: groupId },
|
where: { id: groupId, userId },
|
||||||
include: {
|
include: {
|
||||||
alerts: {
|
alerts: {
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
@@ -298,10 +299,11 @@ export class CorrelationEngine {
|
|||||||
|
|
||||||
public async resolveGroup(
|
public async resolveGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
status: string = CorrelationStatus.RESOLVED
|
status: string = CorrelationStatus.RESOLVED
|
||||||
): Promise<CorrelationGroupOutput | null> {
|
): Promise<CorrelationGroupOutput | null> {
|
||||||
const group = await (prisma as any).correlationGroup.update({
|
const group = await (prisma as any).correlationGroup.update({
|
||||||
where: { id: groupId },
|
where: { id: groupId, userId },
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
resolvedAt: new Date(),
|
resolvedAt: new Date(),
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import {
|
|||||||
|
|
||||||
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
|
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 {
|
interface DarkWatchAlertPayload {
|
||||||
exposureId: string;
|
exposureId: string;
|
||||||
breachName: string;
|
breachName: string;
|
||||||
@@ -92,7 +110,7 @@ export class AlertNormalizer {
|
|||||||
: `Exposure detected in ${payload.breachName}`,
|
: `Exposure detected in ${payload.breachName}`,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,7 +150,7 @@ export class AlertNormalizer {
|
|||||||
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,7 +197,7 @@ export class AlertNormalizer {
|
|||||||
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -237,7 +255,7 @@ export class AlertNormalizer {
|
|||||||
description,
|
description,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,12 +126,12 @@ export class CorrelationService {
|
|||||||
return this.engine.getCorrelationGroups(query);
|
return this.engine.getCorrelationGroups(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getGroupById(groupId: string) {
|
public getGroupById(groupId: string, userId: string) {
|
||||||
return this.engine.getGroupById(groupId);
|
return this.engine.getGroupById(groupId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveGroup(groupId: string, status?: string) {
|
public resolveGroup(groupId: string, userId: string, status?: string) {
|
||||||
return this.engine.resolveGroup(groupId, status as any);
|
return this.engine.resolveGroup(groupId, userId, status as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "@shieldai/db",
|
"name": "@shieldai/db",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"main": "./dist/index.js",
|
"type": "module",
|
||||||
"types": "./dist/index.js",
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && tsc",
|
"build": "prisma generate && tsc",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:format": "prisma format",
|
||||||
"generate": "prisma generate"
|
"generate": "prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.2.0",
|
"@prisma/client": "^6.2.0",
|
||||||
"prisma": "^6.2.0"
|
"prisma": "^6.2.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsx": "^4.19.0"
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Prisma schema for ShieldAI
|
||||||
|
// All models for the multi-service SaaS platform
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -7,337 +10,438 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionTier {
|
// ============================================
|
||||||
BASIC
|
// User & Authentication Models
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
emailVerified DateTime?
|
||||||
subscriptionTier SubscriptionTier @default(BASIC)
|
name String?
|
||||||
familyGroupId String?
|
image String?
|
||||||
watchListItems WatchListItem[]
|
role UserRole @default(user)
|
||||||
alerts Alert[]
|
|
||||||
scanJobs ScanJob[]
|
// Relationships
|
||||||
scanSchedules ScanSchedule[]
|
accounts Account[]
|
||||||
voiceEnrollments VoiceEnrollment[]
|
sessions Session[]
|
||||||
analysisJobs AnalysisJob[]
|
familyGroups FamilyGroupMember[]
|
||||||
spamFeedback SpamFeedback[]
|
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||||
spamCallAnalyses SpamCallAnalysis[]
|
subscriptions Subscription[]
|
||||||
spamAuditLogs SpamAuditLog[]
|
alerts Alert[]
|
||||||
normalizedAlerts NormalizedAlert[]
|
voiceEnrollments VoiceEnrollment[]
|
||||||
correlationGroups CorrelationGroup[]
|
voiceAnalyses VoiceAnalysis[]
|
||||||
createdAt DateTime @default(now())
|
spamFeedback SpamFeedback[]
|
||||||
updatedAt DateTime @updatedAt
|
spamRules SpamRule[]
|
||||||
|
normalizedAlerts NormalizedAlert[]
|
||||||
|
correlationGroups CorrelationGroup[]
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
|
@@index([role])
|
||||||
}
|
}
|
||||||
|
|
||||||
model WatchListItem {
|
enum UserRole {
|
||||||
id String @id @default(uuid())
|
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
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
familyGroupId String?
|
||||||
identifierType IdentifierType
|
stripeId String? @unique
|
||||||
identifierValue String
|
tier SubscriptionTier @default(basic)
|
||||||
identifierHash String @unique
|
status SubscriptionStatus @default(active)
|
||||||
status WatchListStatus @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[]
|
exposures Exposure[]
|
||||||
createdAt DateTime @default(now())
|
alerts Alert[]
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@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 {
|
model Exposure {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
watchListItemId String
|
subscriptionId String
|
||||||
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
|
watchlistItemId String?
|
||||||
dataSource DataSource
|
source ExposureSource
|
||||||
breachName String
|
dataType WatchlistType
|
||||||
exposedAt DateTime
|
identifier String
|
||||||
dataType String[]
|
identifierHash String
|
||||||
severity Severity
|
severity ExposureSeverity @default(info)
|
||||||
details String?
|
metadata Json? // Additional source-specific data
|
||||||
contentHash String @unique
|
isFirstTime Boolean @default(false)
|
||||||
alert Alert?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([watchListItemId])
|
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
@@index([contentHash])
|
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
|
||||||
@@index([dataSource])
|
alerts Alert[]
|
||||||
|
|
||||||
|
detectedAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@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 {
|
model Alert {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
subscriptionId 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())
|
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
exposureId String?
|
||||||
intervalMinutes Int // minutes between scans
|
type AlertType
|
||||||
cronExpression String // cron expression for scheduling
|
title String
|
||||||
status ScheduleStatus @default(ACTIVE)
|
message String
|
||||||
lastScanAt DateTime?
|
severity AlertSeverity @default(info)
|
||||||
nextScanAt DateTime?
|
isRead Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
readAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
channel AlertChannel[] // Array of notification channels
|
||||||
|
|
||||||
@@unique([userId])
|
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
@@index([status])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
exposure Exposure? @relation(fields: [exposureId], references: [id])
|
||||||
|
|
||||||
enum WebhookEventType {
|
createdAt DateTime @default(now())
|
||||||
SCAN_TRIGGER
|
updatedAt DateTime @updatedAt
|
||||||
BREACH_DETECTED
|
|
||||||
SUBSCRIPTION_CHANGE
|
|
||||||
}
|
|
||||||
|
|
||||||
model WebhookEvent {
|
@@index([subscriptionId])
|
||||||
id String @id @default(uuid())
|
@@index([userId])
|
||||||
eventType WebhookEventType
|
@@index([isRead])
|
||||||
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([createdAt])
|
@@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 {
|
model VoiceEnrollment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
name String
|
||||||
label String
|
voiceHash String // FAISS embedding hash
|
||||||
embeddingVector Float[]
|
audioMetadata Json? // Sample rate, duration, etc.
|
||||||
embeddingDim Int @default(192)
|
|
||||||
audioFilePath String?
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
sampleRate Int @default(16000)
|
analyses VoiceAnalysis[]
|
||||||
durationSec Float?
|
|
||||||
createdAt DateTime @default(now())
|
isActive Boolean @default(true)
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([embeddingDim])
|
@@index([voiceHash])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AnalysisJob {
|
model VoiceAnalysis {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
enrollmentId String?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
userId String
|
||||||
analysisType AnalysisType
|
audioHash String // Content hash of audio file
|
||||||
audioFilePath String
|
isSynthetic Boolean
|
||||||
status AnalysisJobStatus @default(PENDING)
|
confidence Float // 0.0 to 1.0
|
||||||
result AnalysisResult?
|
analysisResult Json // Full ML analysis results
|
||||||
errorMessage String?
|
audioUrl String // S3 storage URL
|
||||||
completedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([userId, status])
|
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
|
||||||
@@index([createdAt])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([enrollmentId])
|
||||||
|
@@index([audioHash])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AnalysisResult {
|
// ============================================
|
||||||
id String @id @default(uuid())
|
// SpamShield Models (Spam Detection)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
model SpamFeedback {
|
model SpamFeedback {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
phoneNumber String
|
||||||
phoneNumber String // AES-256 encrypted PII
|
phoneNumberHash String // SHA-256 hash
|
||||||
phoneNumberHash String // SHA-256 hash for anonymized lookup
|
isSpam Boolean
|
||||||
isSpam Boolean
|
confidence Float? // ML model confidence
|
||||||
label String?
|
feedbackType FeedbackType
|
||||||
metadata String? // Unbounded JSON
|
metadata Json? // Call duration, time, etc.
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([phoneNumberHash])
|
@@index([phoneNumberHash])
|
||||||
@@index([createdAt])
|
@@index([isSpam])
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpamCallAnalysis {
|
enum FeedbackType {
|
||||||
id String @id @default(uuid())
|
initial_detection
|
||||||
userId String
|
user_confirmation
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user_rejection
|
||||||
phoneNumber String
|
auto_learned
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpamRule {
|
model SpamRule {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
userId String?
|
||||||
pattern String @db.VarChar(500) // Regex pattern - validated for ReDoS at application layer
|
isGlobal Boolean @default(false)
|
||||||
decision SpamDecision
|
ruleType RuleType
|
||||||
description String?
|
pattern String
|
||||||
isActive Boolean @default(true)
|
action RuleAction
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
isActive Boolean @default(true)
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([isActive])
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@index([priority])
|
|
||||||
}
|
|
||||||
|
|
||||||
model SpamAuditLog {
|
createdAt DateTime @default(now())
|
||||||
id String @id @default(uuid())
|
updatedAt DateTime @updatedAt
|
||||||
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())
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([isGlobal])
|
||||||
@@index([decision])
|
@@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 {
|
enum AlertSource {
|
||||||
DARKWATCH
|
DARKWATCH
|
||||||
SPAMSHIELD
|
SPAMSHIELD
|
||||||
@@ -351,62 +455,69 @@ enum AlertCategory {
|
|||||||
SPAM_SMS
|
SPAM_SMS
|
||||||
SYNTHETIC_VOICE
|
SYNTHETIC_VOICE
|
||||||
VOICE_MISMATCH
|
VOICE_MISMATCH
|
||||||
CALL_QUALITY
|
|
||||||
CALL_ANOMALY
|
CALL_ANOMALY
|
||||||
|
CALL_QUALITY
|
||||||
CALL_EVENT
|
CALL_EVENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NormalizedAlertSeverity {
|
||||||
|
LOW
|
||||||
|
INFO
|
||||||
|
MEDIUM
|
||||||
|
WARNING
|
||||||
|
HIGH
|
||||||
|
CRITICAL
|
||||||
|
}
|
||||||
|
|
||||||
enum CorrelationStatus {
|
enum CorrelationStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
RESOLVED
|
RESOLVED
|
||||||
FALSE_POSITIVE
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EntityType {
|
|
||||||
PHONE_NUMBER
|
|
||||||
EMAIL
|
|
||||||
USER_ID
|
|
||||||
CALL_ID
|
|
||||||
IP_ADDRESS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model NormalizedAlert {
|
model NormalizedAlert {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
source AlertSource
|
source AlertSource
|
||||||
category AlertCategory
|
category AlertCategory
|
||||||
severity Severity
|
severity NormalizedAlertSeverity
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
title String
|
||||||
title String
|
description String
|
||||||
description String
|
entities Json
|
||||||
entities Json // [{ type: EntityType, value: string }]
|
sourceAlertId String
|
||||||
sourceAlertId String
|
groupId String?
|
||||||
groupId String?
|
payload Json?
|
||||||
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
createdAt DateTime
|
||||||
payload Json
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@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([groupId])
|
||||||
@@index([sourceAlertId])
|
|
||||||
@@index([source])
|
@@index([source])
|
||||||
@@index([severity])
|
@@index([severity])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([userId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model CorrelationGroup {
|
model CorrelationGroup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "corr_user_idx")
|
entities Json
|
||||||
entities Json // [{ type: EntityType, value: string }]
|
highestSeverity NormalizedAlertSeverity
|
||||||
highestSeverity Severity
|
status CorrelationStatus @default(ACTIVE)
|
||||||
status CorrelationStatus @default(ACTIVE)
|
alertCount Int @default(0)
|
||||||
alertCount Int @default(0)
|
|
||||||
alerts NormalizedAlert[]
|
|
||||||
summary String?
|
summary String?
|
||||||
resolvedAt DateTime?
|
resolvedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
alerts NormalizedAlert[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ async function main() {
|
|||||||
create: {
|
create: {
|
||||||
email: "dev@shieldai.local",
|
email: "dev@shieldai.local",
|
||||||
name: "Dev User",
|
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 { PrismaClient } from '@prisma/client';
|
||||||
import { FieldEncryptionService } from './services/field-encryption.service';
|
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;
|
export default prisma;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Services (from @shieldai/db)
|
||||||
|
// ============================================
|
||||||
export { FieldEncryptionService };
|
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 };
|
export type { PrismaClient };
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import crypto from 'crypto';
|
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;
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
export class FieldEncryptionService {
|
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:*",
|
"@shieldai/shared-notifications": "workspace:*",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.0",
|
||||||
|
"@jest/globals": "^29.7.0",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
"typescript": "^5.0.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",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,5 +15,9 @@
|
|||||||
"@shieldai/types": "workspace:*",
|
"@shieldai/types": "workspace:*",
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"ioredis": "^5.4.0"
|
"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 { Queue, Worker, Job } from 'bullmq';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { tierConfig, getTierFeatures } from '@shieldsai/shared-billing';
|
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
|
* 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 {
|
export function hashPhoneNumber(phoneNumber: string): string {
|
||||||
let hash = 0;
|
const hash = crypto.createHash('sha256').update(phoneNumber).digest('hex');
|
||||||
for (let i = 0; i < phoneNumber.length; i++) {
|
return `sha256_${hash}`;
|
||||||
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return `hash_${Math.abs(hash)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
|||||||
@@ -5,5 +5,18 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
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",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3",
|
"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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
"@shieldai/correlation": "workspace:*",
|
"@shieldai/correlation": "workspace:*",
|
||||||
"node-cache": "^5.1.2"
|
"node-cache": "^5.1.2"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
|
||||||
import {
|
import {
|
||||||
NotificationService,
|
NotificationService,
|
||||||
NotificationPriority,
|
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';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
function hashIdentifier(identifier: string): string {
|
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 { tierConfig } from '@shieldsai/shared-billing';
|
||||||
import { darkwatchScanQueue } from '@shieldsai/jobs';
|
import { darkwatchScanQueue } from '@shieldsai/jobs';
|
||||||
import { randomUUID } from 'crypto';
|
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';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
export function normalizeValue(type: WatchlistType, value: string): string {
|
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 { createHash } from 'crypto';
|
||||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,21 @@ export class WebhookHandler {
|
|||||||
private secret: string;
|
private secret: string;
|
||||||
|
|
||||||
constructor(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.
|
* Verify HMAC signature of incoming webhook payload.
|
||||||
*/
|
*/
|
||||||
verifySignature(payload: string, signature: string | string[]): boolean {
|
verifySignature(payload: string, signature: string | string[]): boolean {
|
||||||
|
if (!this.secret) return false;
|
||||||
if (!signature) return false;
|
if (!signature) return false;
|
||||||
|
|
||||||
const sigArray = Array.isArray(signature) ? signature : [signature];
|
const sigArray = Array.isArray(signature) ? signature : [signature];
|
||||||
@@ -39,7 +47,7 @@ export class WebhookHandler {
|
|||||||
): Promise<{ eventId: string; scanTriggered: boolean }> {
|
): Promise<{ eventId: string; scanTriggered: boolean }> {
|
||||||
const payloadStr = JSON.stringify(payload);
|
const payloadStr = JSON.stringify(payload);
|
||||||
|
|
||||||
if (signature && !this.verifySignature(payloadStr, signature)) {
|
if (!signature || !this.verifySignature(payloadStr, signature)) {
|
||||||
throw new Error("Webhook signature verification failed");
|
throw new Error("Webhook signature verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +196,9 @@ export class WebhookHandler {
|
|||||||
private normalizeEventType(eventType: string): WebhookEventType {
|
private normalizeEventType(eventType: string): WebhookEventType {
|
||||||
const upper = eventType.toUpperCase().replace(/\s+/g, "_");
|
const upper = eventType.toUpperCase().replace(/\s+/g, "_");
|
||||||
const validTypes: WebhookEventType[] = [WebhookEventType.SCAN_TRIGGER, WebhookEventType.BREACH_DETECTED, WebhookEventType.SUBSCRIPTION_CHANGE];
|
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",
|
"dev": "tsx watch src/index.ts",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"eslint": "^8.56.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 }> = [];
|
const results: Array<{ type: CarrierType; healthy: boolean }> = [];
|
||||||
|
|
||||||
for (const [type, carrier] of this.carriers.entries()) {
|
for (const [type, carrier] of this.carriers.entries()) {
|
||||||
|
const healthy = await carrier.isHealthy();
|
||||||
results.push({
|
results.push({
|
||||||
type,
|
type,
|
||||||
healthy: carrier.isHealthy(),
|
healthy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ export interface SmsClassificationResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SmsClassificationContext {
|
||||||
|
text: string;
|
||||||
|
senderPhoneNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SmsClassifier {
|
export interface SmsClassifier {
|
||||||
classify(text: string): Promise<SmsClassificationResult>;
|
classify(textOrContext: string | SmsClassificationContext): Promise<SmsClassificationResult>;
|
||||||
getMetrics(): {
|
getMetrics(): {
|
||||||
totalClassified: number;
|
totalClassified: number;
|
||||||
spamDetected: number;
|
spamDetected: number;
|
||||||
@@ -44,7 +49,10 @@ export class BertSmsClassifier implements SmsClassifier {
|
|||||||
this.spamShield = spamShield;
|
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
|
// Feature 1: Language Analysis
|
||||||
const language = this.analyzeLanguage(text);
|
const language = this.analyzeLanguage(text);
|
||||||
|
|
||||||
@@ -85,9 +93,11 @@ export class BertSmsClassifier implements SmsClassifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combine with reputation score if available
|
// Combine with reputation score if available
|
||||||
const reputation = await this.spamShield.checkReputation('placeholder');
|
if (senderPhoneNumber) {
|
||||||
if (reputation.isSpam) {
|
const reputation = await this.spamShield.checkReputation(senderPhoneNumber);
|
||||||
spamScore += REPUTATION_SCORE_WEIGHT;
|
if (reputation.isSpam) {
|
||||||
|
spamScore += REPUTATION_SCORE_WEIGHT;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSpam = spamScore > SMS_SPAM_THRESHOLD;
|
const isSpam = spamScore > SMS_SPAM_THRESHOLD;
|
||||||
|
|||||||
@@ -116,8 +116,23 @@ export class DecisionEngine {
|
|||||||
async evaluate(context: DecisionContext): Promise<DecisionResult> {
|
async evaluate(context: DecisionContext): Promise<DecisionResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const reqId = context.requestId ?? 'unknown';
|
const reqId = context.requestId ?? 'unknown';
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
const evaluation = (async () => {
|
||||||
const [reputationScore, ruleScore, behavioralScore, userHistoryScore] = await Promise.all([
|
const [reputationScore, ruleScore, behavioralScore, userHistoryScore] = await Promise.all([
|
||||||
this.calculateReputationScore(context.cachedReputation),
|
this.calculateReputationScore(context.cachedReputation),
|
||||||
this.calculateRuleScore(context.ruleMatches),
|
this.calculateRuleScore(context.ruleMatches),
|
||||||
@@ -151,25 +166,25 @@ export class DecisionEngine {
|
|||||||
executedAt: new Date(),
|
executedAt: new Date(),
|
||||||
requestId: reqId,
|
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) {
|
} catch (error) {
|
||||||
console.error(`[DecisionEngine] [${reqId}] Evaluation error:`, error);
|
console.error(`[DecisionEngine] [${reqId}] Evaluation error:`, error);
|
||||||
|
|
||||||
if (this.config.fallbackOnTimeout) {
|
if (this.config.fallbackOnTimeout) {
|
||||||
return {
|
return { ...fallback, reasons: ['Fallback decision due to evaluation error'] };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { PrismaClient, SpamRule } from '@prisma/client';
|
|||||||
import { generateRequestId } from '@shieldai/types';
|
import { generateRequestId } from '@shieldai/types';
|
||||||
import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation';
|
import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation';
|
||||||
|
|
||||||
|
export interface CompiledRule {
|
||||||
|
rule: SpamRule;
|
||||||
|
compiledPattern: RegExp;
|
||||||
|
compiledCaseInsensitive?: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuleMatch {
|
export interface RuleMatch {
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
ruleName: string;
|
ruleName: string;
|
||||||
@@ -25,10 +31,10 @@ const DEFAULT_CONFIG: Required<RuleEngineConfig> = {
|
|||||||
|
|
||||||
export class RuleEngine {
|
export class RuleEngine {
|
||||||
private readonly config: Required<RuleEngineConfig>;
|
private readonly config: Required<RuleEngineConfig>;
|
||||||
private numberPatternRules: SpamRule[] = [];
|
private numberPatternRules: CompiledRule[] = [];
|
||||||
private behavioralRules: SpamRule[] = [];
|
private behavioralRules: CompiledRule[] = [];
|
||||||
private contentRules: SpamRule[] = [];
|
private contentRules: CompiledRule[] = [];
|
||||||
private allRules: SpamRule[] = [];
|
private allRules: CompiledRule[] = [];
|
||||||
private lastLoadTime: Date | null = null;
|
private lastLoadTime: Date | null = null;
|
||||||
private readonly prisma: PrismaClient;
|
private readonly prisma: PrismaClient;
|
||||||
|
|
||||||
@@ -52,11 +58,17 @@ export class RuleEngine {
|
|||||||
orderBy: { priority: 'desc' },
|
orderBy: { priority: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const validatedRules: SpamRule[] = [];
|
const compiledRules: CompiledRule[] = [];
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
try {
|
try {
|
||||||
validateRegexPattern(rule.pattern);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof RegexValidationError) {
|
if (error instanceof RegexValidationError) {
|
||||||
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk: ${error.reason}, skipping`);
|
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.allRules = compiledRules;
|
||||||
this.numberPatternRules = validatedRules.filter(r => (r as any).category === 'number_pattern');
|
this.numberPatternRules = compiledRules.filter(r => (r.rule as any).category === 'number_pattern');
|
||||||
this.behavioralRules = validatedRules.filter(r => (r as any).category === 'behavioral');
|
this.behavioralRules = compiledRules.filter(r => (r.rule as any).category === 'behavioral');
|
||||||
this.contentRules = validatedRules.filter(r => (r as any).category === 'content');
|
this.contentRules = compiledRules.filter(r => (r.rule as any).category === 'content');
|
||||||
this.lastLoadTime = now;
|
this.lastLoadTime = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,26 +92,20 @@ export class RuleEngine {
|
|||||||
|
|
||||||
const matches: RuleMatch[] = [];
|
const matches: RuleMatch[] = [];
|
||||||
|
|
||||||
for (const rule of this.allRules) {
|
for (const compiled of this.allRules) {
|
||||||
try {
|
try {
|
||||||
validateRegexPattern(rule.pattern);
|
if (compiled.compiledPattern.test(phoneNumber)) {
|
||||||
const pattern = new RegExp(rule.pattern);
|
|
||||||
if (pattern.test(phoneNumber)) {
|
|
||||||
matches.push({
|
matches.push({
|
||||||
ruleId: rule.id,
|
ruleId: compiled.rule.id,
|
||||||
ruleName: rule.name,
|
ruleName: compiled.rule.name,
|
||||||
pattern: rule.pattern,
|
pattern: compiled.rule.pattern,
|
||||||
score: (rule as any).score,
|
score: (compiled.rule as any).score,
|
||||||
priority: (rule as any).priority as 'high' | 'medium' | 'low',
|
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
|
||||||
matchedAt: new Date(),
|
matchedAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RegexValidationError) {
|
console.error(`[RuleEngine] [req:${generateRequestId()}] Evaluation error for rule ${compiled.rule.id}:`, error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,26 +119,20 @@ export class RuleEngine {
|
|||||||
|
|
||||||
const matches: RuleMatch[] = [];
|
const matches: RuleMatch[] = [];
|
||||||
|
|
||||||
for (const rule of this.contentRules) {
|
for (const compiled of this.contentRules) {
|
||||||
try {
|
try {
|
||||||
validateRegexPattern(rule.pattern);
|
if (compiled.compiledCaseInsensitive!.test(smsBody)) {
|
||||||
const pattern = new RegExp(rule.pattern, 'i');
|
|
||||||
if (pattern.test(smsBody)) {
|
|
||||||
matches.push({
|
matches.push({
|
||||||
ruleId: rule.id,
|
ruleId: compiled.rule.id,
|
||||||
ruleName: rule.name,
|
ruleName: compiled.rule.name,
|
||||||
pattern: rule.pattern,
|
pattern: compiled.rule.pattern,
|
||||||
score: (rule as any).score,
|
score: (compiled.rule as any).score,
|
||||||
priority: (rule as any).priority as 'high' | 'medium' | 'low',
|
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
|
||||||
matchedAt: new Date(),
|
matchedAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RegexValidationError) {
|
console.error(`[RuleEngine] [req:${generateRequestId()}] SMS evaluation error for rule ${compiled.rule.id}:`, error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,19 +140,19 @@ export class RuleEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getNumberPatternRules(): SpamRule[] {
|
getNumberPatternRules(): SpamRule[] {
|
||||||
return [...this.numberPatternRules];
|
return this.numberPatternRules.map(r => r.rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBehavioralRules(): SpamRule[] {
|
getBehavioralRules(): SpamRule[] {
|
||||||
return [...this.behavioralRules];
|
return this.behavioralRules.map(r => r.rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
getContentRules(): SpamRule[] {
|
getContentRules(): SpamRule[] {
|
||||||
return [...this.contentRules];
|
return this.contentRules.map(r => r.rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllRules(): SpamRule[] {
|
getAllRules(): SpamRule[] {
|
||||||
return [...this.allRules];
|
return this.allRules.map(r => r.rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshRules(): Promise<void> {
|
async refreshRules(): Promise<void> {
|
||||||
|
|||||||
@@ -202,29 +202,23 @@ export class SpamShieldService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validated = this.validatePhoneNumber(phoneNumber);
|
const validated = this.validatePhoneNumber(phoneNumber);
|
||||||
const rules = await this.getActiveRules();
|
const matches = this.ruleEngine
|
||||||
|
? await this.ruleEngine.evaluate(validated)
|
||||||
|
: [];
|
||||||
|
|
||||||
const ruleMatches: string[] = [];
|
const ruleMatchIds = matches.map(m => m.ruleId);
|
||||||
let confidence = 0;
|
const confidence = Math.min(matches.reduce((sum, m) => sum + m.score, 0), 1.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 decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
||||||
|
|
||||||
|
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||||
|
|
||||||
const auditLog = await prisma.spamAuditLog.create({
|
const auditLog = await prisma.spamAuditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: 'system',
|
userId: 'system',
|
||||||
phoneNumber: validated,
|
phoneNumber: encrypted,
|
||||||
decision: decision as any,
|
decision: decision as any,
|
||||||
reason: `Rule-based analysis`,
|
reason: `Rule-based analysis`,
|
||||||
ruleId: ruleMatches[0],
|
ruleId: ruleMatchIds[0],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,11 +229,11 @@ export class SpamShieldService {
|
|||||||
validated,
|
validated,
|
||||||
decision,
|
decision,
|
||||||
confidence,
|
confidence,
|
||||||
ruleMatches
|
ruleMatchIds
|
||||||
).catch((err) => console.error(`[Correlation] SpamShield emit failed:`, err));
|
).catch((err) => console.error(`[Correlation] SpamShield emit failed:`, err));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { decision, confidence, ruleMatches };
|
return { decision, confidence, ruleMatches: ruleMatchIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordFeedback(
|
async recordFeedback(
|
||||||
@@ -253,6 +247,18 @@ export class SpamShieldService {
|
|||||||
throw new Error('Feedback loop disabled via feature flag');
|
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 validated = this.validatePhoneNumber(phoneNumber);
|
||||||
const encrypted = FieldEncryptionService.encrypt(validated);
|
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||||
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { DecisionResult } from '../engine/decision-engine';
|
import { DecisionResult } from '../engine/decision-engine';
|
||||||
|
|
||||||
export interface AlertEvent {
|
export interface AlertEvent {
|
||||||
@@ -29,14 +30,20 @@ export interface AlertServerConfig {
|
|||||||
heartbeatIntervalMs?: number;
|
heartbeatIntervalMs?: number;
|
||||||
maxClients?: number;
|
maxClients?: number;
|
||||||
enableLogging?: boolean;
|
enableLogging?: boolean;
|
||||||
|
enableAuth?: boolean;
|
||||||
|
jwtSecret?: string;
|
||||||
|
allowedOrigins?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: Required<AlertServerConfig> = {
|
const DEFAULT_CONFIG: Required<AlertServerConfig> = {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
heartbeatIntervalMs: 30000,
|
heartbeatIntervalMs: 30000,
|
||||||
maxClients: 1000,
|
maxClients: 100,
|
||||||
enableLogging: true,
|
enableLogging: true,
|
||||||
|
enableAuth: true,
|
||||||
|
jwtSecret: process.env.SPAMSHIELD_JWT_SECRET || '',
|
||||||
|
allowedOrigins: ['http://localhost:3000'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AlertServer {
|
export class AlertServer {
|
||||||
@@ -57,7 +64,32 @@ export class AlertServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupWebSocketHandlers(): void {
|
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 clientId = req.headers['x-client-id'] as string || `client-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
const subscription: ClientSubscription = {
|
const subscription: ClientSubscription = {
|
||||||
@@ -281,6 +313,21 @@ export class AlertServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hashPhoneNumber(phoneNumber: string): string {
|
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', () => {
|
describe('enableFeedbackLoop flag', () => {
|
||||||
it('throws when feedback loop is disabled in recordFeedback', async () => {
|
it('throws when feedback loop is disabled in recordFeedback', async () => {
|
||||||
process.env.FLAG_ENABLEFEEDBACKLOOP = 'false';
|
process.env.FLAG_ENABLEFEEDBACKLOOP = 'false';
|
||||||
|
|||||||
@@ -5,5 +5,22 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
"@shieldai/correlation": "workspace:*",
|
"@shieldai/correlation": "workspace:*",
|
||||||
"node-cache": "^5.1.2"
|
"node-cache": "^5.1.2"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
|
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
|
||||||
import {
|
import {
|
||||||
voicePrintEnv,
|
voicePrintEnv,
|
||||||
AnalysisJobStatus,
|
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"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["coverage/**"]
|
"outputs": ["coverage/**"]
|
||||||
},
|
},
|
||||||
|
"test:coverage": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["coverage/**"]
|
||||||
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user