Compare commits

...

11 Commits

Author SHA1 Message Date
baa216d62c turbo 2026-05-03 22:45:03 -04:00
f2593c1e67 use crypto package instead 2026-05-03 22:44:48 -04:00
a4684e9121 Fix SMS classifier test mock: add defaultScores and metadataLimits exports (FRE-4509)
The test mock for spamshield.config was missing defaultScores and
metadataLimits exports that are imported by spamshield.service.ts,
causing 8 tests to fail with 'No defaultScores export is defined'.
2026-05-02 20:23:29 -04:00
Senior Engineer
91e4985a8e FRE-4474 Phase 5: Verify and resolve security review findings for SpamShield and Cross-Service Correlation
- FRE-4499 (SpamShield): Verified 6 security fixes (2 High, 4 Medium)
  - S01: Pre-compiled regex in RuleEngine (ReDoS fix)
  - S02: SmsClassifier accepts senderPhoneNumber context
  - S03: AlertServer JWT auth + origin validation
  - S04: SHA-256 phone hashing (PII protection)
  - S05: DecisionEngine timeout enforcement via Promise.race
  - S06: CarrierFactory.getAllCarriers properly async/await

- FRE-4500 (Correlation): Verified 7 security fixes (2 Critical, 2 High, 2 Medium, 1 Low)
  - C1: Ingest endpoints auth via request.user.id
  - C2: IDOR protection on group endpoints (userId filter)
  - H3: JWT middleware registered in server.ts
  - H4: Fastify schema validation on all routes
  - M6: Payload sanitization with depth limit and circular ref detection
  - L7: CORS origin restricted to env var

- Resolved liveness incidents FRE-4652 and FRE-4654
- All Phase 5 child issues now complete
2026-05-02 18:36:29 -04:00
0afdf8b6e8 FRE-4500: Fix security review findings (Critical/High/Medium/Low)
- Critical #1: Add auth check to ingest endpoints (use request.user.id)
- Critical #2: Add IDOR protection on group endpoints (userId ownership)
- High #3: Register auth middleware in server.ts (populates request.user)
- High #4: Add Fastify schema validation to all route handlers
- Medium #5: Add NormalizedAlert/CorrelationGroup models to Prisma schema
- Medium #6: Sanitize payload storage in normalizer (depth limit, circular ref)
- Low #7: Restrict CORS origins (use CORS_ORIGIN env var)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 16:40:01 -04:00
274afa6335 FRE-4499: Fix security review findings (S01-S06)
- S01 (High): Pre-compile regex patterns in RuleEngine.loadActiveRules() and
  cache them; eliminate per-evaluation RegExp construction in rule-engine.ts
  and spamshield.service.ts (ReDoS mitigation)
- S02 (High): SMS classifier now accepts optional senderPhoneNumber via
  SmsClassificationContext; reputation check uses actual sender instead of
  hardcoded 'placeholder'
- S03 (Medium): AlertServer (services/spamshield) now enforces JWT auth,
  origin allowlist, and max client limit on WebSocket connections
- S04 (Medium): hashPhoneNumber() uses SHA-256 (crypto.createHash) instead
  of reversible hex encoding (Buffer.toString('hex'))
- S05 (Medium): DecisionEngine.evaluate() wraps evaluation in Promise.race
  with configurable evaluationTimeout; returns fallback decision on timeout
- S06 (Medium): CarrierFactory.getAllCarriers() is now async and properly
  awaits isHealthy() promises instead of returning raw Promise objects

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 15:58:49 -04:00
24bc9c235f Consolidate @shieldai/db and @shieldsai/shared-db packages (FRE-4603)
- Merged singleton pattern + type exports from shared-db
- Kept FieldEncryptionService from original db package
- Upgraded to Prisma v6.2.0 (newer version)
- Adopted shared-db's complete schema for multi-service platform
- Updated 17 consumer imports across darkwatch, voiceprint, jobs, api
- Standardized on @shieldai/db namespace

Files changed:
- packages/db/package.json (v0.1.0 → v0.2.0)
- packages/db/src/index.ts (consolidated exports)
- packages/db/prisma/schema.prisma (merged schema)
- packages/db/prisma/seed.ts (updated for new schema)
- 17 consumer files updated

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 15:06:02 -04:00
93ff4885ee Add integration tests README documentation (FRE-4522)
Documentation for integration test suite including:
- Test file descriptions and coverage
- External provider mock configuration
- Running tests commands
- CI integration requirements
- Environment variables needed
- Test strategy and error scenarios
2026-05-02 13:23:12 -04:00
67622a2f11 Add integration tests for notification services (FRE-4522)
Comprehensive integration test suite for notification services:
- EmailService integration tests (Resend provider)
- SMSService integration tests (Twilio provider)
- PushService integration tests (FCM/APNs providers)
- NotificationService integration tests (orchestration layer)

Test coverage includes:
- Successful notification delivery
- Error handling (API errors, network timeouts, invalid inputs)
- Rate limiting enforcement
- Batch operations with partial failures
- Notification preferences and deduplication
- Template-based email sending
- Metadata and attachment handling

Total: ~1400 lines across 4 test files
2026-05-02 13:22:41 -04:00
bdf8ad30b6 Apply security remediations for FRE-4498 (FRE-4612)
Security findings from April 30 review were claimed fixed but never committed.
Applied all remediations:

HIGH:
- WebhookHandler: fail fast when DARKWATCH_WEBHOOK_SECRET missing instead of defaulting to hardcoded secret
- field-encryption.service: require PII_ENCRYPTION_KEY at startup instead of defaulting

MEDIUM:
- WebhookHandler: make signature required (was optional, accepted unsigned events)
- WebhookHandler: reject unknown event types instead of silently defaulting to SCAN_TRIGGER
- scheduler.routes + webhook.routes: add ownership checks on /:userId endpoints (IDOR)

LOW:
- webhook.routes: generic error responses, full error logged server-side

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 13:03:28 -04:00
f34adc5e82 Add null checks in feedback processing pipeline (FRE-4514)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 13:01:02 -04:00
66 changed files with 3116 additions and 576 deletions

View File

@@ -84,11 +84,18 @@ jobs:
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
- name: Run tests
run: npm run test
- name: Run tests with coverage
run: npm run test:coverage
env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
REDIS_URL: "redis://localhost:6379"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
flags: unittests
name: shieldai-coverage
fail_on_empty: false
docker-build:
name: Docker Build

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"6abb2efbabfd492c","duration":728,"sha":"a4684e912110fdf2702981e23494be96df91b86f","dirty_hash":"85a4cfa756e84c777eeff88ca5a3d970b636968eb72658995bfec15eeba2d9b4"}

BIN
.turbo/cache/6abb2efbabfd492c.tar.zst vendored Normal file

Binary file not shown.

View 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"]}

View File

@@ -0,0 +1 @@
{"hash":"df8d582601d96e8d","duration":684,"sha":"274afa63352200107e5e3ed5a783555fe3c68e37","dirty_hash":"1b22568f1b7a3df274940e36b290211b3251b700c1e1286bc843ed3e00b07e05"}

