diff --git a/app/ai-service/main.py b/app/ai-service/main.py index e20f8bd5..c84e32ad 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -809,6 +809,19 @@ async def health_check(): return {"status": "healthy", "service": "chainforge-ai-service", "version": "1.0.0"} +@app.get("/api/v1/pii/patterns") +async def get_pii_patterns(): + return { + "email": PIIScrubberService.EMAIL_REGEXES, + "phone": PIIScrubberService.PHONE_REGEXES, + "name": PIIScrubberService.NAME_REGEXES, + "location": PIIScrubberService.LOCATION_REGEXES, + "date": PIIScrubberService.DATE_REGEXES, + "id": PIIScrubberService.ID_REGEXES, + } + + + @app.get("/health/dependencies") async def health_dependencies(): """Lightweight dependency probe for staging and CI. diff --git a/app/backend/scripts/nestjs-route-doctor.ts b/app/backend/scripts/nestjs-route-doctor.ts new file mode 100644 index 00000000..d5d1c81e --- /dev/null +++ b/app/backend/scripts/nestjs-route-doctor.ts @@ -0,0 +1,103 @@ +import { NestFactory } from '@nestjs/core'; +import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core'; +import { AppModule } from '../src/app.module'; +import { ROLES_KEY } from '../src/auth/roles.decorator'; +import { NO_AUTH_STRICT_KEY } from '../src/common/decorators/no-auth-strict.decorator'; + +async function runRouteDoctor() { + // Use createApplicationContext for light-weight boot without opening HTTP ports + const app = await NestFactory.createApplicationContext(AppModule, { logger: false }); + + const discoveryService = app.get(DiscoveryService); + const metadataScanner = app.get(MetadataScanner); + const reflector = app.get(Reflector); + + const controllers = discoveryService.getControllers(); + let undecoratedCount = 0; + + // Retrieve bypass paths from environment + const bypassEnv = process.env.PUBLIC_AUTH_BYPASS ?? ''; + const bypassedPaths = bypassEnv + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + + console.log('🔍 Running NestJS Route Doctor...'); + console.log(`Bypass list: ${JSON.stringify(bypassedPaths)}`); + + for (const wrapper of controllers) { + const { instance, name: controllerName } = wrapper; + if (!instance) continue; + + const controllerClass = wrapper.metatype; + const classPath = Reflect.getMetadata('path', controllerClass) ?? ''; + + const prototype = Object.getPrototypeOf(instance); + const methodNames = metadataScanner.getAllMethodNames(prototype); + + for (const methodName of methodNames) { + const handler = instance[methodName]; + const methodPath = Reflect.getMetadata('path', handler); + + // If methodPath is undefined, this method is not a route handler + if (methodPath === undefined) continue; + + // Construct the paths to check + const methodPaths = Array.isArray(methodPath) ? methodPath : [methodPath]; + + for (const mPath of methodPaths) { + // Build the combined path + let fullPath = `/${classPath}/${mPath}`.replace(/\/+/g, '/').replace(/\/$/, ''); + if (fullPath === '') fullPath = '/'; + + // Prepend api/v1 to simulate typical route prefixing in this app + const fullPathWithPrefix = `/api/v1${fullPath}`.replace(/\/+/g, '/').replace(/\/$/, ''); + + // Check if bypassed + const isBypassed = bypassedPaths.some((bpath) => { + const cleanBPath = bpath.replace(/^\/+|\/+$/g, ''); + const cleanReqPath = fullPathWithPrefix.replace(/^\/+|\/+$/g, ''); + if (cleanBPath === cleanReqPath) return true; + if (cleanReqPath.endsWith(cleanBPath)) { + const index = cleanReqPath.lastIndexOf(cleanBPath); + if (index > 0 && cleanReqPath.charAt(index - 1) === '/') return true; + } + return false; + }); + + if (isBypassed) { + continue; + } + + // Check decorators + const requiredRoles = reflector.getAllAndOverride(ROLES_KEY, [ + handler, + controllerClass, + ]); + const isNoAuthStrict = reflector.getAllAndOverride(NO_AUTH_STRICT_KEY, [ + handler, + controllerClass, + ]); + + if (!requiredRoles && !isNoAuthStrict) { + console.error( + `❌ Undecorated route found: ${controllerName}.${methodName} [${fullPathWithPrefix}] is missing @Roles() or @NoAuthStrict()`, + ); + undecoratedCount++; + } + } + } + } + + await app.close(); + + if (undecoratedCount > 0) { + console.error(`\n🚨 Route Doctor found ${undecoratedCount} undecorated route(s). Failing build.`); + process.exit(1); + } else { + console.log('✅ Route Doctor: All routes are properly decorated or bypassed!'); + process.exit(0); + } +} + +void runRouteDoctor(); diff --git a/app/backend/src/app.controller.ts b/app/backend/src/app.controller.ts index d64adfef..cd39d6a9 100644 --- a/app/backend/src/app.controller.ts +++ b/app/backend/src/app.controller.ts @@ -4,6 +4,7 @@ import { AppService } from './app.service'; import { API_VERSIONS } from './common/constants/api-version.constants'; import { Public } from './common/decorators/public.decorator'; import { Deprecated } from './common/decorators/deprecated.decorator'; +import { NoAuthStrict } from './common/decorators/no-auth-strict.decorator'; @ApiTags('App') @Controller() @@ -55,4 +56,11 @@ export class AppController { deprecatedTest() { return { message: 'This endpoint is deprecated' }; } + + @Public() + @NoAuthStrict() + @Get('no-auth-strict-test') + noAuthStrictTest() { + return { status: 'ok', message: 'public anonymous access allowed' }; + } } diff --git a/app/backend/src/campaigns/campaigns.service.spec.ts b/app/backend/src/campaigns/campaigns.service.spec.ts index 7071f389..c8ac01c2 100644 --- a/app/backend/src/campaigns/campaigns.service.spec.ts +++ b/app/backend/src/campaigns/campaigns.service.spec.ts @@ -50,7 +50,7 @@ describe('CampaignsService', () => { }); const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0]; - + // Clean match validation instead of strict object equivalence structures expect(createArgs).toMatchObject({ data: { @@ -159,4 +159,4 @@ describe('CampaignsService', () => { expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) }); expect(result.deletedAt).not.toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/app/backend/src/campaigns/campaigns.service.ts b/app/backend/src/campaigns/campaigns.service.ts index 0b9239e4..1ba1346c 100644 --- a/app/backend/src/campaigns/campaigns.service.ts +++ b/app/backend/src/campaigns/campaigns.service.ts @@ -53,7 +53,12 @@ export class CampaignsService { }); } - async findAll(includeArchived = false, ngoId?: string | null, page = 1, limit = 50) { + async findAll( + includeArchived = false, + ngoId?: string | null, + page = 1, + limit = 50, + ) { const where: Prisma.CampaignWhereInput = { deletedAt: null, ...(includeArchived ? {} : { archivedAt: null }), diff --git a/app/backend/src/claims/claim-export.controller.ts b/app/backend/src/claims/claim-export.controller.ts index e907bda1..7e5b7c0b 100644 --- a/app/backend/src/claims/claim-export.controller.ts +++ b/app/backend/src/claims/claim-export.controller.ts @@ -1,10 +1,4 @@ -import { - Controller, - Get, - Query, - Res, - Version, -} from '@nestjs/common'; +import { Controller, Get, Query, Res, Version } from '@nestjs/common'; import type { Response } from 'express'; import { ApiTags, diff --git a/app/backend/src/claims/claim-receipt.controller.ts b/app/backend/src/claims/claim-receipt.controller.ts index 07d79b2b..0d062679 100644 --- a/app/backend/src/claims/claim-receipt.controller.ts +++ b/app/backend/src/claims/claim-receipt.controller.ts @@ -1,10 +1,4 @@ -import { - Controller, - Get, - Post, - Body, - Param, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { ApiTags, ApiOperation, diff --git a/app/backend/src/claims/claims.service.ts b/app/backend/src/claims/claims.service.ts index bb36adf5..d36dd415 100644 --- a/app/backend/src/claims/claims.service.ts +++ b/app/backend/src/claims/claims.service.ts @@ -748,7 +748,10 @@ export class ClaimsService { where.OR = [ { campaign: { - metadata: { path: ['tokenAddress'] as any, equals: query.tokenAddress }, + metadata: { + path: ['tokenAddress'] as any, + equals: query.tokenAddress, + }, }, }, ]; diff --git a/app/backend/src/common/budget/budget.service.spec.ts b/app/backend/src/common/budget/budget.service.spec.ts index 32b4e244..04ed60e9 100644 --- a/app/backend/src/common/budget/budget.service.spec.ts +++ b/app/backend/src/common/budget/budget.service.spec.ts @@ -55,4 +55,3 @@ describe('BudgetService', () => { ); }); }); - diff --git a/app/backend/src/common/decorators/no-auth-strict.decorator.ts b/app/backend/src/common/decorators/no-auth-strict.decorator.ts new file mode 100644 index 00000000..1e317e43 --- /dev/null +++ b/app/backend/src/common/decorators/no-auth-strict.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const NO_AUTH_STRICT_KEY = 'noAuthStrict'; +export const NoAuthStrict = () => SetMetadata(NO_AUTH_STRICT_KEY, true); diff --git a/app/backend/src/common/guards/unexpected-auth-header.guard.ts b/app/backend/src/common/guards/unexpected-auth-header.guard.ts new file mode 100644 index 00000000..e0e98176 --- /dev/null +++ b/app/backend/src/common/guards/unexpected-auth-header.guard.ts @@ -0,0 +1,76 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { ROLES_KEY } from '../../auth/roles.decorator'; +import { NO_AUTH_STRICT_KEY } from '../decorators/no-auth-strict.decorator'; + +@Injectable() +export class UnexpectedAuthHeaderGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly configService: ConfigService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers['authorization']; + + // If there is no authorization header, there's no unexpected credential to reject. + if (!authHeader) { + return true; + } + + // Check if the route has roles required (is decorated with @Roles) + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + if (requiredRoles) { + return true; + } + + // Check if the route is explicitly designed for public anonymous access (is decorated with @NoAuthStrict) + const isNoAuthStrict = this.reflector.getAllAndOverride( + NO_AUTH_STRICT_KEY, + [context.getHandler(), context.getClass()], + ); + if (isNoAuthStrict) { + return true; + } + + // Check if the route is in the PUBLIC_AUTH_BYPASS list + const bypassEnv = + this.configService.get('PUBLIC_AUTH_BYPASS') ?? ''; + const bypassedPaths = bypassEnv + .split(',') + .map(p => p.trim()) + .filter(Boolean); + + const requestPath = request.path; + const isBypassed = bypassedPaths.some(bpath => { + const cleanBPath = bpath.replace(/^\/+|\/+$/g, ''); + const cleanReqPath = requestPath.replace(/^\/+|\/+$/g, ''); + if (cleanBPath === cleanReqPath) return true; + if (cleanReqPath.endsWith(cleanBPath)) { + const index = cleanReqPath.lastIndexOf(cleanBPath); + if (index > 0 && cleanReqPath.charAt(index - 1) === '/') return true; + } + return false; + }); + + if (isBypassed) { + return true; + } + + // Undecorated route received an unexpected authorization header + throw new UnauthorizedException( + 'Unexpected authorization credentials on undecorated route', + ); + } +} diff --git a/app/backend/src/common/interceptors/pii-scrub.interceptor.ts b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts new file mode 100644 index 00000000..1e33c2cd --- /dev/null +++ b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts @@ -0,0 +1,275 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + UnprocessableEntityException, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../../../cache/redis.service'; +import axios from 'axios'; + +interface CompiledPattern { + label: string; + regex: RegExp; +} + +const DEFAULT_PATTERNS: Record = { + email: ['\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b'], + phone: [ + '\\+?\\d{1,4}[-.\\s]?\\(?\\d{1,3}?\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b', + '\\b0\\d{10}\\b', + '\\+234\\s?\\d{3}\\s?\\d{3}\\s?\\d{4}\\b', + ], + name: [ + '\\b(?:Mr|Mrs|Ms|Miss|Dr|Prof)\\.?\\s+[A-Z][a-z]+(?:\\s+[A-Z][a-z]+){0,2}\\b', + '\\b[A-Z][a-z]+\\s+[A-Z][a-z]+\\b', + ], + location: [ + '\\b(?:in|at|from|near)\\s+([A-Z][a-z]+(?:\\s+[A-Z][a-z]+){0,2}(?:\\s+(?:Camp|State|Region|District|City|Village|Way|Island))?)\\b', + '\\d+\\s+[A-Z][a-z]+\\s+[A-Z][a-z]+\\s+(?:Way|Street|Avenue|Road|Island)\\b', + ], + date: [ + '\\b\\d{1,2}[/-]\\d{1,2}[/-]\\d{2,4}\\b', + '\\b\\d{4}[/-]\\d{1,2}[/-]\\d{1,2}\\b', + '\\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\\s+\\d{1,2},?\\s+\\d{4}\\b', + '\\b\\d{1,2}\\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\\s+\\d{4}\\b', + ], + id: [ + '\\b\\d{11}\\b', // NIN + '\\b[A-Z]{2}\\d{8}\\b', // Voter ID + ], +}; + +const MAP_KEY_TO_TOKEN: Record = { + email: 'EMAIL_ADDRESS', + phone: 'PHONE_NUMBER', + name: 'RECIPIENT_NAME', + location: 'LOCATION', + date: 'EVENT_DATE', + id: 'ID_NUMBER', +}; + +@Injectable() +export class PiiScrubInterceptor implements NestInterceptor { + private cachedPatterns: CompiledPattern[] | null = null; + private lastFetchedMemoryTime = 0; + + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + if (!request || !request.body || typeof request.body !== 'object') { + return next.handle(); + } + + const mode = this.configService.get('PII_SCRUB_MODE') || 'redact'; + if (mode === 'off') { + return next.handle(); + } + + const highRiskKeysConfig = + this.configService.get('PII_HIGH_RISK_KEYS'); + const highRiskKeys = highRiskKeysConfig + ? highRiskKeysConfig.split(',').map(k => k.trim()) + : [ + 'email', + 'phone', + 'name', + 'nin', + 'recipientref', + 'metadata', + 'content', + 'input', + 'output', + ]; + + const userSub = + request.user?.sub || request.user?.id || request.user?.apiKeyId; + const patterns = await this.getCompiledPatterns(); + + const offendingPaths: string[] = []; + + const traverseAndScrub = (obj: any, path = ''): any => { + if (obj === null || obj === undefined) return obj; + + if (Array.isArray(obj)) { + return obj.map((item, index) => + traverseAndScrub(item, path ? `${path}[${index}]` : `[${index}]`), + ); + } + + if (typeof obj === 'object') { + const result: any = {}; + for (const key of Object.keys(obj)) { + const val = obj[key]; + const currentPath = path ? `${path}.${key}` : key; + const isHighRisk = this.checkIsHighRiskKey(key, highRiskKeys); + + if (isHighRisk && typeof val === 'string') { + // Check allowlist: if val matches userSub, skip scrubbing + if (userSub && String(val) === String(userSub)) { + result[key] = val; + } else { + const matches = this.checkPiiMatches(val, patterns); + if (matches.length > 0) { + if (mode === 'reject') { + offendingPaths.push(currentPath); + result[key] = val; // keep original for completeness + } else { + // redact mode + result[key] = this.redactPii(val, patterns); + } + } else { + result[key] = val; + } + } + } else { + result[key] = traverseAndScrub(val, currentPath); + } + } + return result; + } + + return obj; + }; + + request.body = traverseAndScrub(request.body); + + if (offendingPaths.length > 0) { + throw new UnprocessableEntityException({ + statusCode: 422, + message: `PII validation failed: offending field paths contain PII: ${offendingPaths.join(', ')}`, + errors: offendingPaths, + }); + } + + return next.handle(); + } + + private checkIsHighRiskKey(key: string, highRiskKeys: string[]): boolean { + const lowerKey = key.toLowerCase(); + return highRiskKeys.some(k => { + const lowerK = k.toLowerCase(); + if (lowerK === 'name') { + return ( + lowerKey === 'name' || + lowerKey === 'fullname' || + lowerKey === 'firstname' || + lowerKey === 'lastname' || + lowerKey === 'recipientname' || + lowerKey === 'username' || + (lowerKey.endsWith('name') && + !lowerKey.includes('campaign') && + !lowerKey.includes('org') && + !lowerKey.includes('app')) + ); + } + return ( + lowerKey === lowerK || + lowerKey.endsWith(lowerK) || + lowerKey.startsWith(lowerK) || + lowerKey.includes('_' + lowerK) || + lowerKey.includes(lowerK + '_') + ); + }); + } + + private checkPiiMatches( + val: string, + patterns: CompiledPattern[], + ): CompiledPattern[] { + const matched: CompiledPattern[] = []; + for (const pattern of patterns) { + // Reset lastIndex for safety when reusing RegExp with global flag + pattern.regex.lastIndex = 0; + if (pattern.regex.test(val)) { + matched.push(pattern); + } + } + return matched; + } + + private redactPii(val: string, patterns: CompiledPattern[]): string { + let scrubbed = val; + for (const pattern of patterns) { + pattern.regex.lastIndex = 0; + scrubbed = scrubbed.replace(pattern.regex, `[${pattern.label}]`); + } + return scrubbed; + } + + private async getCompiledPatterns(): Promise { + const now = Date.now(); + if (this.cachedPatterns && now - this.lastFetchedMemoryTime < 60000) { + return this.cachedPatterns; + } + + // Try Redis + try { + const redisPatterns = + await this.redisService.get>('pii:patterns'); + if (redisPatterns) { + this.cachedPatterns = this.compilePatterns(redisPatterns); + this.lastFetchedMemoryTime = now; + return this.cachedPatterns; + } + } catch { + // Log / fallback silently + } + + // Try AI Service + const aiServiceUrl = + this.configService.get('AI_SERVICE_URL') || + 'http://localhost:8000'; + try { + const response = await axios.get(`${aiServiceUrl}/api/v1/pii/patterns`, { + timeout: 3000, + }); + if (response.data && typeof response.data === 'object') { + const fetched = response.data as Record; + this.cachedPatterns = this.compilePatterns(fetched); + this.lastFetchedMemoryTime = now; + try { + await this.redisService.set('pii:patterns', fetched, 3600); + } catch { + // Redis set fail is non-fatal + } + return this.cachedPatterns; + } + } catch { + // AI Service offline or slow + } + + // Fallback to defaults + this.cachedPatterns = this.compilePatterns(DEFAULT_PATTERNS); + // Set memory refresh time slightly shorter in failure mode so we retry in 10s + this.lastFetchedMemoryTime = now - 50000; + return this.cachedPatterns; + } + + private compilePatterns(raw: Record): CompiledPattern[] { + const compiled: CompiledPattern[] = []; + for (const [key, patterns] of Object.entries(raw)) { + const label = MAP_KEY_TO_TOKEN[key] || 'PII'; + for (const pattern of patterns) { + try { + compiled.push({ + label, + regex: new RegExp(pattern, 'g'), + }); + } catch { + // Skip invalid patterns + } + } + } + return compiled; + } +} diff --git a/app/backend/src/common/security/csp-report.controller.ts b/app/backend/src/common/security/csp-report.controller.ts index df181a29..00550275 100644 --- a/app/backend/src/common/security/csp-report.controller.ts +++ b/app/backend/src/common/security/csp-report.controller.ts @@ -14,14 +14,13 @@ export class CspReportController { @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'Receive CSP violation reports', - description: 'Endpoint to receive and log Content Security Policy violations.', + description: + 'Endpoint to receive and log Content Security Policy violations.', }) handleCspReport(@Body() report: any) { - this.loggerService.warn( - 'CSP violation reported', - 'CspReportController', - { cspReport: report }, - ); + this.loggerService.warn('CSP violation reported', 'CspReportController', { + cspReport: report, + }); return { status: 'ok' }; } diff --git a/app/backend/src/common/security/security.module.ts b/app/backend/src/common/security/security.module.ts index b078d10c..216283d1 100644 --- a/app/backend/src/common/security/security.module.ts +++ b/app/backend/src/common/security/security.module.ts @@ -8,7 +8,6 @@ import { RedisService } from '@liaoliaots/nestjs-redis'; import { CspReportController } from './csp-report.controller'; import { LoggerModule } from '../../logger/logger.module'; - const DEFAULT_ALLOWED_ORIGINS = [ 'http://localhost:3000', 'http://localhost:3001', @@ -195,11 +194,13 @@ export const createRateLimiter = ( const logger = new Logger('RateLimiter'); const windowMs = parseNumber( - config.get('RATE_LIMIT_WINDOW_MS') ?? config.get('THROTTLE_TTL'), + config.get('RATE_LIMIT_WINDOW_MS') ?? + config.get('THROTTLE_TTL'), DEFAULT_RATE_LIMIT_WINDOW_MS, ); const limit = parseNumber( - config.get('RATE_LIMIT_LIMIT') ?? config.get('API_RATE_LIMIT'), + config.get('RATE_LIMIT_LIMIT') ?? + config.get('API_RATE_LIMIT'), DEFAULT_RATE_LIMIT, ); @@ -266,8 +267,12 @@ export const createRateLimiter = ( const zrangeResult = results[2]; const zcardResult = results[3]; - const zrangeRes = Array.isArray(zrangeResult) ? (zrangeResult[1] as string[]) : undefined; - const zcardRes = Array.isArray(zcardResult) ? (zcardResult[1] as number) : undefined; + const zrangeRes = Array.isArray(zrangeResult) + ? (zrangeResult[1] as string[]) + : undefined; + const zcardRes = Array.isArray(zcardResult) + ? (zcardResult[1] as number) + : undefined; const count = typeof zcardRes === 'number' ? zcardRes : 1; @@ -312,9 +317,9 @@ export const createRateLimiter = ( * CSRF is currently mitigated by design due to our stateless, token-based authentication * mechanism (`x-api-key` header). Since browsers do not automatically attach custom headers * on cross-origin requests, CSRF attacks are inherently prevented. - * + * * WARNING: - * If cookie-based session management or any browser-managed credentials are introduced + * If cookie-based session management or any browser-managed credentials are introduced * in the future, CSRF protection middleware MUST be implemented. */ @Module({ diff --git a/app/backend/src/common/utils/__tests__/env-loader.spec.ts b/app/backend/src/common/utils/__tests__/env-loader.spec.ts index 9d529502..ca1d7ef7 100644 --- a/app/backend/src/common/utils/__tests__/env-loader.spec.ts +++ b/app/backend/src/common/utils/__tests__/env-loader.spec.ts @@ -109,7 +109,7 @@ describe('Unified Env Loader', () => { expect(configService.get('COMMON_VAR')).toBe(directCommon); expect(configService.get('ROOT_ONLY')).toBe(directRootOnly); expect(configService.get('BACKEND_ONLY')).toBe(directBackendOnly); - + // Verify that the first candidate (rootEnvPath) successfully won over backendEnvPath expect(configService.get('COMMON_VAR')).toBe('root_val'); }); diff --git a/app/backend/src/common/utils/env-loader.ts b/app/backend/src/common/utils/env-loader.ts index a737de6d..5af1f24b 100644 --- a/app/backend/src/common/utils/env-loader.ts +++ b/app/backend/src/common/utils/env-loader.ts @@ -13,8 +13,12 @@ export function getEnvCandidates(): string[] { // If __dirname is inside src/common/utils (which it is for this file), // we go up 3 levels to reach the parent of src/dist. // Otherwise, we default to 1 level up. - const isNested = __dirname.includes(join('common', 'utils')) || __dirname.replace(/\\/g, '/').includes('common/utils'); - const relativeParent = isNested ? join(__dirname, '..', '..', '..') : join(__dirname, '..'); + const isNested = + __dirname.includes(join('common', 'utils')) || + __dirname.replace(/\\/g, '/').includes('common/utils'); + const relativeParent = isNested + ? join(__dirname, '..', '..', '..') + : join(__dirname, '..'); return [ join(process.cwd(), '.env'), @@ -28,7 +32,7 @@ export function getEnvCandidates(): string[] { * Precedence Rule: * - dotenv variables ALWAYS win over existing OS environment variables (override: true). * - The first existing candidate file in the list takes highest precedence. - * + * * Both main.ts and app.module.ts call this helper. * Returns the candidate files list to be used by NestJS ConfigModule. */ diff --git a/app/backend/src/logger/logger.service.spec.ts b/app/backend/src/logger/logger.service.spec.ts index 4a8d5713..cb7eb7bd 100644 --- a/app/backend/src/logger/logger.service.spec.ts +++ b/app/backend/src/logger/logger.service.spec.ts @@ -34,9 +34,7 @@ describe('LoggerService.child', () => { it('reuses async local storage so correlation ids still flow through child loggers', () => { const childLogger = logger.child({ service: 'claims' }); - const store = new Map([ - [CORRELATION_ID_KEY, 'corr-123'], - ]); + const store = new Map([[CORRELATION_ID_KEY, 'corr-123']]); logger.getAsyncLocalStorage().run(store, () => { childLogger.log('hello', 'LoggerSpec', { requestId: 'req-1' }); diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index d1f07694..1ea17b29 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -10,6 +10,8 @@ import { loadEnv } from './common/utils/env-loader'; import compression from 'compression'; import { RequestIdInterceptor } from './common/interceptors/request-id.interceptor'; +import { PiiScrubInterceptor } from './common/interceptors/pii-scrub.interceptor'; +import { RedisService as CustomRedisService } from '../cache/redis.service'; import { buildCorsOptions, createCorsOriginValidator, @@ -67,6 +69,12 @@ async function bootstrap() { }), ); + // Register PII Scrubbing Interceptor + const redisService = app.get(CustomRedisService); + app.useGlobalInterceptors( + new PiiScrubInterceptor(configService, redisService), + ); + // Global interceptors app.useGlobalInterceptors(new LoggingInterceptor(logger)); diff --git a/app/backend/src/observability/metrics/metrics.middleware.ts b/app/backend/src/observability/metrics/metrics.middleware.ts index f72e2737..c7a0eef9 100644 --- a/app/backend/src/observability/metrics/metrics.middleware.ts +++ b/app/backend/src/observability/metrics/metrics.middleware.ts @@ -31,7 +31,12 @@ export class MetricsMiddleware implements NestMiddleware { // Record metrics self.metricsService.incrementHttpRequest(method, route, statusCode); - self.metricsService.recordHttpDuration(method, route, duration, statusCode); + self.metricsService.recordHttpDuration( + method, + route, + duration, + statusCode, + ); // Call the original end function return originalEnd.apply(res, args); diff --git a/app/backend/test/audit-partitioning.spec.ts b/app/backend/test/audit-partitioning.spec.ts index 03479f31..6c1b892e 100644 --- a/app/backend/test/audit-partitioning.spec.ts +++ b/app/backend/test/audit-partitioning.spec.ts @@ -17,7 +17,10 @@ describe('AuditLog Partitioning (e2e)', () => { await app.init(); prisma = app.get(PrismaService); } catch (error) { - console.log('Skipping e2e test: PrismaModule initialization failed (likely missing database connection/dependencies)', error); + console.log( + 'Skipping e2e test: PrismaModule initialization failed (likely missing database connection/dependencies)', + error, + ); } }); @@ -25,7 +28,9 @@ describe('AuditLog Partitioning (e2e)', () => { if (prisma && prisma.isConnected()) { await prisma.auditLog.deleteMany({ where: { - actorId: { in: ['test-actor-current', 'test-actor-prior', 'test-actor-old'] }, + actorId: { + in: ['test-actor-current', 'test-actor-prior', 'test-actor-old'], + }, }, }); } @@ -37,16 +42,18 @@ describe('AuditLog Partitioning (e2e)', () => { it('asserts that AUDIT rows from prior months still query and write with the same Prisma client API', async () => { // If the database is not connected (e.g. running in unit test env without live PG/SQLite), skip if (!app || !prisma || !prisma.isConnected()) { - console.log('Skipping e2e partitioning test: database or AppModule not initialized'); + console.log( + 'Skipping e2e partitioning test: database or AppModule not initialized', + ); return; } const currentMonthDate = new Date(); - + // Create dates for prior months const oneMonthAgoDate = new Date(); oneMonthAgoDate.setMonth(oneMonthAgoDate.getMonth() - 1); - + const twoMonthsAgoDate = new Date(); twoMonthsAgoDate.setMonth(twoMonthsAgoDate.getMonth() - 2); @@ -88,7 +95,9 @@ describe('AuditLog Partitioning (e2e)', () => { // 2. Query logs from prior months using the standard findMany API const allLogs = await prisma.auditLog.findMany({ where: { - actorId: { in: ['test-actor-current', 'test-actor-prior', 'test-actor-old'] }, + actorId: { + in: ['test-actor-current', 'test-actor-prior', 'test-actor-old'], + }, }, orderBy: { timestamp: 'asc', diff --git a/app/backend/test/audit.e2e-spec.ts b/app/backend/test/audit.e2e-spec.ts index 28f28659..7f894e17 100644 --- a/app/backend/test/audit.e2e-spec.ts +++ b/app/backend/test/audit.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import { AppModule } from 'src/app.module'; import { PrismaService } from 'src/prisma/prisma.service'; import request from 'supertest'; @@ -11,7 +15,8 @@ describe('Audit (e2e)', () => { const base = '/api/v1/audit'; const testApiKey = 'e2e-test-key-0003'; - const testApiKeyHash = 'b4b40ca8559ecd4e296d5b0007eeab955dd480259c25a19d88bb4ef0cfb2c0bb'; + const testApiKeyHash = + 'b4b40ca8559ecd4e296d5b0007eeab955dd480259c25a19d88bb4ef0cfb2c0bb'; const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { @@ -109,4 +114,4 @@ describe('Audit (e2e)', () => { expect(res2.headers['x-edge-cache-status']).toBe('hit'); }); }); -}); \ No newline at end of file +}); diff --git a/app/backend/test/campaigns.e2e-spec.ts b/app/backend/test/campaigns.e2e-spec.ts index da846f8f..a1bf1fdb 100644 --- a/app/backend/test/campaigns.e2e-spec.ts +++ b/app/backend/test/campaigns.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import request, { Response as SupertestResponse } from 'supertest'; import { AppModule } from 'src/app.module'; import { PrismaService } from 'src/prisma/prisma.service'; @@ -28,7 +32,8 @@ describe('Campaigns (e2e)', () => { const base = '/api/v1/campaigns'; const testApiKey = 'e2e-test-key-0001'; - const testApiKeyHash = '7cd155083be719224524695fc6e61cf3747b99dd3f6260e392f1b3b69577dcd9'; + const testApiKeyHash = + '7cd155083be719224524695fc6e61cf3747b99dd3f6260e392f1b3b69577dcd9'; const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { @@ -206,4 +211,4 @@ describe('Campaigns (e2e)', () => { expect(res2.headers['x-edge-cache-status']).toBe('hit'); }); }); -}); \ No newline at end of file +}); diff --git a/app/backend/test/claim-lifecycle.integration-spec.ts b/app/backend/test/claim-lifecycle.integration-spec.ts index 8ae9ce55..d606dcbc 100644 --- a/app/backend/test/claim-lifecycle.integration-spec.ts +++ b/app/backend/test/claim-lifecycle.integration-spec.ts @@ -31,7 +31,9 @@ jest.mock('openai', () => ({ chat: { completions: { create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: JSON.stringify({ verified: true }) } }], + choices: [ + { message: { content: JSON.stringify({ verified: true }) } }, + ], }), }, }, diff --git a/app/backend/test/claims.e2e-spec.ts b/app/backend/test/claims.e2e-spec.ts index 7e6ddd77..f30ffe11 100644 --- a/app/backend/test/claims.e2e-spec.ts +++ b/app/backend/test/claims.e2e-spec.ts @@ -1,5 +1,9 @@ import { Test } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import { AppModule } from 'src/app.module'; import { PrismaService } from 'src/prisma/prisma.service'; import { EncryptionService } from 'src/common/encryption/encryption.service'; @@ -26,7 +30,8 @@ describe('Claims (e2e)', () => { const base = '/api/v1/claims'; const testApiKey = 'e2e-test-key-0002'; - const testApiKeyHash = '0ddfd56b80b5f63187c748e910d5ae632669a46f221170bdcbb04989e44d107a'; + const testApiKeyHash = + '0ddfd56b80b5f63187c748e910d5ae632669a46f221170bdcbb04989e44d107a'; const authHeader = { 'X-Api-Key': testApiKey } as Record; beforeAll(async () => { @@ -45,7 +50,11 @@ describe('Claims (e2e)', () => { }); app.useGlobalPipes( - new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), ); await app.init(); @@ -104,7 +113,12 @@ describe('Claims (e2e)', () => { await request(app.getHttpServer()) .post(base) .set(authHeader) - .send({ campaignId: 'invalid-id', amount: 100.5, recipientRef: 'recipient-123', tokenAddress: STELLAR_ADDR }) + .send({ + campaignId: 'invalid-id', + amount: 100.5, + recipientRef: 'recipient-123', + tokenAddress: STELLAR_ADDR, + }) .expect(404); }); @@ -113,10 +127,17 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1') }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + }, }); - const res = await request(app.getHttpServer()).get(base).set(authHeader).expect(200); + const res = await request(app.getHttpServer()) + .get(base) + .set(authHeader) + .expect(200); const body = res.body as ClaimDto[]; expect(body).toHaveLength(1); }); @@ -126,7 +147,11 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); const claim = await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1') }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + }, }); const res = await request(app.getHttpServer()) @@ -144,7 +169,11 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); const claim = await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1') }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + }, }); const res = await request(app.getHttpServer()) @@ -161,7 +190,12 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); const claim = await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'verified' }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + status: 'verified', + }, }); const res = await request(app.getHttpServer()) @@ -178,7 +212,12 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); const claim = await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'approved' }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + status: 'approved', + }, }); const res = await request(app.getHttpServer()) @@ -195,7 +234,12 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); const claim = await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'disbursed' }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + status: 'disbursed', + }, }); const res = await request(app.getHttpServer()) @@ -212,7 +256,12 @@ describe('Claims (e2e)', () => { data: { name: 'Test Campaign', budget: 1000 }, }); const claim = await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('recipient-1'), status: 'verified' }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('recipient-1'), + status: 'verified', + }, }); await request(app.getHttpServer()) @@ -227,7 +276,11 @@ describe('Claims (e2e)', () => { data: { name: 'Cache Campaign', budget: 1000 }, }); await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 50, recipientRef: encryptionService.encrypt('cache-test') }, + data: { + campaignId: campaign.id, + amount: 50, + recipientRef: encryptionService.encrypt('cache-test'), + }, }); const res = await request(app.getHttpServer()) @@ -246,7 +299,11 @@ describe('Claims (e2e)', () => { data: { name: '304 Campaign', budget: 1000 }, }); await prisma.claim.create({ - data: { campaignId: campaign.id, amount: 25, recipientRef: encryptionService.encrypt('304-test') }, + data: { + campaignId: campaign.id, + amount: 25, + recipientRef: encryptionService.encrypt('304-test'), + }, }); const res1 = await request(app.getHttpServer()) diff --git a/app/backend/test/contract/ai-service.contract.spec.ts b/app/backend/test/contract/ai-service.contract.spec.ts index 57f4e6cf..42001ae4 100644 --- a/app/backend/test/contract/ai-service.contract.spec.ts +++ b/app/backend/test/contract/ai-service.contract.spec.ts @@ -26,11 +26,18 @@ import { // service responses (see verification.service.ts). TypeScript will surface a // compile error here before any test even runs if a shape diverges. -interface _OCRFieldResult { value: string; confidence: number } +interface _OCRFieldResult { + value: string; + confidence: number; +} interface _OCRResponse { success: boolean; processing_time_ms: number; - data?: { fields: Record; raw_text: string; processing_time_ms: number }; + data?: { + fields: Record; + raw_text: string; + processing_time_ms: number; + }; error?: Record; } type _HumanitarianVerdict = 'credible' | 'inconclusive' | 'not_credible'; @@ -39,26 +46,46 @@ interface _HumanitarianResponse { provider?: string | null; model?: string | null; prompt_variant?: string | null; - verification?: { verdict: _HumanitarianVerdict; confidence: number; summary?: string } | null; + verification?: { + verdict: _HumanitarianVerdict; + confidence: number; + summary?: string; + } | null; error?: string | null; } interface _ProofOfLifeResponse { - is_real_person: boolean; confidence: number; threshold: number; - checks: Record; reason: string; + is_real_person: boolean; + confidence: number; + threshold: number; + checks: Record; + reason: string; } interface _AnonymizeResponse { - success: boolean; anonymized_text: string; original_length: number; - pii_summary: { names: number; locations: number; dates: number; total: number }; + success: boolean; + anonymized_text: string; + original_length: number; + pii_summary: { + names: number; + locations: number; + dates: number; + total: number; + }; token_counts: Record; } interface _FraudDetectionResponse { - results: Array<{ claim_id: string; fraud_risk_score: number; is_flagged: boolean; reason?: string | null }>; + results: Array<{ + claim_id: string; + fraud_risk_score: number; + is_flagged: boolean; + reason?: string | null; + }>; flagged_count: number; } // Compile-time-only guards – never called at runtime. function _guardOCR(v: unknown): asserts v is _OCRResponse { - void (v as _OCRResponse).success; void (v as _OCRResponse).processing_time_ms; + void (v as _OCRResponse).success; + void (v as _OCRResponse).processing_time_ms; } function _guardHumanitarian(v: unknown): asserts v is _HumanitarianResponse { void (v as _HumanitarianResponse).success; @@ -73,7 +100,11 @@ function _guardAnonymize(v: unknown): asserts v is _AnonymizeResponse { function _guardFraud(v: unknown): asserts v is _FraudDetectionResponse { void (v as _FraudDetectionResponse).flagged_count; } -void _guardOCR; void _guardHumanitarian; void _guardPOL; void _guardAnonymize; void _guardFraud; +void _guardOCR; +void _guardHumanitarian; +void _guardPOL; +void _guardAnonymize; +void _guardFraud; // ─── 2. Embedded OpenAPI snapshot ──────────────────────────────────────────── // Generated from: curl http://localhost:8000/openapi.json @@ -85,59 +116,148 @@ const EMBEDDED_SPEC: OpenApiDocument = { paths: { '/v1/ai/humanitarian/verify': { post: { - tags: ['humanitarian'], operationId: 'verify_humanitarian_claim', - requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/HumanitarianVerificationRequest' } } } }, - responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/HumanitarianVerificationResponse' } } } } }, + tags: ['humanitarian'], + operationId: 'verify_humanitarian_claim', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/HumanitarianVerificationRequest', + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/HumanitarianVerificationResponse', + }, + }, + }, + }, + }, }, }, '/v1/ai/anonymize': { post: { - tags: ['anonymization'], operationId: 'anonymize_text', - requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/AnonymizeRequest' } } } }, - responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/AnonymizeResponse' } } } } }, + tags: ['anonymization'], + operationId: 'anonymize_text', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AnonymizeRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AnonymizeResponse' }, + }, + }, + }, + }, }, }, '/v1/ai/proof-of-life': { post: { - tags: ['proof-of-life'], operationId: 'analyze_proof_of_life', - requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ProofOfLifeRequest' } } } }, - responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/ProofOfLifeResponse' } } } } }, + tags: ['proof-of-life'], + operationId: 'analyze_proof_of_life', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ProofOfLifeRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ProofOfLifeResponse' }, + }, + }, + }, + }, }, }, '/v1/fraud/detect': { post: { - tags: ['fraud'], operationId: 'detect_fraud_endpoint', - requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/FraudDetectionRequest' } } } }, - responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/FraudDetectionResponse' } } } } }, + tags: ['fraud'], + operationId: 'detect_fraud_endpoint', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/FraudDetectionRequest' }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/FraudDetectionResponse' }, + }, + }, + }, + }, }, }, '/v1/ai/inference': { post: { - tags: ['inference'], operationId: 'create_inference_task', + tags: ['inference'], + operationId: 'create_inference_task', responses: { '200': { description: 'OK' } }, }, }, '/v1/ai/status/{task_id}': { get: { - tags: ['inference'], operationId: 'get_task_status', - responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/TaskStatusResponse' } } } } }, + tags: ['inference'], + operationId: 'get_task_status', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TaskStatusResponse' }, + }, + }, + }, + }, }, }, }, components: { schemas: { HumanitarianVerificationRequest: { - type: 'object', required: ['aid_claim'], + type: 'object', + required: ['aid_claim'], properties: { aid_claim: { type: 'string' }, supporting_evidence: { type: 'array', items: { type: 'string' } }, context_factors: { type: 'object' }, - provider_preference: { type: 'string', enum: ['auto', 'test', 'openai', 'groq'], default: 'auto' }, + provider_preference: { + type: 'string', + enum: ['auto', 'test', 'openai', 'groq'], + default: 'auto', + }, timeout: { type: 'number', nullable: true }, }, }, HumanitarianVerificationResponse: { - type: 'object', required: ['success'], + type: 'object', + required: ['success'], properties: { success: { type: 'boolean' }, provider: { type: 'string', nullable: true }, @@ -148,18 +268,28 @@ const EMBEDDED_SPEC: OpenApiDocument = { }, }, AnonymizeRequest: { - type: 'object', required: ['text'], + type: 'object', + required: ['text'], properties: { text: { type: 'string' } }, }, PIISummary: { - type: 'object', required: ['names', 'locations', 'dates', 'total'], + type: 'object', + required: ['names', 'locations', 'dates', 'total'], properties: { - names: { type: 'integer' }, locations: { type: 'integer' }, - dates: { type: 'integer' }, total: { type: 'integer' }, + names: { type: 'integer' }, + locations: { type: 'integer' }, + dates: { type: 'integer' }, + total: { type: 'integer' }, }, }, AnonymizeResponse: { - type: 'object', required: ['success', 'anonymized_text', 'original_length', 'pii_summary'], + type: 'object', + required: [ + 'success', + 'anonymized_text', + 'original_length', + 'pii_summary', + ], properties: { success: { type: 'boolean' }, anonymized_text: { type: 'string' }, @@ -169,15 +299,27 @@ const EMBEDDED_SPEC: OpenApiDocument = { }, }, ProofOfLifeRequest: { - type: 'object', required: ['selfie_image_base64'], + type: 'object', + required: ['selfie_image_base64'], properties: { selfie_image_base64: { type: 'string' }, - burst_images_base64: { type: 'array', items: { type: 'string' }, nullable: true }, + burst_images_base64: { + type: 'array', + items: { type: 'string' }, + nullable: true, + }, confidence_threshold: { type: 'number', nullable: true }, }, }, ProofOfLifeResponse: { - type: 'object', required: ['is_real_person', 'confidence', 'threshold', 'checks', 'reason'], + type: 'object', + required: [ + 'is_real_person', + 'confidence', + 'threshold', + 'checks', + 'reason', + ], properties: { is_real_person: { type: 'boolean' }, confidence: { type: 'number' }, @@ -187,7 +329,8 @@ const EMBEDDED_SPEC: OpenApiDocument = { }, }, ClaimMetadata: { - type: 'object', required: ['claim_id'], + type: 'object', + required: ['claim_id'], properties: { claim_id: { type: 'string' }, ip_address: { type: 'string', nullable: true }, @@ -198,11 +341,18 @@ const EMBEDDED_SPEC: OpenApiDocument = { }, }, FraudDetectionRequest: { - type: 'object', required: ['claims'], - properties: { claims: { type: 'array', items: { $ref: '#/components/schemas/ClaimMetadata' } } }, + type: 'object', + required: ['claims'], + properties: { + claims: { + type: 'array', + items: { $ref: '#/components/schemas/ClaimMetadata' }, + }, + }, }, ClaimFraudResult: { - type: 'object', required: ['claim_id', 'fraud_risk_score', 'is_flagged'], + type: 'object', + required: ['claim_id', 'fraud_risk_score', 'is_flagged'], properties: { claim_id: { type: 'string' }, fraud_risk_score: { type: 'number' }, @@ -211,29 +361,48 @@ const EMBEDDED_SPEC: OpenApiDocument = { }, }, FraudDetectionResponse: { - type: 'object', required: ['results', 'flagged_count'], + type: 'object', + required: ['results', 'flagged_count'], properties: { - results: { type: 'array', items: { $ref: '#/components/schemas/ClaimFraudResult' } }, + results: { + type: 'array', + items: { $ref: '#/components/schemas/ClaimFraudResult' }, + }, flagged_count: { type: 'integer' }, }, }, TaskStatusResponse: { - type: 'object', required: ['task_id', 'status'], + type: 'object', + required: ['task_id', 'status'], properties: { task_id: { type: 'string' }, - status: { type: 'string', enum: ['pending', 'processing', 'completed', 'failed', 'cancelled', 'not_found'] }, + status: { + type: 'string', + enum: [ + 'pending', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'not_found', + ], + }, result: { nullable: true }, error: { type: 'string', nullable: true }, }, }, ErrorDetail: { - type: 'object', required: ['code', 'message'], + type: 'object', + required: ['code', 'message'], properties: { - code: { type: 'string' }, message: { type: 'string' }, details: { nullable: true }, + code: { type: 'string' }, + message: { type: 'string' }, + details: { nullable: true }, }, }, ErrorEnvelope: { - type: 'object', required: ['error'], + type: 'object', + required: ['error'], properties: { error: { $ref: '#/components/schemas/ErrorDetail' } }, }, }, @@ -243,7 +412,12 @@ const EMBEDDED_SPEC: OpenApiDocument = { // ─── 3. Fixture loader ──────────────────────────────────────────────────────── const FIXTURES_DIR = path.resolve( - __dirname, '..', '..', '..', 'ai-service', 'fixtures', + __dirname, + '..', + '..', + '..', + 'ai-service', + 'fixtures', ); function loadFixture(name: string): T[] { @@ -273,7 +447,12 @@ function assertFixturesMatchSchema( if (!schema) return; fixtures.forEach((fixture, i) => { - const result = validateAgainstSchema(fixture, schema, doc, `${label}[${i}]`); + const result = validateAgainstSchema( + fixture, + schema, + doc, + `${label}[${i}]`, + ); if (!result.valid) { // Surface all errors in a single failure for easy diagnosis. throw new Error( @@ -297,7 +476,9 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { console.log(`[contract] Using live OpenAPI spec from ${liveUrl}`); return; } catch { - console.warn('[contract] Live AI service unreachable – using embedded snapshot'); + console.warn( + '[contract] Live AI service unreachable – using embedded snapshot', + ); } } doc = EMBEDDED_SPEC; @@ -325,13 +506,29 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { // ── 5b. Required v1 paths ───────────────────────────────────────────────── describe('Required v1 endpoint paths', () => { - const REQUIRED_PATHS: Array<{ path: string; method: 'get' | 'post'; label: string }> = [ - { path: '/v1/ai/humanitarian/verify', method: 'post', label: 'Humanitarian verification' }, - { path: '/v1/ai/anonymize', method: 'post', label: 'PII anonymisation' }, - { path: '/v1/ai/proof-of-life', method: 'post', label: 'Proof-of-life' }, - { path: '/v1/fraud/detect', method: 'post', label: 'Fraud detection' }, - { path: '/v1/ai/inference', method: 'post', label: 'Async inference task' }, - { path: '/v1/ai/status/{task_id}', method: 'get', label: 'Task status poll' }, + const REQUIRED_PATHS: Array<{ + path: string; + method: 'get' | 'post'; + label: string; + }> = [ + { + path: '/v1/ai/humanitarian/verify', + method: 'post', + label: 'Humanitarian verification', + }, + { path: '/v1/ai/anonymize', method: 'post', label: 'PII anonymisation' }, + { path: '/v1/ai/proof-of-life', method: 'post', label: 'Proof-of-life' }, + { path: '/v1/fraud/detect', method: 'post', label: 'Fraud detection' }, + { + path: '/v1/ai/inference', + method: 'post', + label: 'Async inference task', + }, + { + path: '/v1/ai/status/{task_id}', + method: 'get', + label: 'Task status poll', + }, ]; REQUIRED_PATHS.forEach(({ path: p, method, label }) => { @@ -353,14 +550,16 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { const EXPECTED_PROVIDERS = ['auto', 'test', 'openai', 'groq']; it('declares provider_preference in request schema', () => { - const schema = doc.components?.schemas?.['HumanitarianVerificationRequest']; + const schema = + doc.components?.schemas?.['HumanitarianVerificationRequest']; expect(schema).toBeDefined(); expect(schema?.properties?.['provider_preference']).toBeDefined(); }); it('provider_preference enum contains all expected values', () => { - const prop = doc.components?.schemas?.['HumanitarianVerificationRequest'] - ?.properties?.['provider_preference']; + const prop = + doc.components?.schemas?.['HumanitarianVerificationRequest'] + ?.properties?.['provider_preference']; expect(prop?.enum).toBeDefined(); for (const v of EXPECTED_PROVIDERS) { expect(prop!.enum).toContain(v); @@ -368,8 +567,9 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { }); it('provider_preference has a default of "auto"', () => { - const prop = doc.components?.schemas?.['HumanitarianVerificationRequest'] - ?.properties?.['provider_preference']; + const prop = + doc.components?.schemas?.['HumanitarianVerificationRequest'] + ?.properties?.['provider_preference']; expect(prop?.default).toBe('auto'); }); }); @@ -385,8 +585,9 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { }); it('status enum contains all lifecycle values', () => { - const enumVals = doc.components?.schemas?.['TaskStatusResponse'] - ?.properties?.['status']?.enum; + const enumVals = + doc.components?.schemas?.['TaskStatusResponse']?.properties?.['status'] + ?.enum; for (const v of EXPECTED_STATUSES) { expect(enumVals).toContain(v); } @@ -397,7 +598,8 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { describe('Response schema required fields align with backend interfaces', () => { it('HumanitarianVerificationResponse requires "success"', () => { - const schema = doc.components?.schemas?.['HumanitarianVerificationResponse']; + const schema = + doc.components?.schemas?.['HumanitarianVerificationResponse']; expect(schema?.required).toContain('success'); }); @@ -413,9 +615,11 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { it('ProofOfLifeResponse requires is_real_person, confidence, threshold, checks, reason', () => { const schema = doc.components?.schemas?.['ProofOfLifeResponse']; const req = schema?.required ?? []; - ['is_real_person', 'confidence', 'threshold', 'checks', 'reason'].forEach((f) => { - expect(req).toContain(f); - }); + ['is_real_person', 'confidence', 'threshold', 'checks', 'reason'].forEach( + f => { + expect(req).toContain(f); + }, + ); }); it('FraudDetectionResponse requires results and flagged_count', () => { @@ -428,7 +632,7 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { it('ClaimFraudResult requires claim_id, fraud_risk_score, is_flagged', () => { const schema = doc.components?.schemas?.['ClaimFraudResult']; const req = schema?.required ?? []; - ['claim_id', 'fraud_risk_score', 'is_flagged'].forEach((f) => { + ['claim_id', 'fraud_risk_score', 'is_flagged'].forEach(f => { expect(req).toContain(f); }); }); @@ -436,7 +640,7 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { it('PIISummary requires names, locations, dates, total', () => { const schema = doc.components?.schemas?.['PIISummary']; const req = schema?.required ?? []; - ['names', 'locations', 'dates', 'total'].forEach((f) => { + ['names', 'locations', 'dates', 'total'].forEach(f => { expect(req).toContain(f); }); }); @@ -454,32 +658,42 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { describe('Property type declarations', () => { it('ProofOfLifeResponse.is_real_person is boolean', () => { - const prop = doc.components?.schemas?.['ProofOfLifeResponse'] - ?.properties?.['is_real_person']; + const prop = + doc.components?.schemas?.['ProofOfLifeResponse']?.properties?.[ + 'is_real_person' + ]; expect(prop?.type).toBe('boolean'); }); it('ProofOfLifeResponse.confidence is number', () => { - const prop = doc.components?.schemas?.['ProofOfLifeResponse'] - ?.properties?.['confidence']; + const prop = + doc.components?.schemas?.['ProofOfLifeResponse']?.properties?.[ + 'confidence' + ]; expect(prop?.type).toBe('number'); }); it('FraudDetectionResponse.flagged_count is integer', () => { - const prop = doc.components?.schemas?.['FraudDetectionResponse'] - ?.properties?.['flagged_count']; + const prop = + doc.components?.schemas?.['FraudDetectionResponse']?.properties?.[ + 'flagged_count' + ]; expect(prop?.type).toBe('integer'); }); it('AnonymizeResponse.original_length is integer', () => { - const prop = doc.components?.schemas?.['AnonymizeResponse'] - ?.properties?.['original_length']; + const prop = + doc.components?.schemas?.['AnonymizeResponse']?.properties?.[ + 'original_length' + ]; expect(prop?.type).toBe('integer'); }); it('ClaimFraudResult.fraud_risk_score is number', () => { - const prop = doc.components?.schemas?.['ClaimFraudResult'] - ?.properties?.['fraud_risk_score']; + const prop = + doc.components?.schemas?.['ClaimFraudResult']?.properties?.[ + 'fraud_risk_score' + ]; expect(prop?.type).toBe('number'); }); }); @@ -491,7 +705,7 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { // in the response envelope the endpoint actually emits. it('humanitarian fixtures satisfy HumanitarianVerificationResponse schema', () => { const rawFixtures = loadFixture>('humanitarian'); - const enveloped = rawFixtures.map((f) => ({ + const enveloped = rawFixtures.map(f => ({ success: true, verification: f, })); @@ -507,7 +721,7 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { it('anonymize fixtures satisfy AnonymizeResponse schema', () => { const fixtures = loadFixture>('anonymize'); // The fixtures already contain all AnonymizeResponse fields except success. - const enveloped = fixtures.map((f) => ({ success: true, ...f })); + const enveloped = fixtures.map(f => ({ success: true, ...f })); assertFixturesMatchSchema( doc, '/v1/ai/anonymize', @@ -533,15 +747,17 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { describe('Cross-field consistency', () => { it('HumanitarianVerificationResponse.verification is nullable (not required)', () => { - const schema = doc.components?.schemas?.['HumanitarianVerificationResponse']; + const schema = + doc.components?.schemas?.['HumanitarianVerificationResponse']; const required = schema?.required ?? []; // verification must NOT be in required – it is absent on failure paths expect(required).not.toContain('verification'); }); it('HumanitarianVerificationResponse.error is nullable (not required)', () => { - const schema = doc.components?.schemas?.['HumanitarianVerificationResponse']; - expect((schema?.required ?? [])).not.toContain('error'); + const schema = + doc.components?.schemas?.['HumanitarianVerificationResponse']; + expect(schema?.required ?? []).not.toContain('error'); }); it('FraudDetectionRequest requires at least one claim (min-length enforced in Pydantic)', () => { @@ -553,8 +769,10 @@ describe('AI service contract: backend client ↔ Pydantic schemas', () => { }); it('AnonymizeResponse.pii_summary $ref resolves to PIISummary', () => { - const prop = doc.components?.schemas?.['AnonymizeResponse'] - ?.properties?.['pii_summary']; + const prop = + doc.components?.schemas?.['AnonymizeResponse']?.properties?.[ + 'pii_summary' + ]; // Either $ref or inline – if $ref it must resolve if (prop?.$ref) { const resolved = doc.components?.schemas?.['PIISummary']; diff --git a/app/backend/test/mocks/bullmq.mock.ts b/app/backend/test/mocks/bullmq.mock.ts new file mode 100644 index 00000000..a8543999 --- /dev/null +++ b/app/backend/test/mocks/bullmq.mock.ts @@ -0,0 +1,99 @@ +import { Module, DynamicModule, Inject } from '@nestjs/common'; + +export const getQueueToken = (name: string) => `BullQueue_${name}`; +export const InjectQueue = (name: string) => Inject(getQueueToken(name)); + +@Module({}) +export class MockBullModule { + static forRoot(): DynamicModule { + return { + module: MockBullModule, + providers: [], + exports: [], + }; + } + + static forRootAsync(): DynamicModule { + return { + module: MockBullModule, + providers: [], + exports: [], + }; + } + + static registerQueue(...queues: any[]): DynamicModule { + const providers = queues.map(queue => { + const name = typeof queue === 'string' ? queue : queue.name; + return { + provide: getQueueToken(name), + useValue: { + add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }), + getJobs: jest.fn().mockResolvedValue([]), + getJob: jest.fn().mockResolvedValue(null), + obliterate: jest.fn().mockResolvedValue(undefined), + pause: jest.fn().mockResolvedValue(undefined), + resume: jest.fn().mockResolvedValue(undefined), + }, + }; + }); + return { + module: MockBullModule, + providers: providers, + exports: providers, + }; + } + + static registerQueueAsync(...queues: any[]): DynamicModule { + const providers = queues.map(queue => { + const name = + typeof queue === 'string' ? queue : (queue.name ?? 'default'); + return { + provide: getQueueToken(name), + useValue: { + add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }), + getJobs: jest.fn().mockResolvedValue([]), + getJob: jest.fn().mockResolvedValue(null), + obliterate: jest.fn().mockResolvedValue(undefined), + pause: jest.fn().mockResolvedValue(undefined), + resume: jest.fn().mockResolvedValue(undefined), + }, + }; + }); + return { + module: MockBullModule, + providers: providers, + exports: providers, + }; + } + + static registerFlowProducer(..._producers: any[]): DynamicModule { + return { + module: MockBullModule, + providers: [], + exports: [], + }; + } + + static registerFlowProducerAsync(..._producers: any[]): DynamicModule { + return { + module: MockBullModule, + providers: [], + exports: [], + }; + } +} + +export const BullModule = MockBullModule; + +export const Processor = (_name?: string) => (_target: any) => {}; + +export class WorkerHost { + worker: any = { + on: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; +} + +export const OnWorkerEvent = + (_event: string) => + (_target: any, _propertyKey: string, _descriptor: PropertyDescriptor) => {}; diff --git a/app/backend/test/mocks/ioredis.mock.ts b/app/backend/test/mocks/ioredis.mock.ts new file mode 100644 index 00000000..96b1c4c8 --- /dev/null +++ b/app/backend/test/mocks/ioredis.mock.ts @@ -0,0 +1,4 @@ +import RedisMock from 'ioredis-mock'; + +export const Redis = RedisMock; +export default RedisMock; diff --git a/app/backend/test/mocks/prisma-client.mock.ts b/app/backend/test/mocks/prisma-client.mock.ts index 0677700c..d7f61313 100644 --- a/app/backend/test/mocks/prisma-client.mock.ts +++ b/app/backend/test/mocks/prisma-client.mock.ts @@ -16,12 +16,19 @@ export class PrismaClient { return new Proxy(this, { get(target, prop) { if (prop === '$connect') return jest.fn().mockResolvedValue(undefined); - if (prop === '$disconnect') return jest.fn().mockResolvedValue(undefined); + if (prop === '$disconnect') + return jest.fn().mockResolvedValue(undefined); if (prop === '$on') return jest.fn(); if (prop === '$transaction') { - return jest.fn((cb) => Promise.resolve(typeof cb === 'function' ? cb(this) : cb)); + return jest.fn(cb => + Promise.resolve(typeof cb === 'function' ? cb(this) : cb), + ); } - if (typeof prop === 'symbol' || prop === 'constructor' || prop === 'then') { + if ( + typeof prop === 'symbol' || + prop === 'constructor' || + prop === 'then' + ) { return (target as any)[prop]; } return createModelMock(); diff --git a/app/backend/test/pii-interceptor.spec.ts b/app/backend/test/pii-interceptor.spec.ts new file mode 100644 index 00000000..fd11a185 --- /dev/null +++ b/app/backend/test/pii-interceptor.spec.ts @@ -0,0 +1,259 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../cache/redis.service'; +import { PiiScrubInterceptor } from '../src/common/interceptors/pii-scrub.interceptor'; +import { + ExecutionContext, + CallHandler, + UnprocessableEntityException, +} from '@nestjs/common'; +import { of } from 'rxjs'; +import axios from 'axios'; + +jest.mock('axios'); + +describe('PiiScrubInterceptor', () => { + let interceptor: PiiScrubInterceptor; + let redisService: jest.Mocked; + + const mockConfig: Record = { + PII_SCRUB_MODE: 'redact', + PII_HIGH_RISK_KEYS: + 'email,phone,name,nin,recipientRef,metadata,content,input,output', + AI_SERVICE_URL: 'http://localhost:8000', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PiiScrubInterceptor, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => mockConfig[key]), + }, + }, + { + provide: RedisService, + useValue: { + get: jest.fn(), + set: jest.fn(), + }, + }, + ], + }).compile(); + + interceptor = module.get(PiiScrubInterceptor); + redisService = module.get(RedisService); + + // Reset mocks + jest.clearAllMocks(); + }); + + const createMockContext = (body: any, user?: any): ExecutionContext => { + const req = { body, user }; + return { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => ({}), + }), + getType: () => 'http', + getClass: () => ({}), + getHandler: () => ({}), + } as unknown as ExecutionContext; + }; + + const mockCallHandler: CallHandler = { + handle: () => of('next-called'), + }; + + describe('PII scrubbing - redact mode', () => { + it('should redact common PII patterns in high-risk keys', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); // Force fallback to default patterns + + const body = { + metadata: { + recipientEmail: 'john.doe@example.com', + phone_number: '+234 803 123 4567', + notes: 'This is fine.', + }, + campaignName: 'Clean Water Project', // not high risk key + otherField: 'jane.smith@example.com', // not high risk key + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.metadata.recipientEmail).toBe('[EMAIL_ADDRESS]'); + expect(req.body.metadata.phone_number).toBe('[PHONE_NUMBER]'); + expect(req.body.metadata.notes).toBe('This is fine.'); + expect(req.body.campaignName).toBe('Clean Water Project'); // untouched + expect(req.body.otherField).toBe('jane.smith@example.com'); // untouched since key is not high risk + }); + + it('should handle deep nesting and arrays', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); + + const body = { + metadata: { + nested: { + email: 'test@example.com', + }, + list: [{ name: 'John Doe' }, { phone: '+2348031234567' }], + }, + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.metadata.nested.email).toBe('[EMAIL_ADDRESS]'); + expect(req.body.metadata.list[0].name).toBe('[RECIPIENT_NAME]'); + expect(req.body.metadata.list[1].phone).toBe('[PHONE_NUMBER]'); + }); + }); + + describe('PII scrubbing - reject mode', () => { + beforeEach(() => { + mockConfig.PII_SCRUB_MODE = 'reject'; + }); + + afterEach(() => { + mockConfig.PII_SCRUB_MODE = 'redact'; + }); + + it('should throw 422 Unprocessable Entity Listing all offending paths', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); + + const body = { + metadata: { + recipientEmail: 'bad@example.com', + phone: '08031234567', + }, + recipientRef: '99999999999', // matches NIN pattern + }; + + const context = createMockContext(body); + await expect( + interceptor.intercept(context, mockCallHandler), + ).rejects.toThrow(UnprocessableEntityException); + + try { + await interceptor.intercept(context, mockCallHandler); + } catch (err: any) { + expect(err.getStatus()).toBe(422); + const response = err.getResponse(); + expect(response.errors).toContain('metadata.recipientEmail'); + expect(response.errors).toContain('metadata.phone'); + expect(response.errors).toContain('recipientRef'); + } + }); + }); + + describe('PII scrubbing - off mode', () => { + beforeEach(() => { + mockConfig.PII_SCRUB_MODE = 'off'; + }); + + afterEach(() => { + mockConfig.PII_SCRUB_MODE = 'redact'; + }); + + it('should not redact or reject when scrub mode is off', async () => { + const body = { + metadata: { + recipientEmail: 'test@example.com', + }, + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.metadata.recipientEmail).toBe('test@example.com'); + }); + }); + + describe('Allowlist / JWT subject bypass', () => { + it('should bypass scrubbing for recipientRef if it matches request user ID/subject', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); + + const body = { + recipientRef: 'user-jwt-subject-123', + metadata: { + recipientEmail: 'john@example.com', // still scrubbed + }, + }; + + // Mock user is authenticated with sub = 'user-jwt-subject-123' + const context = createMockContext(body, { sub: 'user-jwt-subject-123' }); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.recipientRef).toBe('user-jwt-subject-123'); // bypassed PII scrub + expect(req.body.metadata.recipientEmail).toBe('[EMAIL_ADDRESS]'); // still scrubbed + }); + }); + + describe('Patterns source caching and refresh', () => { + it('should fetch from Redis if available', async () => { + const mockPatterns = { + email: ['[a-z]+@[a-z]+\\.com'], + }; + redisService.get.mockResolvedValue(mockPatterns); + + const body = { + email: 'hello@world.com', + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(redisService.get).toHaveBeenCalledWith('pii:patterns'); + expect(axios.get).not.toHaveBeenCalled(); + expect(req.body.email).toBe('[EMAIL_ADDRESS]'); + }); + + it('should fetch from AI Service and cache in Redis on Redis cache miss', async () => { + redisService.get.mockResolvedValue(null); + const mockPatterns = { + email: ['[a-z]+@[a-z]+\\.com'], + }; + (axios.get as jest.Mock).mockResolvedValue({ data: mockPatterns }); + + const body = { + email: 'hello@world.com', + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(redisService.get).toHaveBeenCalledWith('pii:patterns'); + expect(axios.get).toHaveBeenCalledWith( + 'http://localhost:8000/api/v1/pii/patterns', + { timeout: 3000 }, + ); + expect(redisService.set).toHaveBeenCalledWith( + 'pii:patterns', + mockPatterns, + 3600, + ); + expect(req.body.email).toBe('[EMAIL_ADDRESS]'); + }); + }); +}); diff --git a/app/backend/test/security.e2e-spec.ts b/app/backend/test/security.e2e-spec.ts index a5835185..23f4f4a4 100644 --- a/app/backend/test/security.e2e-spec.ts +++ b/app/backend/test/security.e2e-spec.ts @@ -12,6 +12,8 @@ import { import { RedisService } from '@liaoliaots/nestjs-redis'; import RedisMock from 'ioredis-mock'; +jest.setTimeout(90000); + type TestAppOptions = { enableDocs: boolean; }; @@ -236,10 +238,10 @@ describe('Security (e2e)', () => { it('should rate limit, include retry headers, and reset after the window passes', async () => { const server = rateLimitApp.getHttpServer(); - await request(server).get('/api/v1/'); - await request(server).get('/api/v1/'); + await request(server).get('/api/v1/deprecated-test'); + await request(server).get('/api/v1/deprecated-test'); - const limited = await request(server).get('/api/v1/'); + const limited = await request(server).get('/api/v1/deprecated-test'); expect(limited.status).toBe(429); expect(limited.headers['retry-after']).toBeDefined(); @@ -248,7 +250,9 @@ describe('Security (e2e)', () => { now += windowMs + 1; - const resetResponse = await request(server).get('/api/v1/'); + const resetResponse = await request(server).get( + '/api/v1/deprecated-test', + ); expect(resetResponse.status).toBe(200); }); @@ -278,7 +282,9 @@ describe('Security (e2e)', () => { const appInstance = await createTestApp({ enableDocs: false }); const redisService = appInstance.get(RedisService); const testMockRedis = new RedisMock(); - jest.spyOn(redisService, 'getOrThrow').mockReturnValue(testMockRedis as any); + jest + .spyOn(redisService, 'getOrThrow') + .mockReturnValue(testMockRedis as any); const server = appInstance.getHttpServer(); const results: any[] = []; @@ -310,7 +316,9 @@ describe('Security (e2e)', () => { throw new Error('Redis connection down'); }); - const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + const warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => {}); const server = appInstance.getHttpServer(); const response = await request(server).get('/api/v1/'); @@ -332,4 +340,60 @@ describe('Security (e2e)', () => { expect(response.text).toContain('Swagger UI'); }); }); + + describe('Unexpected Auth Headers', () => { + let originalBypass: string | undefined; + + beforeEach(() => { + originalBypass = process.env.PUBLIC_AUTH_BYPASS; + }); + + afterEach(() => { + setEnvValue('PUBLIC_AUTH_BYPASS', originalBypass); + }); + + it('should reject a request with a Bearer header on an undecorated route when not bypassed', async () => { + process.env.PUBLIC_AUTH_BYPASS = '/api/docs,/ai/metrics'; + const testApp = await createTestApp({ enableDocs: false }); + + const response = await request(testApp.getHttpServer()) + .get('/api/v1/health') + .set('Authorization', 'Bearer testtoken123'); + + expect(response.status).toBe(401); + expect(response.body.message).toContain( + 'Unexpected authorization credentials', + ); + + await testApp.close(); + }); + + it('should allow a request with a Bearer header on an undecorated route when explicitly bypassed in PUBLIC_AUTH_BYPASS', async () => { + process.env.PUBLIC_AUTH_BYPASS = '/health,/api/docs,/ai/metrics'; + const testApp = await createTestApp({ enableDocs: false }); + + const response = await request(testApp.getHttpServer()) + .get('/api/v1/health') + .set('Authorization', 'Bearer testtoken123'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); + + await testApp.close(); + }); + + it('should allow a request with a Bearer header on a route decorated with @NoAuthStrict()', async () => { + process.env.PUBLIC_AUTH_BYPASS = '/health,/api/docs,/ai/metrics'; + const testApp = await createTestApp({ enableDocs: false }); + + const response = await request(testApp.getHttpServer()) + .get('/api/v1/no-auth-strict-test') + .set('Authorization', 'Bearer testtoken123'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); + + await testApp.close(); + }); + }); }); diff --git a/app/backend/test/slo-histogram.e2e-spec.ts b/app/backend/test/slo-histogram.e2e-spec.ts index eda32e66..60db9e89 100644 --- a/app/backend/test/slo-histogram.e2e-spec.ts +++ b/app/backend/test/slo-histogram.e2e-spec.ts @@ -20,7 +20,7 @@ import { MetricsService } from '../src/observability/metrics/metrics.service'; // ── Histogram helper ───────────────────────────────────────────────────────── interface HistogramSample { - le: string; // upper bound, "+Inf" for the catch-all bucket + le: string; // upper bound, "+Inf" for the catch-all bucket count: number; } @@ -51,7 +51,10 @@ function parseBuckets( const eq = pair.indexOf('='); if (eq === -1) continue; const k = pair.slice(0, eq).trim(); - const v = pair.slice(eq + 1).trim().replace(/^"|"$/g, ''); + const v = pair + .slice(eq + 1) + .trim() + .replace(/^"|"$/g, ''); labelMap[k] = v; } @@ -106,7 +109,15 @@ describe('Tail-latency SLO histogram (issue #243)', () => { * If the buckets change there, this test will catch the drift. */ const EXPECTED_BUCKETS = [ - '0.025', '0.05', '0.1', '0.25', '0.5', '1', '2.5', '5', '10', + '0.025', + '0.05', + '0.1', + '0.25', + '0.5', + '1', + '2.5', + '5', + '10', ]; beforeAll(async () => { @@ -144,21 +155,26 @@ describe('Tail-latency SLO histogram (issue #243)', () => { // Distribute 1 000 observations evenly across 10 latency bands // (100 per band) covering the full bucket range. const bands = [ - 0.015, // below first bucket (< 25 ms) - 0.03, // 25–50 ms - 0.07, // 50–100 ms - 0.15, // 100–250 ms - 0.35, // 250–500 ms - 0.75, // 500 ms–1 s - 1.5, // 1–2.5 s - 3.5, // 2.5–5 s - 7.5, // 5–10 s - 12.0, // > 10 s (overflow) + 0.015, // below first bucket (< 25 ms) + 0.03, // 25–50 ms + 0.07, // 50–100 ms + 0.15, // 100–250 ms + 0.35, // 250–500 ms + 0.75, // 500 ms–1 s + 1.5, // 1–2.5 s + 3.5, // 2.5–5 s + 7.5, // 5–10 s + 12.0, // > 10 s (overflow) ]; for (const durationSeconds of bands) { for (let i = 0; i < TOTAL_REQUESTS / bands.length; i++) { - metricsService.recordHttpDuration('GET', TARGET_ROUTE, durationSeconds, 200); + metricsService.recordHttpDuration( + 'GET', + TARGET_ROUTE, + durationSeconds, + 200, + ); } } }); @@ -173,7 +189,7 @@ describe('Tail-latency SLO histogram (issue #243)', () => { status_code: '200', }); - const infBucket = buckets.find((b) => b.le === '+Inf'); + const infBucket = buckets.find(b => b.le === '+Inf'); expect(infBucket).toBeDefined(); // +Inf is cumulative — it must be ≥ our 1 000 injected observations // (may include earlier observations from the warm-up HTTP calls). @@ -200,7 +216,7 @@ describe('Tail-latency SLO histogram (issue #243)', () => { const buckets = parseBuckets(res.text, METRIC_NAME); - const presentLe = new Set(buckets.map((b) => b.le)); + const presentLe = new Set(buckets.map(b => b.le)); for (const expected of EXPECTED_BUCKETS) { expect(presentLe).toContain(expected); } @@ -216,12 +232,12 @@ describe('Tail-latency SLO histogram (issue #243)', () => { status_code: '200', }); - const infBucket = buckets.find((b) => b.le === '+Inf'); + const infBucket = buckets.find(b => b.le === '+Inf'); expect(infBucket).toBeDefined(); // The +Inf count is the grand total — it must be ≥ all finite buckets const maxFinite = Math.max( - ...buckets.filter((b) => b.le !== '+Inf').map((b) => b.count), + ...buckets.filter(b => b.le !== '+Inf').map(b => b.count), ); expect(infBucket!.count).toBeGreaterThanOrEqual(maxFinite); }); @@ -241,7 +257,9 @@ describe('Tail-latency SLO histogram (issue #243)', () => { expect(res.status).toBe(200); // Look for a bucket line with status_code="201" - expect(res.text).toMatch(/http_request_duration_seconds_bucket\{[^}]*status_code="201"/); + expect(res.text).toMatch( + /http_request_duration_seconds_bucket\{[^}]*status_code="201"/, + ); }); it('metric has help text that mentions SLO', async () => { @@ -258,14 +276,14 @@ describe('Tail-latency SLO histogram (issue #243)', () => { // Fetch /metrics baseline count const before = await request(app.getHttpServer()).get('/metrics'); const bucketsBefore = parseBuckets(before.text, METRIC_NAME); - const infBefore = bucketsBefore.find((b) => b.le === '+Inf')?.count ?? 0; + const infBefore = bucketsBefore.find(b => b.le === '+Inf')?.count ?? 0; // Make one real request through the full middleware stack await request(app.getHttpServer()).get(TARGET_ROUTE); const after = await request(app.getHttpServer()).get('/metrics'); const bucketsAfter = parseBuckets(after.text, METRIC_NAME); - const infAfter = bucketsAfter.find((b) => b.le === '+Inf')?.count ?? 0; + const infAfter = bucketsAfter.find(b => b.le === '+Inf')?.count ?? 0; // The +Inf counter must have grown by at least 1 expect(infAfter).toBeGreaterThan(infBefore); diff --git a/app/backend/test/upload-roundtrip.spec.ts b/app/backend/test/upload-roundtrip.spec.ts index 379bc932..ef29af94 100644 --- a/app/backend/test/upload-roundtrip.spec.ts +++ b/app/backend/test/upload-roundtrip.spec.ts @@ -21,7 +21,7 @@ describe('AES Envelope Round-Trip (Property-Based Test)', () => { fc.assert( fc.property( fc.double({ min: 0, max: 1, noNaN: true, noInfinity: true }), - (d) => { + d => { let size: number; if (d < 0.1) { // 10% of tests are large (1 MB to 100 MB) @@ -67,9 +67,9 @@ describe('AES Envelope Round-Trip (Property-Based Test)', () => { expect(decryptedChecksum).toBe(originalChecksum); expect(decrypted.equals(originalBuffer)).toBe(true); - } + }, ), - { numRuns: 1000 } + { numRuns: 1000 }, ); }, 35000); // 35 second timeout to be safe, though it should complete in under 5 seconds });