BIN
.turbo/cache/df8d582601d96e8d.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -10,15 +10,17 @@
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"test:coverage": "turbo run test:coverage",
"db:migrate": "turbo run db:migrate",
"db:seed": "turbo run db:seed",
"lint": "turbo run lint"
},
"devDependencies": {
"@types/node": "^25.6.0",
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5",
"turbo": "^2.3.0",
"typescript": "^5.7.0",
"vitest": "^4.1.5"
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20.0.0"

View File

@@ -6,6 +6,7 @@
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
@@ -19,5 +20,9 @@
"fastify": "^5.2.0",
"@shieldai/darkwatch": "workspace:*",
"@shieldai/voiceprint": "workspace:*"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
}
}

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SMSClassifierService } from '../services/spamshield/spamshield.service';
// Mock shared-db before anything else (Prisma client is not generated in test env)
vi.mock('@shieldsai/shared-db', () => ({
vi.mock('@shieldai/db', () => ({
prisma: {},
SpamFeedback: {},
}));
@@ -35,6 +35,31 @@ vi.mock('../services/spamshield/spamshield.config', () => ({
VERY_HIGH: 'very_high',
},
spamRateLimits: {},
defaultScores: {
defaultReputationConfidence: 0.0,
defaultReputationLowConfidence: 0.1,
defaultBaseConfidence: 0.5,
defaultMaxConfidence: 1.0,
featureWeights: {
urlPresent: 0.1,
highEmojiDensity: 0.15,
urgencyKeyword: 0.2,
excessiveCaps: 0.15,
},
defaultSpamScore: 0.0,
highReputationThreshold: 0.7,
reputationWeightInCombinedScore: 0.4,
shortDurationScore: 0.2,
voipScore: 0.15,
unusualHoursScore: 0.1,
hiyaWeightInCombinedScore: 0.7,
truecallerWeightInCombinedScore: 0.3,
},
metadataLimits: {
maxMetadataSizeBytes: 4096,
maxMetadataKeys: 20,
maxMetadataValueSizeBytes: 512,
},
}));
describe('SMSClassifierService', () => {

View File

@@ -1,30 +1,37 @@
import { FastifyInstance } from "fastify";
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { correlationService } from "@shieldai/correlation";
type AuthUser = { id?: string };
function getUserId(request: FastifyRequest): string | undefined {
return (request.user as AuthUser | undefined)?.id;
}
export function correlationRoutes(fastify: FastifyInstance) {
fastify.get("/dashboard", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const timeWindow = parseInt((request.query as any).timeWindow as string) || 60;
const timeWindow =
parseInt(
(request.query as Record<string, string>).timeWindow as string
) || 60;
const data = await correlationService.getDashboardData(userId, timeWindow);
return reply.send(data);
});
fastify.get("/groups", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelationGroups({
userId,
status: query.status || undefined,
status: (query.status as any) || undefined,
timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow)
: 60,
@@ -34,43 +41,91 @@ export function correlationRoutes(fastify: FastifyInstance) {
return reply.send(result);
});
fastify.get("/groups/:groupId", async (request, reply) => {
const groupId = (request.params as any).groupId;
const group = await correlationService.getGroupById(groupId);
fastify.get(
"/groups/:groupId",
{
schema: {
params: {
type: "object",
properties: {
groupId: { type: "string", format: "uuid" },
},
required: ["groupId"],
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
if (!group) {
return reply.code(404).send({ error: "Correlation group not found" });
const groupId = (request.params as Record<string, string>).groupId;
const group = await correlationService.getGroupById(groupId, userId);
if (!group) {
return reply.code(404).send({ error: "Correlation group not found" });
}
return reply.send(group);
}
);
return reply.send(group);
});
fastify.patch(
"/groups/:groupId/resolve",
{
schema: {
params: {
type: "object",
properties: {
groupId: { type: "string", format: "uuid" },
},
required: ["groupId"],
},
body: {
type: "object",
properties: {
status: { type: "string", enum: ["RESOLVED", "ACTIVE"] },
},
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
fastify.patch("/groups/:groupId/resolve", async (request, reply) => {
const groupId = (request.params as any).groupId;
const body = (request.body as any) || {};
const status = body.status || "RESOLVED";
const group = await correlationService.resolveGroup(groupId, status);
const groupId = (request.params as Record<string, string>).groupId;
const body = request.body as Record<string, string> | undefined;
const status = body?.status || "RESOLVED";
const group = await correlationService.resolveGroup(
groupId,
userId,
status
);
if (!group) {
return reply.code(404).send({ error: "Correlation group not found" });
if (!group) {
return reply.code(404).send({ error: "Correlation group not found" });
}
return reply.send(group);
}
return reply.send(group);
});
);
fastify.get("/alerts", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelatedAlerts({
userId,
source: query.source || undefined,
category: query.category || undefined,
severity: query.severity || undefined,
source: (query.source as any) || undefined,
category: (query.category as any) || undefined,
severity: (query.severity as any) || undefined,
timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow)
: 60,
@@ -80,72 +135,200 @@ export function correlationRoutes(fastify: FastifyInstance) {
return reply.send(result);
});
fastify.post("/ingest/darkwatch", async (request, reply) => {
const body = request.body as any;
const alert = await correlationService.ingestDarkWatchAlert(
body.userId,
body.sourceAlertId,
{
exposureId: body.exposureId,
breachName: body.breachName,
severity: body.severity,
channel: body.channel,
dataType: body.dataType,
dataSource: body.dataSource,
fastify.post(
"/ingest/darkwatch",
{
schema: {
body: {
type: "object",
properties: {
sourceAlertId: { type: "string" },
exposureId: { type: "string" },
breachName: { type: "string", maxLength: 500 },
severity: { type: "string", maxLength: 20 },
channel: { type: "string", maxLength: 50 },
dataType: { type: "array", items: { type: "string" } },
dataSource: { type: "string", maxLength: 100 },
},
required: ["sourceAlertId", "breachName", "severity", "channel"],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
);
return reply.code(201).send(alert);
});
fastify.post("/ingest/spamshield", async (request, reply) => {
const body = request.body as any;
const alert = await correlationService.ingestSpamShieldAlert(
body.userId,
body.sourceAlertId,
{
phoneNumber: body.phoneNumber,
decision: body.decision,
confidence: body.confidence,
reasons: body.reasons,
channel: body.channel,
hiyaReputationScore: body.hiyaReputationScore,
truecallerSpamScore: body.truecallerSpamScore,
}
);
return reply.code(201).send(alert);
});
const body = request.body as Record<string, unknown>;
const alert = await correlationService.ingestDarkWatchAlert(
userId,
body.sourceAlertId as string,
{
exposureId: body.exposureId as string,
breachName: body.breachName as string,
severity: body.severity as string,
channel: body.channel as string,
dataType: body.dataType as string[] | undefined,
dataSource: body.dataSource as string | undefined,
}
);
return reply.code(201).send(alert);
}
);
fastify.post("/ingest/voiceprint", async (request, reply) => {
const body = request.body as any;
const alert = await correlationService.ingestVoicePrintAlert(
body.userId,
body.sourceAlertId,
{
jobId: body.jobId,
verdict: body.verdict,
syntheticScore: body.syntheticScore,
confidence: body.confidence,
matchedEnrollmentId: body.matchedEnrollmentId,
matchedSimilarity: body.matchedSimilarity,
analysisType: body.analysisType,
fastify.post(
"/ingest/spamshield",
{
schema: {
body: {
type: "object",
properties: {
sourceAlertId: { type: "string" },
phoneNumber: { type: "string", maxLength: 20 },
decision: { type: "string", enum: ["BLOCK", "FLAG", "ALLOW"] },
confidence: { type: "number", minimum: 0, maximum: 1 },
reasons: { type: "array", items: { type: "string" } },
channel: { type: "string", enum: ["call", "sms"] },
hiyaReputationScore: { type: "number" },
truecallerSpamScore: { type: "number" },
},
required: ["sourceAlertId", "phoneNumber", "decision", "confidence"],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
);
return reply.code(201).send(alert);
});
fastify.post("/ingest/call-analysis", async (request, reply) => {
const body = request.body as any;
const alert = await correlationService.ingestCallAnalysisAlert(
body.userId,
body.sourceAlertId,
{
callId: body.callId,
eventType: body.eventType,
mosScore: body.mosScore,
anomaly: body.anomaly,
sentiment: body.sentiment,
const body = request.body as Record<string, unknown>;
const alert = await correlationService.ingestSpamShieldAlert(
userId,
body.sourceAlertId as string,
{
phoneNumber: body.phoneNumber as string,
decision: body.decision as string,
confidence: body.confidence as number,
reasons: body.reasons as string[] | undefined,
channel: body.channel as "call" | "sms" | undefined,
hiyaReputationScore: body.hiyaReputationScore as
| number
| undefined,
truecallerSpamScore: body.truecallerSpamScore as
| number
| undefined,
}
);
return reply.code(201).send(alert);
}
);
fastify.post(
"/ingest/voiceprint",
{
schema: {
body: {
type: "object",
properties: {
sourceAlertId: { type: "string" },
jobId: { type: "string" },
verdict: {
type: "string",
enum: ["SYNTHETIC", "NATURAL", "UNCERTAIN"],
},
syntheticScore: { type: "number", minimum: 0, maximum: 1 },
confidence: { type: "number", minimum: 0, maximum: 1 },
matchedEnrollmentId: { type: "string" },
matchedSimilarity: { type: "number" },
analysisType: { type: "string", maxLength: 50 },
},
required: [
"sourceAlertId",
"jobId",
"verdict",
"syntheticScore",
"confidence",
],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
);
return reply.code(201).send(alert);
});
const body = request.body as Record<string, unknown>;
const alert = await correlationService.ingestVoicePrintAlert(
userId,
body.sourceAlertId as string,
{
jobId: body.jobId as string,
verdict: body.verdict as string,
syntheticScore: body.syntheticScore as number,
confidence: body.confidence as number,
matchedEnrollmentId: body.matchedEnrollmentId as
| string
| undefined,
matchedSimilarity: body.matchedSimilarity as number | undefined,
analysisType: body.analysisType as string | undefined,
}
);
return reply.code(201).send(alert);
}
);
fastify.post(
"/ingest/call-analysis",
{
schema: {
body: {
type: "object",
properties: {
sourceAlertId: { type: "string" },
callId: { type: "string" },
eventType: { type: "string", maxLength: 100 },
mosScore: { type: "number", minimum: 1, maximum: 5 },
anomaly: { type: "string", maxLength: 500 },
sentiment: {
type: "object",
properties: {
label: { type: "string", maxLength: 50 },
score: { type: "number", minimum: 0, maximum: 1 },
},
},
},
required: ["sourceAlertId", "callId"],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const body = request.body as Record<string, unknown>;
const alert = await correlationService.ingestCallAnalysisAlert(
userId,
body.sourceAlertId as string,
{
callId: body.callId as string,
eventType: body.eventType as string | undefined,
mosScore: body.mosScore as number | undefined,
anomaly: body.anomaly as string | undefined,
sentiment: body.sentiment as
| { label: string; score: number }
| undefined,
}
);
return reply.code(201).send(alert);
}
);
}

View File

@@ -1,5 +1,5 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
import { prisma, SubscriptionTier } from '@shieldai/db';
import { tierConfig, SubscriptionTier as BillingTier } from '@shieldsai/shared-billing';
import {
watchlistService,

View File

@@ -21,8 +21,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
fastify.get(
"/:userId",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
const schedule = await scheduler.getSchedule(userId);
const params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
const schedule = await scheduler.getSchedule(params.userId);
if (!schedule) {
return reply.code(404).send({ error: "Schedule not found" });
@@ -35,8 +39,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
fastify.post(
"/:userId/pause",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
await scheduler.pauseSchedule(userId);
const params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
await scheduler.pauseSchedule(params.userId);
return reply.send({ paused: true });
}
);
@@ -44,8 +52,12 @@ export function schedulerRoutes(fastify: FastifyInstance) {
fastify.post(
"/:userId/resume",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
await scheduler.resumeSchedule(userId);
const params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
await scheduler.resumeSchedule(params.userId);
return reply.send({ resumed: true });
}
);

View File

@@ -31,13 +31,8 @@ export function webhookRoutes(fastify: FastifyInstance) {
scanTriggered: result.scanTriggered,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("signature")) {
return reply.code(401).send({ error: message });
}
return reply.code(400).send({ error: message });
console.error("[Webhook] Event processing error:", err);
return reply.code(400).send({ error: "Webhook processing failed" });
}
}
);
@@ -56,11 +51,15 @@ export function webhookRoutes(fastify: FastifyInstance) {
fastify.get(
"/user/:userId",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
const params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
const limit = parseInt((request.query as { limit?: string }).limit || "50");
const offset = parseInt((request.query as { offset?: string }).offset || "0");
const events = await handler.getUserEvents(userId, limit, offset);
const events = await handler.getUserEvents(params.userId, limit, offset);
return reply.send(events);
}
);

View File

@@ -3,7 +3,10 @@ import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import sensible from "@fastify/sensible";
import { extractOrGenerateRequestId } from "@shieldai/types";
import { darkwatchRoutes, voiceprintRoutes, correlationRoutes } from "./routes";
import { authMiddleware } from "./middleware/auth.middleware";
import { darkwatchRoutes } from "./routes/darkwatch.routes";
import { voiceprintRoutes } from "./routes/voiceprint.routes";
import { correlationRoutes } from "./routes/correlation.routes";
const app = Fastify({
logger: {
@@ -12,10 +15,13 @@ const app = Fastify({
});
async function bootstrap() {
await app.register(cors, { origin: true });
await app.register(cors, { origin: process.env.CORS_ORIGIN || "http://localhost:5173" });
await app.register(helmet);
await app.register(sensible);
// Register auth middleware to populate request.user
await app.register(authMiddleware);
app.addHook("onRequest", async (request, _reply) => {
const requestId = extractOrGenerateRequestId(request.headers);
request.id = requestId;

View File

@@ -1,4 +1,4 @@
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
import {
NotificationService,
NotificationPriority,

View File

@@ -1,4 +1,4 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db';
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldai/db';
import { createHash } from 'crypto';
function hashIdentifier(identifier: string): string {

View File

@@ -1,4 +1,4 @@
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldsai/shared-db';
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldai/db';
import { tierConfig } from '@shieldsai/shared-billing';
import { darkwatchScanQueue } from '@shieldsai/jobs';
import { randomUUID } from 'crypto';

View File

@@ -1,4 +1,4 @@
import { prisma, WatchlistType } from '@shieldsai/shared-db';
import { prisma, WatchlistType } from '@shieldai/db';
import { createHash } from 'crypto';
export function normalizeValue(type: WatchlistType, value: string): string {

View File

@@ -1,4 +1,4 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldsai/shared-db';
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldai/db';
import { createHash } from 'crypto';
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';

View File

@@ -1,4 +1,4 @@
import { prisma, SpamFeedback } from '@shieldsai/shared-db';
import { prisma, SpamFeedback } from '@shieldai/db';
import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
import { createHash } from 'crypto';
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
@@ -366,8 +366,27 @@ export class SpamFeedbackService {
confidence?: number,
metadata?: Record<string, any>
): Promise<SpamFeedback> {
// Validate metadata
const validation = this.validateMetadata(metadata);
// Defensive null checks for required fields
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
throw new Error('Feedback: userId is required');
}
if (!phoneNumber || typeof phoneNumber !== 'string' || phoneNumber.trim().length === 0) {
throw new Error('Feedback: phoneNumber is required');
}
if (typeof isSpam !== 'boolean') {
throw new Error('Feedback: isSpam must be a boolean');
}
// Validate confidence range if provided
const validatedConfidence = confidence !== undefined && confidence !== null
? (Number.isFinite(confidence) && confidence >= 0 && confidence <= 1 ? confidence : undefined)
: undefined;
// Treat null metadata as undefined
const effectiveMetadata = metadata !== null ? metadata : undefined;
const validation = this.validateMetadata(effectiveMetadata);
const validatedMetadata = validation.trimmedMetadata;
// Only enable if feature flag is set
@@ -379,7 +398,7 @@ export class SpamFeedbackService {
phoneNumber,
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
isSpam,
confidence,
confidence: validatedConfidence,
feedbackType: 'user_confirmation' as const,
metadata: validatedMetadata,
createdAt: new Date(),
@@ -395,7 +414,7 @@ export class SpamFeedbackService {
phoneNumber,
phoneNumberHash,
isSpam,
confidence,
confidence: validatedConfidence,
feedbackType: 'user_confirmation',
metadata: validatedMetadata,
},

View File

@@ -1,4 +1,4 @@
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
import {
voicePrintEnv,
AnalysisJobStatus,

View 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,
},
},
},
});

View File

@@ -282,10 +282,11 @@ export class CorrelationEngine {
}
public async getGroupById(
groupId: string
groupId: string,
userId: string
): Promise<CorrelationGroupOutput | null> {
const group = await (prisma as any).correlationGroup.findUnique({
where: { id: groupId },
where: { id: groupId, userId },
include: {
alerts: {
orderBy: { createdAt: "asc" },
@@ -298,10 +299,11 @@ export class CorrelationEngine {
public async resolveGroup(
groupId: string,
userId: string,
status: string = CorrelationStatus.RESOLVED
): Promise<CorrelationGroupOutput | null> {
const group = await (prisma as any).correlationGroup.update({
where: { id: groupId },
where: { id: groupId, userId },
data: {
status,
resolvedAt: new Date(),

View File

@@ -8,6 +8,24 @@ import {
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
function sanitizePayload(
payload: Record<string, unknown>,
maxDepth: number = 5
): Record<string, unknown> {
const seen = new WeakSet<object>();
const clone = (obj: unknown, depth: number): unknown => {
if (depth > maxDepth) return "[max depth]";
if (obj === null || typeof obj !== "object") return obj;
if (seen.has(obj as object)) return "[circular]";
seen.add(obj as object);
if (Array.isArray(obj)) return obj.map((item) => clone(item, depth + 1));
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, clone(v, depth + 1)])
);
};
return clone(payload, 0) as Record<string, unknown>;
}
interface DarkWatchAlertPayload {
exposureId: string;
breachName: string;
@@ -92,7 +110,7 @@ export class AlertNormalizer {
: `Exposure detected in ${payload.breachName}`,
entities,
sourceAlertId,
payload: payload as unknown as Record<string, unknown>,
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp,
};
}
@@ -132,7 +150,7 @@ export class AlertNormalizer {
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
entities,
sourceAlertId,
payload: payload as unknown as Record<string, unknown>,
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp,
};
}
@@ -179,7 +197,7 @@ export class AlertNormalizer {
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
entities,
sourceAlertId,
payload: payload as unknown as Record<string, unknown>,
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp,
};
}
@@ -237,7 +255,7 @@ export class AlertNormalizer {
description,
entities,
sourceAlertId,
payload: payload as unknown as Record<string, unknown>,
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp,
};
}

View File

@@ -126,12 +126,12 @@ export class CorrelationService {
return this.engine.getCorrelationGroups(query);
}
public getGroupById(groupId: string) {
return this.engine.getGroupById(groupId);
public getGroupById(groupId: string, userId: string) {
return this.engine.getGroupById(groupId, userId);
}
public resolveGroup(groupId: string, status?: string) {
return this.engine.resolveGroup(groupId, status as any);
public resolveGroup(groupId: string, userId: string, status?: string) {
return this.engine.resolveGroup(groupId, userId, status as any);
}
public getDashboardData(userId: string, timeWindowMinutes?: number) {

View File

@@ -1,21 +1,26 @@
{
"name": "@shieldai/db",
"version": "0.1.0",
"main": "./dist/index.js",
"types": "./dist/index.js",
"version": "0.2.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "prisma generate && tsc",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:push": "prisma db push",
"db:format": "prisma format",
"generate": "prisma generate"
},
"dependencies": {
"@prisma/client": "^6.2.0",
"prisma": "^6.2.0"
"prisma": "^6.2.0",
"zod": "^4.3.6"
},
"devDependencies": {
"tsx": "^4.19.0"
"tsx": "^4.19.0",
"typescript": "^5.3.3"
},
"exports": {
".": "./src/index.ts"

View File

@@ -1,3 +1,6 @@
// Prisma schema for ShieldAI
// All models for the multi-service SaaS platform
generator client {
provider = "prisma-client-js"
}
@@ -7,337 +10,438 @@ datasource db {
url = env("DATABASE_URL")
}
enum SubscriptionTier {
BASIC
PLUS
PREMIUM
}
enum IdentifierType {
EMAIL
PHONE
SSN
}
enum WatchListStatus {
ACTIVE
PAUSED
}
enum Severity {
LOW
INFO
MEDIUM
WARNING
HIGH
CRITICAL
}
enum AlertChannel {
EMAIL
PUSH
SMS
}
enum AlertStatus {
PENDING
SENT
READ
}
enum ScanJobStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
enum DataSource {
HIBP
SECURITY_TRAILS
CENSYS
SHODAN
HONEYPOT
}
enum AnalysisJobStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
enum AnalysisType {
SYNTHETIC_DETECTION
VOICE_MATCH
BATCH
}
enum DetectionVerdict {
NATURAL
SYNTHETIC
UNCERTAIN
}
// ============================================
// User & Authentication Models
// ============================================
model User {
id String @id @default(uuid())
email String @unique
name String?
subscriptionTier SubscriptionTier @default(BASIC)
familyGroupId String?
watchListItems WatchListItem[]
alerts Alert[]
scanJobs ScanJob[]
scanSchedules ScanSchedule[]
voiceEnrollments VoiceEnrollment[]
analysisJobs AnalysisJob[]
spamFeedback SpamFeedback[]
spamCallAnalyses SpamCallAnalysis[]
spamAuditLogs SpamAuditLog[]
normalizedAlerts NormalizedAlert[]
correlationGroups CorrelationGroup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
emailVerified DateTime?
name String?
image String?
role UserRole @default(user)
// Relationships
accounts Account[]
sessions Session[]
familyGroups FamilyGroupMember[]
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
subscriptions Subscription[]
alerts Alert[]
voiceEnrollments VoiceEnrollment[]
voiceAnalyses VoiceAnalysis[]
spamFeedback SpamFeedback[]
spamRules SpamRule[]
normalizedAlerts NormalizedAlert[]
correlationGroups CorrelationGroup[]
// Audit
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([role])
}
model WatchListItem {
id String @id @default(uuid())
enum UserRole {
user
family_admin
family_member
support
}
model Account {
id String @id @default(uuid())
userId String
provider String
providerAccountId String
access_token String?
refresh_token String?
expires_at Int?
token_type String?
scope String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(uuid())
userId String
sessionToken String @unique
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionToken])
@@index([userId])
}
// ============================================
// Family & Subscription Models
// ============================================
model FamilyGroup {
id String @id @default(uuid())
name String
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id])
members FamilyGroupMember[]
subscriptions Subscription[]
@@index([ownerId])
@@index([name])
}
model FamilyGroupMember {
id String @id @default(uuid())
groupId String
userId String
role FamilyMemberRole @default(member)
joinedAt DateTime @default(now())
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([groupId, userId])
@@index([groupId])
@@index([userId])
}
enum FamilyMemberRole {
owner
admin
member
}
model Subscription {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
identifierType IdentifierType
identifierValue String
identifierHash String @unique
status WatchListStatus @default(ACTIVE)
familyGroupId String?
stripeId String? @unique
tier SubscriptionTier @default(basic)
status SubscriptionStatus @default(active)
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id])
watchlistItems WatchlistItem[]
exposures Exposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
alerts Alert[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([identifierHash])
@@index([familyGroupId])
@@index([stripeId])
@@index([tier])
}
enum SubscriptionTier {
basic
plus
premium
}
enum SubscriptionStatus {
active
past_due
canceled
unpaid
trialing
}
// ============================================
// DarkWatch Models (Dark Web Monitoring)
// ============================================
model WatchlistItem {
id String @id @default(uuid())
subscriptionId String
type WatchlistType
value String
hash String // SHA-256 hash for deduplication
isActive Boolean @default(true)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
exposures Exposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subscriptionId, type, hash])
@@index([subscriptionId])
@@index([type])
@@index([hash])
}
enum WatchlistType {
email
phoneNumber
ssn
address
domain
}
model Exposure {
id String @id @default(uuid())
watchListItemId String
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
dataSource DataSource
breachName String
exposedAt DateTime
dataType String[]
severity Severity
details String?
contentHash String @unique
alert Alert?
subscriptionId String
watchlistItemId String?
source ExposureSource
dataType WatchlistType
identifier String
identifierHash String
severity ExposureSeverity @default(info)
metadata Json? // Additional source-specific data
isFirstTime Boolean @default(false)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
alerts Alert[]
detectedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([watchListItemId])
@@index([contentHash])
@@index([dataSource])
@@index([subscriptionId])
@@index([watchlistItemId])
@@index([source])
@@index([severity])
@@index([detectedAt])
}
enum ExposureSource {
hibp // Have I Been Pwned
securityTrails
censys
darkWebForum
shodan
honeypot
}
enum ExposureSeverity {
info
warning
critical
}
// ============================================
// Notification & Alert Models
// ============================================
model Alert {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exposureId String @unique
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
severity Severity
channel AlertChannel
status AlertStatus @default(PENDING)
dedupKey String
sentAt DateTime?
createdAt DateTime @default(now())
@@index([userId, status])
@@index([dedupKey])
}
model ScanJob {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status ScanJobStatus @default(PENDING)
source DataSource?
resultCount Int @default(0)
errorMessage String?
scheduledBy String?
webhookEvents WebhookEvent[]
completedAt DateTime?
createdAt DateTime @default(now())
@@index([userId, status])
@@index([createdAt])
}
enum ScheduleStatus {
ACTIVE
PAUSED
}
model ScanSchedule {
id String @id @default(uuid())
id String @id @default(uuid())
subscriptionId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
intervalMinutes Int // minutes between scans
cronExpression String // cron expression for scheduling
status ScheduleStatus @default(ACTIVE)
lastScanAt DateTime?
nextScanAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
exposureId String?
type AlertType
title String
message String
severity AlertSeverity @default(info)
isRead Boolean @default(false)
readAt DateTime?
channel AlertChannel[] // Array of notification channels
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exposure Exposure? @relation(fields: [exposureId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId])
@@index([status])
}
enum WebhookEventType {
SCAN_TRIGGER
BREACH_DETECTED
SUBSCRIPTION_CHANGE
}
model WebhookEvent {
id String @id @default(uuid())
eventType WebhookEventType
payload String
source String?
signature String?
processed Boolean @default(false)
processedAt DateTime?
scanJobId String?
scanJob ScanJob? @relation(fields: [scanJobId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([eventType, processed])
@@index([subscriptionId])
@@index([userId])
@@index([isRead])
@@index([createdAt])
}
enum AlertType {
exposure_detected
exposure_resolved
scan_complete
subscription_changed
system_warning
}
enum AlertSeverity {
info
warning
critical
}
enum AlertChannel {
email
push
sms
}
// ============================================
// VoicePrint Models (Voice Cloning Detection)
// ============================================
model VoiceEnrollment {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
label String
embeddingVector Float[]
embeddingDim Int @default(192)
audioFilePath String?
sampleRate Int @default(16000)
durationSec Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
userId String
name String
voiceHash String // FAISS embedding hash
audioMetadata Json? // Sample rate, duration, etc.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
analyses VoiceAnalysis[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([embeddingDim])
@@index([voiceHash])
}
model AnalysisJob {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
analysisType AnalysisType
audioFilePath String
status AnalysisJobStatus @default(PENDING)
result AnalysisResult?
errorMessage String?
completedAt DateTime?
createdAt DateTime @default(now())
model VoiceAnalysis {
id String @id @default(uuid())
enrollmentId String?
userId String
audioHash String // Content hash of audio file
isSynthetic Boolean
confidence Float // 0.0 to 1.0
analysisResult Json // Full ML analysis results
audioUrl String // S3 storage URL
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([userId, status])
@@index([createdAt])
@@index([userId])
@@index([enrollmentId])
@@index([audioHash])
}
model AnalysisResult {
id String @id @default(uuid())
analysisJobId String @unique
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id], onDelete: Cascade)
syntheticScore Float
verdict DetectionVerdict
matchedEnrollmentId String?
matchedSimilarity Float?
confidence Float
processingTimeMs Int
modelVersion String?
metadata String?
createdAt DateTime @default(now())
@@index([analysisJobId])
@@index([verdict])
}
enum SpamDecision {
BLOCK
FLAG
ALLOW
}
// ============================================
// SpamShield Models (Spam Detection)
// ============================================
model SpamFeedback {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String // AES-256 encrypted PII
phoneNumberHash String // SHA-256 hash for anonymized lookup
isSpam Boolean
label String?
metadata String? // Unbounded JSON
createdAt DateTime @default(now())
id String @id @default(uuid())
userId String
phoneNumber String
phoneNumberHash String // SHA-256 hash
isSpam Boolean
confidence Float? // ML model confidence
feedbackType FeedbackType
metadata Json? // Call duration, time, etc.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([phoneNumberHash])
@@index([createdAt])
@@index([isSpam])
}
model SpamCallAnalysis {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String
callTimestamp DateTime
hiyaReputationScore Float?
truecallerSpamScore Float?
decision SpamDecision
confidence Float
ruleMatches String[] // IDs of matched rules
auditLogs SpamAuditLog[]
createdAt DateTime @default(now())
@@index([userId])
@@index([phoneNumber])
@@index([callTimestamp])
enum FeedbackType {
initial_detection
user_confirmation
user_rejection
auto_learned
}
model SpamRule {
id String @id @default(uuid())
name String @unique
pattern String @db.VarChar(500) // Regex pattern - validated for ReDoS at application layer
decision SpamDecision
description String?
isActive Boolean @default(true)
priority Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([priority])
}
model SpamAuditLog {
id String @id @default(uuid())
analysisId String?
analysis SpamCallAnalysis? @relation(fields: [analysisId], references: [id], onDelete: SetNull)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String
decision SpamDecision
reason String
ruleId String?
createdAt DateTime @default(now())
id String @id @default(uuid())
userId String?
isGlobal Boolean @default(false)
ruleType RuleType
pattern String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([createdAt])
@@index([decision])
@@index([isGlobal])
@@index([ruleType])
}
enum RuleType {
phoneNumber
areaCode
prefix
pattern
reputation
}
enum RuleAction {
block
flag
allow
challenge
}
// ============================================
// Audit & Analytics Models
// ============================================
model AuditLog {
id String @id @default(uuid())
userId String?
action String
resource String
resourceId String?
changes Json? // Before/after values
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
@@index([resource])
@@index([createdAt])
}
model KPISnapshot {
id String @id @default(uuid())
date DateTime @unique
metricName String
metricValue Float
metadata Json?
createdAt DateTime @default(now())
@@index([metricName])
@@index([date])
}
// ============================================
// Cross-Service Alert Correlation Models
// ============================================
enum AlertSource {
DARKWATCH
SPAMSHIELD
@@ -351,62 +455,69 @@ enum AlertCategory {
SPAM_SMS
SYNTHETIC_VOICE
VOICE_MISMATCH
CALL_QUALITY
CALL_ANOMALY
CALL_QUALITY
CALL_EVENT
}
enum NormalizedAlertSeverity {
LOW
INFO
MEDIUM
WARNING
HIGH
CRITICAL
}
enum CorrelationStatus {
ACTIVE
RESOLVED
FALSE_POSITIVE
}
enum EntityType {
PHONE_NUMBER
EMAIL
USER_ID
CALL_ID
IP_ADDRESS
}
model NormalizedAlert {
id String @id @default(uuid())
source AlertSource
category AlertCategory
severity Severity
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
description String
entities Json // [{ type: EntityType, value: string }]
sourceAlertId String
groupId String?
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
payload Json
createdAt DateTime @default(now())
id String @id @default(uuid())
source AlertSource
category AlertCategory
severity NormalizedAlertSeverity
userId String
title String
description String
entities Json
sourceAlertId String
groupId String?
payload Json?
createdAt DateTime
updatedAt DateTime @default(now()) @updatedAt
@@index([userId, createdAt])
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([sourceAlertId])
@@index([userId])
@@index([groupId])
@@index([sourceAlertId])
@@index([source])
@@index([severity])
@@index([createdAt])
@@index([userId, createdAt])
}
model CorrelationGroup {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "corr_user_idx")
entities Json // [{ type: EntityType, value: string }]
highestSeverity Severity
status CorrelationStatus @default(ACTIVE)
alertCount Int @default(0)
alerts NormalizedAlert[]
entities Json
highestSeverity NormalizedAlertSeverity
status CorrelationStatus @default(ACTIVE)
alertCount Int @default(0)
summary String?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
alerts NormalizedAlert[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@index([userId, status])
@@index([createdAt])
}

View File

@@ -7,7 +7,7 @@ async function main() {
create: {
email: "dev@shieldai.local",
name: "Dev User",
subscriptionTier: "PREMIUM",
role: "user",
},
});

View File

@@ -1,7 +1,71 @@
// ============================================
// Consolidated @shieldai/db package
// ============================================
// Merges functionality from:
// - @shieldai/db (Prisma v6.2.0, FieldEncryptionService)
// - @shieldsai/shared-db (singleton pattern, type exports)
// ============================================
import { PrismaClient } from '@prisma/client';
import { FieldEncryptionService } from './services/field-encryption.service';
export const prisma = new PrismaClient();
// ============================================
// Singleton Pattern (from shared-db)
// ============================================
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV === 'development') {
globalForPrisma.prisma = prisma;
}
export default prisma;
// ============================================
// Services (from @shieldai/db)
// ============================================
export { FieldEncryptionService };
// ============================================
// Type Exports (from shared-db)
// ============================================
export type {
User,
Account,
Session,
FamilyGroup,
FamilyGroupMember,
Subscription,
WatchlistItem,
Exposure,
Alert,
VoiceEnrollment,
VoiceAnalysis,
SpamFeedback,
SpamRule,
AuditLog,
KPISnapshot,
UserRole,
FamilyMemberRole,
SubscriptionTier,
SubscriptionStatus,
WatchlistType,
ExposureSource,
ExposureSeverity,
AlertType,
AlertSeverity,
AlertChannel,
FeedbackType,
RuleType,
RuleAction,
} from '@prisma/client';
export * as PrismaModels from '@prisma/client';
export type { PrismaClient };

View File

@@ -1,6 +1,9 @@
import crypto from 'crypto';
const ENCRYPTION_KEY = process.env.PII_ENCRYPTION_KEY || 'default-32-byte-key-for-aes-256';
if (!process.env.PII_ENCRYPTION_KEY) {
throw new Error("PII_ENCRYPTION_KEY environment variable is required — set it before starting the server");
}
const ENCRYPTION_KEY = process.env.PII_ENCRYPTION_KEY;
const IV_LENGTH = 16;
export class FieldEncryptionService {

View 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

View File

@@ -16,6 +16,7 @@
"@shieldai/shared-notifications": "workspace:*",
"jest": "^29.7.0",
"@types/jest": "^29.5.0",
"@jest/globals": "^29.7.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
},

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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',
}),
}),
})
);
});
});
});

View File

@@ -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),
})
);
});
});
});

View File

@@ -6,6 +6,7 @@
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
@@ -14,5 +15,9 @@
"@shieldai/types": "workspace:*",
"@shieldai/darkwatch": "workspace:*",
"ioredis": "^5.4.0"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
}
}

View File

@@ -1,4 +1,4 @@
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
import { prisma, SubscriptionTier } from '@shieldai/db';
import { Queue, Worker, Job } from 'bullmq';
import { Redis } from 'ioredis';
import { tierConfig, getTierFeatures } from '@shieldsai/shared-billing';

View 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,
},
},
},
});

View File

@@ -1,12 +1,10 @@
import crypto from 'crypto';
/**
* Hash a phone number for analytics purposes
* Uses a consistent hashing algorithm to create a deterministic hash
* Uses SHA-256 for consistent, cryptographically strong hashing
*/
export function hashPhoneNumber(phoneNumber: string): string {
let hash = 0;
for (let i = 0; i < phoneNumber.length; i++) {
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
hash |= 0;
}
return `hash_${Math.abs(hash)}`;
const hash = crypto.createHash('sha256').update(phoneNumber).digest('hex');
return `sha256_${hash}`;
}

View File

@@ -7,6 +7,7 @@
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
@@ -20,7 +21,8 @@
"devDependencies": {
"@types/express": "^4.17.0",
"typescript": "^5.0.0",
"vitest": "^4.1.5"
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
},
"peerDependencies": {
"typescript": "^5.0.0"

View File

@@ -5,5 +5,18 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', '**/node_modules/**', '**/test/**'],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});

View File

@@ -7,10 +7,12 @@
"types": "src/index.ts",
"scripts": {
"lint": "eslint src/",
"test": "vitest"
"test": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"typescript": "^5.3.3",
"vitest": "^1.3.1"
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
}
}

View 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,
},
},
},
});

View File

@@ -6,6 +6,7 @@
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
@@ -14,6 +15,10 @@
"@shieldai/correlation": "workspace:*",
"node-cache": "^5.1.2"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
},
"exports": {
".": "./src/index.ts"
}

View File

@@ -1,4 +1,4 @@
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
import {
NotificationService,
NotificationPriority,

View File

@@ -1,4 +1,4 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db';
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldai/db';
import { createHash } from 'crypto';
function hashIdentifier(identifier: string): string {

View File

@@ -1,4 +1,4 @@
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldsai/shared-db';
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldai/db';
import { tierConfig } from '@shieldsai/shared-billing';
import { darkwatchScanQueue } from '@shieldsai/jobs';
import { randomUUID } from 'crypto';

View File

@@ -1,4 +1,4 @@
import { prisma, WatchlistType } from '@shieldsai/shared-db';
import { prisma, WatchlistType } from '@shieldai/db';
import { createHash } from 'crypto';
export function normalizeValue(type: WatchlistType, value: string): string {

View File

@@ -1,4 +1,4 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldsai/shared-db';
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldai/db';
import { createHash } from 'crypto';
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';

View File

@@ -6,13 +6,21 @@ export class WebhookHandler {
private secret: string;
constructor(secret?: string) {
this.secret = secret || process.env.WEBHOOK_SECRET || "default-webhook-secret";
if (secret) {
this.secret = secret;
} else if (process.env.DARKWATCH_WEBHOOK_SECRET) {
this.secret = process.env.DARKWATCH_WEBHOOK_SECRET;
} else {
console.warn("[Webhook] DARKWATCH_WEBHOOK_SECRET not set — signature verification will fail");
this.secret = "";
}
}
/**
* Verify HMAC signature of incoming webhook payload.
*/
verifySignature(payload: string, signature: string | string[]): boolean {
if (!this.secret) return false;
if (!signature) return false;
const sigArray = Array.isArray(signature) ? signature : [signature];
@@ -39,7 +47,7 @@ export class WebhookHandler {
): Promise<{ eventId: string; scanTriggered: boolean }> {
const payloadStr = JSON.stringify(payload);
if (signature && !this.verifySignature(payloadStr, signature)) {
if (!signature || !this.verifySignature(payloadStr, signature)) {
throw new Error("Webhook signature verification failed");
}
@@ -188,6 +196,9 @@ export class WebhookHandler {
private normalizeEventType(eventType: string): WebhookEventType {
const upper = eventType.toUpperCase().replace(/\s+/g, "_");
const validTypes: WebhookEventType[] = [WebhookEventType.SCAN_TRIGGER, WebhookEventType.BREACH_DETECTED, WebhookEventType.SUBSCRIPTION_CHANGE];
return validTypes.includes(upper as WebhookEventType) ? (upper as WebhookEventType) : WebhookEventType.SCAN_TRIGGER;
if (!validTypes.includes(upper as WebhookEventType)) {
throw new Error(`Unknown event type: ${eventType}`);
}
return upper as WebhookEventType;
}
}

View 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,
},
},
},
});

View File

@@ -8,6 +8,7 @@
"dev": "tsx watch src/index.ts",
"lint": "eslint src/",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -22,6 +23,8 @@
"typescript": "^5.3.3",
"tsx": "^4.19.0",
"eslint": "^8.56.0",
"@types/ws": "^8.5.10"
"@types/ws": "^8.5.10",
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
}
}

View File

@@ -90,13 +90,14 @@ export class CarrierFactory {
}
}
getAllCarriers(): Array<{ type: CarrierType; healthy: boolean }> {
async getAllCarriers(): Promise<Array<{ type: CarrierType; healthy: boolean }>> {
const results: Array<{ type: CarrierType; healthy: boolean }> = [];
for (const [type, carrier] of this.carriers.entries()) {
const healthy = await carrier.isHealthy();
results.push({
type,
healthy: carrier.isHealthy(),
healthy,
});
}

View File

@@ -20,8 +20,13 @@ export interface SmsClassificationResult {
};
}
export interface SmsClassificationContext {
text: string;
senderPhoneNumber?: string;
}
export interface SmsClassifier {
classify(text: string): Promise<SmsClassificationResult>;
classify(textOrContext: string | SmsClassificationContext): Promise<SmsClassificationResult>;
getMetrics(): {
totalClassified: number;
spamDetected: number;
@@ -44,7 +49,10 @@ export class BertSmsClassifier implements SmsClassifier {
this.spamShield = spamShield;
}
async classify(text: string): Promise<SmsClassificationResult> {
async classify(textOrContext: string | SmsClassificationContext): Promise<SmsClassificationResult> {
const text = typeof textOrContext === 'string' ? textOrContext : textOrContext.text;
const senderPhoneNumber = typeof textOrContext === 'string' ? undefined : textOrContext.senderPhoneNumber;
// Feature 1: Language Analysis
const language = this.analyzeLanguage(text);
@@ -85,9 +93,11 @@ export class BertSmsClassifier implements SmsClassifier {
}
// Combine with reputation score if available
const reputation = await this.spamShield.checkReputation('placeholder');
if (reputation.isSpam) {
spamScore += REPUTATION_SCORE_WEIGHT;
if (senderPhoneNumber) {
const reputation = await this.spamShield.checkReputation(senderPhoneNumber);
if (reputation.isSpam) {
spamScore += REPUTATION_SCORE_WEIGHT;
}
}
const isSpam = spamScore > SMS_SPAM_THRESHOLD;

View File

@@ -116,8 +116,23 @@ export class DecisionEngine {
async evaluate(context: DecisionContext): Promise<DecisionResult> {
const startTime = Date.now();
const reqId = context.requestId ?? 'unknown';
try {
const fallback: DecisionResult = {
decision: this.config.fallbackDecision,
confidence: 0.5,
reasons: ['Fallback decision due to evaluation timeout'],
fallbackDecision: this.config.fallbackDecision,
scoring: {
reputationScore: 0.5,
ruleScore: 0.5,
behavioralScore: 0.5,
userHistoryScore: 0.5,
totalScore: 0.5,
},
executedAt: new Date(),
requestId: reqId,
};
const evaluation = (async () => {
const [reputationScore, ruleScore, behavioralScore, userHistoryScore] = await Promise.all([
this.calculateReputationScore(context.cachedReputation),
this.calculateRuleScore(context.ruleMatches),
@@ -151,25 +166,25 @@ export class DecisionEngine {
executedAt: new Date(),
requestId: reqId,
};
})();
try {
const result = await Promise.race([
evaluation,
new Promise<DecisionResult>((resolve) => {
setTimeout(() => {
console.log(`[DecisionEngine] [${reqId}] Evaluation timeout after ${this.config.evaluationTimeout}ms`);
resolve(fallback);
}, this.config.evaluationTimeout);
}),
]);
return result;
} catch (error) {
console.error(`[DecisionEngine] [${reqId}] Evaluation error:`, error);
if (this.config.fallbackOnTimeout) {
return {
decision: this.config.fallbackDecision,
confidence: 0.5,
reasons: ['Fallback decision due to evaluation error'],
fallbackDecision: this.config.fallbackDecision,
scoring: {
reputationScore: 0.5,
ruleScore: 0.5,
behavioralScore: 0.5,
userHistoryScore: 0.5,
totalScore: 0.5,
},
executedAt: new Date(),
requestId: reqId,
};
return { ...fallback, reasons: ['Fallback decision due to evaluation error'] };
}
throw error;

View File

@@ -2,6 +2,12 @@ import { PrismaClient, SpamRule } from '@prisma/client';
import { generateRequestId } from '@shieldai/types';
import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation';
export interface CompiledRule {
rule: SpamRule;
compiledPattern: RegExp;
compiledCaseInsensitive?: RegExp;
}
export interface RuleMatch {
ruleId: string;
ruleName: string;
@@ -25,10 +31,10 @@ const DEFAULT_CONFIG: Required<RuleEngineConfig> = {
export class RuleEngine {
private readonly config: Required<RuleEngineConfig>;
private numberPatternRules: SpamRule[] = [];
private behavioralRules: SpamRule[] = [];
private contentRules: SpamRule[] = [];
private allRules: SpamRule[] = [];
private numberPatternRules: CompiledRule[] = [];
private behavioralRules: CompiledRule[] = [];
private contentRules: CompiledRule[] = [];
private allRules: CompiledRule[] = [];
private lastLoadTime: Date | null = null;
private readonly prisma: PrismaClient;
@@ -52,11 +58,17 @@ export class RuleEngine {
orderBy: { priority: 'desc' },
});
const validatedRules: SpamRule[] = [];
const compiledRules: CompiledRule[] = [];
for (const rule of rules) {
try {
validateRegexPattern(rule.pattern);
validatedRules.push(rule);
const compiledPattern = new RegExp(rule.pattern);
const compiledCaseInsensitive = new RegExp(rule.pattern, 'i');
compiledRules.push({
rule,
compiledPattern,
compiledCaseInsensitive,
});
} catch (error) {
if (error instanceof RegexValidationError) {
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk: ${error.reason}, skipping`);
@@ -66,10 +78,10 @@ export class RuleEngine {
}
}
this.allRules = validatedRules;
this.numberPatternRules = validatedRules.filter(r => (r as any).category === 'number_pattern');
this.behavioralRules = validatedRules.filter(r => (r as any).category === 'behavioral');
this.contentRules = validatedRules.filter(r => (r as any).category === 'content');
this.allRules = compiledRules;
this.numberPatternRules = compiledRules.filter(r => (r.rule as any).category === 'number_pattern');
this.behavioralRules = compiledRules.filter(r => (r.rule as any).category === 'behavioral');
this.contentRules = compiledRules.filter(r => (r.rule as any).category === 'content');
this.lastLoadTime = now;
}
@@ -80,26 +92,20 @@ export class RuleEngine {
const matches: RuleMatch[] = [];
for (const rule of this.allRules) {
for (const compiled of this.allRules) {
try {
validateRegexPattern(rule.pattern);
const pattern = new RegExp(rule.pattern);
if (pattern.test(phoneNumber)) {
if (compiled.compiledPattern.test(phoneNumber)) {
matches.push({
ruleId: rule.id,
ruleName: rule.name,
pattern: rule.pattern,
score: (rule as any).score,
priority: (rule as any).priority as 'high' | 'medium' | 'low',
ruleId: compiled.rule.id,
ruleName: compiled.rule.name,
pattern: compiled.rule.pattern,
score: (compiled.rule as any).score,
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
matchedAt: new Date(),
});
}
} catch (error) {
if (error instanceof RegexValidationError) {
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
} else {
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
}
console.error(`[RuleEngine] [req:${generateRequestId()}] Evaluation error for rule ${compiled.rule.id}:`, error);
}
}
@@ -113,26 +119,20 @@ export class RuleEngine {
const matches: RuleMatch[] = [];
for (const rule of this.contentRules) {
for (const compiled of this.contentRules) {
try {
validateRegexPattern(rule.pattern);
const pattern = new RegExp(rule.pattern, 'i');
if (pattern.test(smsBody)) {
if (compiled.compiledCaseInsensitive!.test(smsBody)) {
matches.push({
ruleId: rule.id,
ruleName: rule.name,
pattern: rule.pattern,
score: (rule as any).score,
priority: (rule as any).priority as 'high' | 'medium' | 'low',
ruleId: compiled.rule.id,
ruleName: compiled.rule.name,
pattern: compiled.rule.pattern,
score: (compiled.rule as any).score,
priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low',
matchedAt: new Date(),
});
}
} catch (error) {
if (error instanceof RegexValidationError) {
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
} else {
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
}
console.error(`[RuleEngine] [req:${generateRequestId()}] SMS evaluation error for rule ${compiled.rule.id}:`, error);
}
}
@@ -140,19 +140,19 @@ export class RuleEngine {
}
getNumberPatternRules(): SpamRule[] {
return [...this.numberPatternRules];
return this.numberPatternRules.map(r => r.rule);
}
getBehavioralRules(): SpamRule[] {
return [...this.behavioralRules];
return this.behavioralRules.map(r => r.rule);
}
getContentRules(): SpamRule[] {
return [...this.contentRules];
return this.contentRules.map(r => r.rule);
}
getAllRules(): SpamRule[] {
return [...this.allRules];
return this.allRules.map(r => r.rule);
}
async refreshRules(): Promise<void> {

View File

@@ -202,29 +202,23 @@ export class SpamShieldService {
}
const validated = this.validatePhoneNumber(phoneNumber);
const rules = await this.getActiveRules();
const matches = this.ruleEngine
? await this.ruleEngine.evaluate(validated)
: [];
const ruleMatches: string[] = [];
let confidence = 0;
for (const rule of rules) {
const pattern = new RegExp(rule.pattern);
if (pattern.test(validated)) {
ruleMatches.push(rule.id);
confidence += 0.2;
}
}
confidence = Math.min(confidence, 1.0);
const ruleMatchIds = matches.map(m => m.ruleId);
const confidence = Math.min(matches.reduce((sum, m) => sum + m.score, 0), 1.0);
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
const encrypted = FieldEncryptionService.encrypt(validated);
const auditLog = await prisma.spamAuditLog.create({
data: {
userId: 'system',
phoneNumber: validated,
phoneNumber: encrypted,
decision: decision as any,
reason: `Rule-based analysis`,
ruleId: ruleMatches[0],
ruleId: ruleMatchIds[0],
},
});
@@ -235,11 +229,11 @@ export class SpamShieldService {
validated,
decision,
confidence,
ruleMatches
ruleMatchIds
).catch((err) => console.error(`[Correlation] SpamShield emit failed:`, err));
}
return { decision, confidence, ruleMatches };
return { decision, confidence, ruleMatches: ruleMatchIds };
}
async recordFeedback(
@@ -253,6 +247,18 @@ export class SpamShieldService {
throw new Error('Feedback loop disabled via feature flag');
}
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
throw new Error('Feedback: userId is required');
}
if (!phoneNumber || typeof phoneNumber !== 'string') {
throw new Error('Feedback: phoneNumber must be a non-empty string');
}
if (typeof isSpam !== 'boolean') {
throw new Error('Feedback: isSpam must be a boolean');
}
const validated = this.validatePhoneNumber(phoneNumber);
const encrypted = FieldEncryptionService.encrypt(validated);
const hash = FieldEncryptionService.hashPhoneNumber(validated);

View File

@@ -1,4 +1,5 @@
import { WebSocketServer, WebSocket } from 'ws';
import { createHash } from 'crypto';
import { DecisionResult } from '../engine/decision-engine';
export interface AlertEvent {
@@ -29,14 +30,20 @@ export interface AlertServerConfig {
heartbeatIntervalMs?: number;
maxClients?: number;
enableLogging?: boolean;
enableAuth?: boolean;
jwtSecret?: string;
allowedOrigins?: string[];
}
const DEFAULT_CONFIG: Required<AlertServerConfig> = {
port: 8080,
host: '0.0.0.0',
heartbeatIntervalMs: 30000,
maxClients: 1000,
maxClients: 100,
enableLogging: true,
enableAuth: true,
jwtSecret: process.env.SPAMSHIELD_JWT_SECRET || '',
allowedOrigins: ['http://localhost:3000'],
};
export class AlertServer {
@@ -57,9 +64,34 @@ export class AlertServer {
}
private setupWebSocketHandlers(): void {
this.wss.on('connection', (ws: WebSocket, req: any) => {
this.wss.on('connection', async (ws: WebSocket, req: any) => {
const origin = req.headers.origin;
if (origin && this.config.allowedOrigins.length > 0 && !this.config.allowedOrigins.includes(origin)) {
ws.close(1008, 'Origin not allowed');
return;
}
if (this.config.enableAuth && this.config.jwtSecret) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
ws.close(4001, 'Missing or invalid JWT token');
return;
}
const token = authHeader.substring(7);
const valid = await this.verifyJWT(token);
if (!valid) {
ws.close(4002, 'Invalid or expired JWT token');
return;
}
}
if (this.clients.size >= this.config.maxClients) {
ws.close(1013, 'Too many clients');
return;
}
const clientId = req.headers['x-client-id'] as string || `client-${Date.now()}-${Math.random()}`;
const subscription: ClientSubscription = {
clientId,
subscribedEvents: ['decision', 'flag', 'block', 'user_feedback', 'carrier_action'],
@@ -281,6 +313,21 @@ export class AlertServer {
}
private hashPhoneNumber(phoneNumber: string): string {
return Buffer.from(phoneNumber).toString('hex');
return createHash('sha256').update(phoneNumber).digest('hex');
}
private async verifyJWT(token: string): Promise<boolean> {
try {
const { jwtVerify } = await import('jose');
await jwtVerify(token, new TextEncoder().encode(this.config.jwtSecret), {
algorithms: ['HS256'],
});
return true;
} catch {
if (this.config.enableLogging) {
console.log('[AlertServer] JWT verification failed');
}
return false;
}
}
}

View File

@@ -418,6 +418,61 @@ describe('SpamShieldService', () => {
});
});
describe('recordFeedback null checks', () => {
it('throws when userId is null', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback(null as any, '+14155552671', true);
await expect(result).rejects.toThrow('Feedback: userId is required');
});
it('throws when userId is empty string', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback('', '+14155552671', true);
await expect(result).rejects.toThrow('Feedback: userId is required');
});
it('throws when phoneNumber is null', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback('user123', null as any, true);
await expect(result).rejects.toThrow('Feedback: phoneNumber must be a non-empty string');
});
it('throws when isSpam is null', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback('user123', '+14155552671', null as any);
await expect(result).rejects.toThrow('Feedback: isSpam must be a boolean');
});
it('throws when isSpam is undefined', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback('user123', '+14155552671', undefined as any);
await expect(result).rejects.toThrow('Feedback: isSpam must be a boolean');
});
it('throws when userId is undefined', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback(undefined as any, '+14155552671', true);
await expect(result).rejects.toThrow('Feedback: userId is required');
});
it('throws when phoneNumber is undefined', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback('user123', undefined as any, true);
await expect(result).rejects.toThrow('Feedback: phoneNumber must be a non-empty string');
});
it('handles null metadata gracefully (falls back to default)', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'true';
const result = service.recordFeedback('user123', '+14155552671', true, undefined, null as any);
try {
await result;
} catch (e) {
expect((e as Error).message).not.toContain('userId is required');
expect((e as Error).message).not.toContain('isSpam must be a boolean');
}
});
});
describe('enableFeedbackLoop flag', () => {
it('throws when feedback loop is disabled in recordFeedback', async () => {
process.env.FLAG_ENABLEFEEDBACKLOOP = 'false';

View File

@@ -5,5 +5,22 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: [
'src/**/*.d.ts',
'**/node_modules/**',
'**/test/**',
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});

View File

@@ -6,6 +6,7 @@
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
@@ -14,6 +15,10 @@
"@shieldai/correlation": "workspace:*",
"node-cache": "^5.1.2"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
},
"exports": {
".": "./src/index.ts"
}

View File

@@ -1,4 +1,4 @@
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db';
import {
voicePrintEnv,
AnalysisJobStatus,

View 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,
},
},
},
});

View File

@@ -15,6 +15,10 @@
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"test:coverage": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
},