From 1b809b05fcfd9d169324ff33eb1bcaf71c0f2b9c Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Thu, 16 Jul 2026 10:15:27 +0100 Subject: [PATCH 1/5] feat: implement PiiScrubInterceptor for PII hardening in metadata and payload fields --- app/ai-service/main.py | 13 + app/backend/src/app.module.ts | 4 + .../interceptors/pii-scrub.interceptor.ts | 254 ++++++++++++++++++ app/backend/src/main.ts | 8 + app/backend/test/pii-interceptor.spec.ts | 244 +++++++++++++++++ pnpm-lock.yaml | 62 ++--- 6 files changed, 547 insertions(+), 38 deletions(-) create mode 100644 app/backend/src/common/interceptors/pii-scrub.interceptor.ts create mode 100644 app/backend/test/pii-interceptor.spec.ts diff --git a/app/ai-service/main.py b/app/ai-service/main.py index b067e438..f8c00393 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -482,6 +482,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/src/app.module.ts b/app/backend/src/app.module.ts index 3935b65e..d548437c 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -45,6 +45,8 @@ import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guar import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; +import { RedisService as CustomRedisService } from '../cache/redis.service'; + @Module({ imports: [ @@ -135,6 +137,8 @@ import { SandboxModule } from './sandbox/sandbox.module'; controllers: [AppController], providers: [ AppService, + CustomRedisService, + { provide: APP_FILTER, useClass: AllExceptionsFilter, 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..f51cfde1 --- /dev/null +++ b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts @@ -0,0 +1,254 @@ +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 (e) { + // 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 (err) { + // Redis set fail is non-fatal + } + return this.cachedPatterns; + } + } catch (err) { + // 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 (e) { + // Skip invalid patterns + } + } + } + return compiled; + } +} diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 9fa25572..08a83e80 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -12,6 +12,8 @@ import { join } from 'node:path'; 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, @@ -19,6 +21,7 @@ import { createRateLimiter, } from './common/security/security.module'; + async function bootstrap() { // Load environment variables const candidates = [ @@ -77,9 +80,14 @@ 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)); + // Swagger/OpenAPI Documentation const document = SwaggerModule.createDocument(app, buildSwaggerConfig()); SwaggerModule.setup('api/docs', app, document, { diff --git a/app/backend/test/pii-interceptor.spec.ts b/app/backend/test/pii-interceptor.spec.ts new file mode 100644 index 00000000..3d54fb92 --- /dev/null +++ b/app/backend/test/pii-interceptor.spec.ts @@ -0,0 +1,244 @@ +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 configService: jest.Mocked; + 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); + configService = module.get(ConfigService); + 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/pnpm-lock.yaml b/pnpm-lock.yaml index fdd7d856..89a56385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.4 + compression: + specifier: 1.7.5 + version: 1.7.5 dotenv: specifier: ^17.2.3 version: 17.4.2 @@ -185,6 +188,9 @@ importers: '@nestjs/testing': specifier: ^11.0.1 version: 11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)) + '@types/compression': + specifier: 1.7.5 + version: 1.7.5 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -362,7 +368,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: ^16.2.1 - version: 16.2.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.4(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) jest: specifier: ^30.4.2 version: 30.4.2(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) @@ -3242,6 +3248,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -4370,8 +4379,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -6973,8 +6982,8 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} once@1.4.0: @@ -9792,7 +9801,7 @@ snapshots: bplist-parser: 0.3.2 chalk: 4.1.2 ci-info: 3.9.0 - compression: 1.8.1 + compression: 1.7.5 connect: 3.7.0 debug: 4.4.3(supports-color@10.2.2) env-editor: 0.4.2 @@ -12192,6 +12201,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.9.1 + '@types/compression@1.7.5': + dependencies: + '@types/express': 5.0.6 + '@types/connect@3.4.38': dependencies: '@types/node': 25.9.1 @@ -13661,13 +13674,13 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.7.5: dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 negotiator: 0.6.4 - on-headers: 1.1.0 + on-headers: 1.0.2 safe-buffer: 5.2.1 vary: 1.1.2 transitivePeerDependencies: @@ -14114,13 +14127,13 @@ snapshots: - supports-color - typescript - eslint-config-next@16.2.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.4(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.4 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) @@ -14210,33 +14223,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -17178,7 +17164,7 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} + on-headers@1.0.2: {} once@1.4.0: dependencies: From 44199e6de0ab099f86e742e77dde4b08728f7ec5 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Thu, 16 Jul 2026 12:35:00 +0100 Subject: [PATCH 2/5] feat: implement UnexpectedAuthHeaderGuard and nestjs-route-doctor --- .github/workflows/backend-ci.yml | 7 ++ app/backend/package.json | 6 +- app/backend/scripts/nestjs-route-doctor.ts | 103 ++++++++++++++++++ app/backend/src/app.controller.ts | 8 ++ app/backend/src/app.module.ts | 49 ++++++--- .../decorators/no-auth-strict.decorator.ts | 4 + .../guards/unexpected-auth-header.guard.ts | 75 +++++++++++++ app/backend/test/jest-e2e.json | 3 + app/backend/test/mocks/bullmq.mock.ts | 100 +++++++++++++++++ app/backend/test/mocks/ioredis.mock.ts | 4 + app/backend/test/security.e2e-spec.ts | 65 ++++++++++- 11 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 app/backend/scripts/nestjs-route-doctor.ts create mode 100644 app/backend/src/common/decorators/no-auth-strict.decorator.ts create mode 100644 app/backend/src/common/guards/unexpected-auth-header.guard.ts create mode 100644 app/backend/test/mocks/bullmq.mock.ts create mode 100644 app/backend/test/mocks/ioredis.mock.ts diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 7c5fd774..6782adc5 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -34,6 +34,13 @@ jobs: - name: Lint run: pnpm --filter backend run lint + - name: Run NestJS Route Doctor + run: pnpm --filter backend run nestjs-route-doctor + env: + DATABASE_URL: file:./prisma/dev.db + PUBLIC_AUTH_BYPASS: /health,/api/docs,/ai/metrics + NODE_ENV: test + - name: Test run: pnpm --filter backend run test diff --git a/app/backend/package.json b/app/backend/package.json index c6a274fb..5e905259 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -27,7 +27,8 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e:ci": "prisma migrate deploy && jest --config ./test/jest-e2e.json --runInBand", - "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts" + "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts", + "nestjs-route-doctor": "ts-node --transpile-only -r tsconfig-paths/register scripts/nestjs-route-doctor.ts" }, "dependencies": { "@liaoliaots/nestjs-redis": "^10.0.0", @@ -113,7 +114,8 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "^src/(.*)$": "/src/$1" + "^src/(.*)$": "/src/$1", + "^cache/(.*)$": "/cache/$1" } }, "prisma": { 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/app.module.ts b/app/backend/src/app.module.ts index d548437c..71cd1c41 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -20,9 +20,10 @@ import { JobsModule } from './jobs/jobs.module'; import { RequestCorrelationMiddleware } from './middleware/request-correlation.middleware'; import { SecurityModule } from './common/security/security.module'; import { CampaignsModule } from './campaigns/campaigns.module'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, DiscoveryModule } from '@nestjs/core'; import { ApiKeyGuard } from './common/guards/api-key.guard'; import { RolesGuard } from './auth/roles.guard'; +import { UnexpectedAuthHeaderGuard } from './common/guards/unexpected-auth-header.guard'; import { ObservabilityModule } from './observability/observability.module'; import { ClaimsModule } from './claims/claims.module'; import { LoggingInterceptor } from './interceptors/logging.interceptor'; @@ -66,11 +67,16 @@ import { RedisService as CustomRedisService } from '../cache/redis.service'; BullModule.forRootAsync({ imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - connection: { - host: configService.get('REDIS_HOST') ?? 'localhost', - port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), - }, + useFactory: (configService: ConfigService) => { + const isTest = process.env.NODE_ENV === 'test'; + return { + connection: { + host: configService.get('REDIS_HOST') ?? 'localhost', + port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), + maxRetriesPerRequest: isTest ? 0 : null, + enableReadyCheck: !isTest, + retryStrategy: isTest ? () => null : undefined, + }, defaultJobOptions: { attempts: 3, backoff: { @@ -86,9 +92,10 @@ import { RedisService as CustomRedisService } from '../cache/redis.service'; count: 5000, }, }, - }), - inject: [ConfigService], - }), + }; + }, + inject: [ConfigService], + }), ScheduleModule.forRoot(), LoggerModule, @@ -116,14 +123,20 @@ import { RedisService as CustomRedisService } from '../cache/redis.service'; EntityLinkingModule, DeploymentMetadataModule, SandboxModule, + DiscoveryModule, RedisModule.forRootAsync({ imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - config: { - host: configService.get('REDIS_HOST') ?? 'localhost', - port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), - }, - }), + useFactory: (configService: ConfigService) => { + const isTest = process.env.NODE_ENV === 'test'; + return { + config: { + host: configService.get('REDIS_HOST') ?? 'localhost', + port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), + maxRetriesPerRequest: isTest ? 0 : 3, + retryStrategy: isTest ? () => null : undefined, + }, + }; + }, inject: [ConfigService], }), ThrottlerModule.forRoot([ @@ -149,7 +162,11 @@ import { RedisService as CustomRedisService } from '../cache/redis.service'; }, { provide: APP_GUARD, - useClass: RolesGuard, // runs second — checks request.user.role against @Roles() + useClass: UnexpectedAuthHeaderGuard, // checks for unexpected auth headers on undecorated routes + }, + { + provide: APP_GUARD, + useClass: RolesGuard, // runs third — checks request.user.role against @Roles() }, { provide: APP_GUARD, 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..bfb8955d --- /dev/null +++ b/app/backend/src/common/guards/unexpected-auth-header.guard.ts @@ -0,0 +1,75 @@ +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/test/jest-e2e.json b/app/backend/test/jest-e2e.json index 988edaeb..c2ed31e6 100644 --- a/app/backend/test/jest-e2e.json +++ b/app/backend/test/jest-e2e.json @@ -8,6 +8,9 @@ }, "moduleNameMapper": { "^src/(.*)$": "/src/$1", + "^cache/(.*)$": "/cache/$1", + "^ioredis$": "/test/mocks/ioredis.mock.ts", + "^@nestjs/bullmq$": "/test/mocks/bullmq.mock.ts", "^@stellar/stellar-sdk$": "/test/mocks/stellar-sdk.mock.ts", "^openai$": "/test/mocks/openai.mock.ts" } diff --git a/app/backend/test/mocks/bullmq.mock.ts b/app/backend/test/mocks/bullmq.mock.ts new file mode 100644 index 00000000..521fc31a --- /dev/null +++ b/app/backend/test/mocks/bullmq.mock.ts @@ -0,0 +1,100 @@ +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/security.e2e-spec.ts b/app/backend/test/security.e2e-spec.ts index c76c8e12..1bbb2a9c 100644 --- a/app/backend/test/security.e2e-spec.ts +++ b/app/backend/test/security.e2e-spec.ts @@ -11,6 +11,8 @@ import { createRateLimiter, } from '../src/common/security/security.module'; +jest.setTimeout(90000); + type TestAppOptions = { enableDocs: boolean; }; @@ -29,6 +31,7 @@ const createTestApp = async ({ enableDocs }: TestAppOptions) => { }).compile(); const app = moduleFixture.createNestApplication(); + (app as any).getHttpAdapter().getInstance().disable('x-powered-by'); app.setGlobalPrefix('api'); app.enableVersioning({ @@ -198,10 +201,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(); @@ -210,7 +213,7 @@ 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); }); @@ -241,4 +244,58 @@ 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(); + }); + }); }); From 54e3db8646f5e010cab4534742904b47c42045d0 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Fri, 17 Jul 2026 15:35:00 +0100 Subject: [PATCH 3/5] fix: resolve lint and formatting errors in PiiScrubInterceptor and test suite --- app/backend/src/app.module.ts | 47 +++++++------ .../interceptors/pii-scrub.interceptor.ts | 67 ++++++++++++------- app/backend/src/main.ts | 6 +- app/backend/test/pii-interceptor.spec.ts | 45 ++++++++----- 4 files changed, 103 insertions(+), 62 deletions(-) diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 71cd1c41..e2da21b2 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -48,7 +48,6 @@ import { HttpCacheInterceptor } from './common/interceptors/http-cache.intercept import { SandboxModule } from './sandbox/sandbox.module'; import { RedisService as CustomRedisService } from '../cache/redis.service'; - @Module({ imports: [ ConfigModule.forRoot({ @@ -72,30 +71,33 @@ import { RedisService as CustomRedisService } from '../cache/redis.service'; return { connection: { host: configService.get('REDIS_HOST') ?? 'localhost', - port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), + port: parseInt( + configService.get('REDIS_PORT') ?? '6379', + 10, + ), maxRetriesPerRequest: isTest ? 0 : null, enableReadyCheck: !isTest, retryStrategy: isTest ? () => null : undefined, }, - defaultJobOptions: { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - removeOnComplete: { - age: 3600, // keep for 1 hour - count: 1000, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + removeOnComplete: { + age: 3600, // keep for 1 hour + count: 1000, + }, + removeOnFail: { + age: 24 * 3600, // keep for 24 hours + count: 5000, + }, }, - removeOnFail: { - age: 24 * 3600, // keep for 24 hours - count: 5000, - }, - }, - }; - }, - inject: [ConfigService], - }), + }; + }, + inject: [ConfigService], + }), ScheduleModule.forRoot(), LoggerModule, @@ -131,7 +133,10 @@ import { RedisService as CustomRedisService } from '../cache/redis.service'; return { config: { host: configService.get('REDIS_HOST') ?? 'localhost', - port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), + port: parseInt( + configService.get('REDIS_PORT') ?? '6379', + 10, + ), maxRetriesPerRequest: isTest ? 0 : 3, retryStrategy: isTest ? () => null : undefined, }, diff --git a/app/backend/src/common/interceptors/pii-scrub.interceptor.ts b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts index f51cfde1..79ee9e94 100644 --- a/app/backend/src/common/interceptors/pii-scrub.interceptor.ts +++ b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts @@ -10,39 +10,36 @@ 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' - ], + 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' + '\\+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' + '\\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' + '\\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' + '\\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 - ] + '\\b[A-Z]{2}\\d{8}\\b', // Voter ID + ], }; const MAP_KEY_TO_TOKEN: Record = { @@ -51,7 +48,7 @@ const MAP_KEY_TO_TOKEN: Record = { name: 'RECIPIENT_NAME', location: 'LOCATION', date: 'EVENT_DATE', - id: 'ID_NUMBER' + id: 'ID_NUMBER', }; @Injectable() @@ -64,7 +61,10 @@ export class PiiScrubInterceptor implements NestInterceptor { private readonly redisService: RedisService, ) {} - async intercept(context: ExecutionContext, next: CallHandler): Promise> { + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { const request = context.switchToHttp().getRequest(); if (!request || !request.body || typeof request.body !== 'object') { return next.handle(); @@ -75,12 +75,24 @@ export class PiiScrubInterceptor implements NestInterceptor { return next.handle(); } - const highRiskKeysConfig = this.configService.get('PII_HIGH_RISK_KEYS'); + 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']; + : [ + 'email', + 'phone', + 'name', + 'nin', + 'recipientref', + 'metadata', + 'content', + 'input', + 'output', + ]; - const userSub = request.user?.sub || request.user?.id || request.user?.apiKeyId; + const userSub = + request.user?.sub || request.user?.id || request.user?.apiKeyId; const patterns = await this.getCompiledPatterns(); const offendingPaths: string[] = []; @@ -89,7 +101,9 @@ export class PiiScrubInterceptor implements NestInterceptor { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) { - return obj.map((item, index) => traverseAndScrub(item, path ? `${path}[${index}]` : `[${index}]`)); + return obj.map((item, index) => + traverseAndScrub(item, path ? `${path}[${index}]` : `[${index}]`), + ); } if (typeof obj === 'object') { @@ -168,8 +182,10 @@ export class PiiScrubInterceptor implements NestInterceptor { }); } - - private checkPiiMatches(val: string, patterns: CompiledPattern[]): CompiledPattern[] { + 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 @@ -192,13 +208,14 @@ export class PiiScrubInterceptor implements NestInterceptor { private async getCompiledPatterns(): Promise { const now = Date.now(); - if (this.cachedPatterns && (now - this.lastFetchedMemoryTime < 60000)) { + if (this.cachedPatterns && now - this.lastFetchedMemoryTime < 60000) { return this.cachedPatterns; } // Try Redis try { - const redisPatterns = await this.redisService.get>('pii:patterns'); + const redisPatterns = + await this.redisService.get>('pii:patterns'); if (redisPatterns) { this.cachedPatterns = this.compilePatterns(redisPatterns); this.lastFetchedMemoryTime = now; @@ -209,9 +226,13 @@ export class PiiScrubInterceptor implements NestInterceptor { } // Try AI Service - const aiServiceUrl = this.configService.get('AI_SERVICE_URL') || 'http://localhost:8000'; + const aiServiceUrl = + this.configService.get('AI_SERVICE_URL') || + 'http://localhost:8000'; try { - const response = await axios.get(`${aiServiceUrl}/api/v1/pii/patterns`, { timeout: 3000 }); + 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); @@ -242,7 +263,7 @@ export class PiiScrubInterceptor implements NestInterceptor { try { compiled.push({ label, - regex: new RegExp(pattern, 'g') + regex: new RegExp(pattern, 'g'), }); } catch (e) { // Skip invalid patterns diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 08a83e80..7e8298b1 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -21,7 +21,6 @@ import { createRateLimiter, } from './common/security/security.module'; - async function bootstrap() { // Load environment variables const candidates = [ @@ -82,12 +81,13 @@ async function bootstrap() { // Register PII Scrubbing Interceptor const redisService = app.get(CustomRedisService); - app.useGlobalInterceptors(new PiiScrubInterceptor(configService, redisService)); + app.useGlobalInterceptors( + new PiiScrubInterceptor(configService, redisService), + ); // Global interceptors app.useGlobalInterceptors(new LoggingInterceptor(logger)); - // Swagger/OpenAPI Documentation const document = SwaggerModule.createDocument(app, buildSwaggerConfig()); SwaggerModule.setup('api/docs', app, document, { diff --git a/app/backend/test/pii-interceptor.spec.ts b/app/backend/test/pii-interceptor.spec.ts index 3d54fb92..fd11a185 100644 --- a/app/backend/test/pii-interceptor.spec.ts +++ b/app/backend/test/pii-interceptor.spec.ts @@ -2,7 +2,11 @@ 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 { + ExecutionContext, + CallHandler, + UnprocessableEntityException, +} from '@nestjs/common'; import { of } from 'rxjs'; import axios from 'axios'; @@ -10,12 +14,12 @@ jest.mock('axios'); describe('PiiScrubInterceptor', () => { let interceptor: PiiScrubInterceptor; - let configService: jest.Mocked; let redisService: jest.Mocked; const mockConfig: Record = { PII_SCRUB_MODE: 'redact', - PII_HIGH_RISK_KEYS: 'email,phone,name,nin,recipientRef,metadata,content,input,output', + PII_HIGH_RISK_KEYS: + 'email,phone,name,nin,recipientRef,metadata,content,input,output', AI_SERVICE_URL: 'http://localhost:8000', }; @@ -40,7 +44,6 @@ describe('PiiScrubInterceptor', () => { }).compile(); interceptor = module.get(PiiScrubInterceptor); - configService = module.get(ConfigService); redisService = module.get(RedisService); // Reset mocks @@ -67,7 +70,9 @@ describe('PiiScrubInterceptor', () => { 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 + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); // Force fallback to default patterns const body = { metadata: { @@ -92,17 +97,16 @@ describe('PiiScrubInterceptor', () => { it('should handle deep nesting and arrays', async () => { redisService.get.mockResolvedValue(null); - (axios.get as jest.Mock).mockRejectedValue(new Error('AI service offline')); + (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' }, - ], + list: [{ name: 'John Doe' }, { phone: '+2348031234567' }], }, }; @@ -127,7 +131,9 @@ describe('PiiScrubInterceptor', () => { 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')); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); const body = { metadata: { @@ -139,7 +145,7 @@ describe('PiiScrubInterceptor', () => { const context = createMockContext(body); await expect( - interceptor.intercept(context, mockCallHandler) + interceptor.intercept(context, mockCallHandler), ).rejects.toThrow(UnprocessableEntityException); try { @@ -181,7 +187,9 @@ describe('PiiScrubInterceptor', () => { 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')); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); const body = { recipientRef: 'user-jwt-subject-123', @@ -236,8 +244,15 @@ describe('PiiScrubInterceptor', () => { 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(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]'); }); }); From bd88fca1f7020f090d021a214d96426bb9ffd6cf Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Fri, 17 Jul 2026 15:43:04 +0100 Subject: [PATCH 4/5] fix: resolve package.json syntax error --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index df23dfa0..b5a1524d 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "pnpm -r build", "lint": "pnpm -r lint", - "test": "pnpm -r test", - }, + "test": "pnpm -r test" + }, "pnpm": { "overrides": { "jest": "^30.4.2", From e563b6a32fa665d1a29d3ac9c11f90b72123a957 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Sun, 19 Jul 2026 18:19:41 +0100 Subject: [PATCH 5/5] fix: resolve all unused variable lint errors --- .../src/campaigns/campaigns.service.spec.ts | 4 +- .../src/campaigns/campaigns.service.ts | 7 +- .../src/claims/claim-export.controller.ts | 8 +- .../src/claims/claim-receipt.controller.ts | 8 +- app/backend/src/claims/claims.service.ts | 5 +- .../src/common/budget/budget.service.spec.ts | 1 - .../guards/unexpected-auth-header.guard.ts | 7 +- .../interceptors/pii-scrub.interceptor.ts | 8 +- .../common/security/csp-report.controller.ts | 11 +- .../src/common/security/security.module.ts | 19 +- .../common/utils/__tests__/env-loader.spec.ts | 2 +- app/backend/src/common/utils/env-loader.ts | 10 +- app/backend/src/logger/logger.service.spec.ts | 4 +- .../metrics/metrics.middleware.ts | 7 +- app/backend/test/audit-partitioning.spec.ts | 21 +- app/backend/test/audit.e2e-spec.ts | 11 +- app/backend/test/campaigns.e2e-spec.ts | 11 +- .../test/claim-lifecycle.integration-spec.ts | 4 +- app/backend/test/claims.e2e-spec.ts | 85 +++- .../test/contract/ai-service.contract.spec.ts | 392 ++++++++++++++---- app/backend/test/mocks/bullmq.mock.ts | 21 +- app/backend/test/mocks/prisma-client.mock.ts | 13 +- app/backend/test/security.e2e-spec.ts | 16 +- app/backend/test/slo-histogram.e2e-spec.ts | 60 ++- app/backend/test/upload-roundtrip.spec.ts | 6 +- 25 files changed, 538 insertions(+), 203 deletions(-) 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/guards/unexpected-auth-header.guard.ts b/app/backend/src/common/guards/unexpected-auth-header.guard.ts index bfb8955d..e0e98176 100644 --- a/app/backend/src/common/guards/unexpected-auth-header.guard.ts +++ b/app/backend/src/common/guards/unexpected-auth-header.guard.ts @@ -45,14 +45,15 @@ export class UnexpectedAuthHeaderGuard implements CanActivate { } // Check if the route is in the PUBLIC_AUTH_BYPASS list - const bypassEnv = this.configService.get('PUBLIC_AUTH_BYPASS') ?? ''; + const bypassEnv = + this.configService.get('PUBLIC_AUTH_BYPASS') ?? ''; const bypassedPaths = bypassEnv .split(',') - .map((p) => p.trim()) + .map(p => p.trim()) .filter(Boolean); const requestPath = request.path; - const isBypassed = bypassedPaths.some((bpath) => { + const isBypassed = bypassedPaths.some(bpath => { const cleanBPath = bpath.replace(/^\/+|\/+$/g, ''); const cleanReqPath = requestPath.replace(/^\/+|\/+$/g, ''); if (cleanBPath === cleanReqPath) return true; diff --git a/app/backend/src/common/interceptors/pii-scrub.interceptor.ts b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts index 79ee9e94..1e33c2cd 100644 --- a/app/backend/src/common/interceptors/pii-scrub.interceptor.ts +++ b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts @@ -221,7 +221,7 @@ export class PiiScrubInterceptor implements NestInterceptor { this.lastFetchedMemoryTime = now; return this.cachedPatterns; } - } catch (e) { + } catch { // Log / fallback silently } @@ -239,12 +239,12 @@ export class PiiScrubInterceptor implements NestInterceptor { this.lastFetchedMemoryTime = now; try { await this.redisService.set('pii:patterns', fetched, 3600); - } catch (err) { + } catch { // Redis set fail is non-fatal } return this.cachedPatterns; } - } catch (err) { + } catch { // AI Service offline or slow } @@ -265,7 +265,7 @@ export class PiiScrubInterceptor implements NestInterceptor { label, regex: new RegExp(pattern, 'g'), }); - } catch (e) { + } catch { // Skip invalid patterns } } 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/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 index 521fc31a..a8543999 100644 --- a/app/backend/test/mocks/bullmq.mock.ts +++ b/app/backend/test/mocks/bullmq.mock.ts @@ -22,7 +22,7 @@ export class MockBullModule { } static registerQueue(...queues: any[]): DynamicModule { - const providers = queues.map((queue) => { + const providers = queues.map(queue => { const name = typeof queue === 'string' ? queue : queue.name; return { provide: getQueueToken(name), @@ -44,8 +44,9 @@ export class MockBullModule { } static registerQueueAsync(...queues: any[]): DynamicModule { - const providers = queues.map((queue) => { - const name = typeof queue === 'string' ? queue : (queue.name ?? 'default'); + const providers = queues.map(queue => { + const name = + typeof queue === 'string' ? queue : (queue.name ?? 'default'); return { provide: getQueueToken(name), useValue: { @@ -65,7 +66,7 @@ export class MockBullModule { }; } - static registerFlowProducer(...producers: any[]): DynamicModule { + static registerFlowProducer(..._producers: any[]): DynamicModule { return { module: MockBullModule, providers: [], @@ -73,7 +74,7 @@ export class MockBullModule { }; } - static registerFlowProducerAsync(...producers: any[]): DynamicModule { + static registerFlowProducerAsync(..._producers: any[]): DynamicModule { return { module: MockBullModule, providers: [], @@ -84,7 +85,7 @@ export class MockBullModule { export const BullModule = MockBullModule; -export const Processor = (name?: string) => (target: any) => {}; +export const Processor = (_name?: string) => (_target: any) => {}; export class WorkerHost { worker: any = { @@ -93,8 +94,6 @@ export class WorkerHost { }; } -export const OnWorkerEvent = (event: string) => ( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, -) => {}; +export const OnWorkerEvent = + (_event: string) => + (_target: any, _propertyKey: string, _descriptor: PropertyDescriptor) => {}; 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/security.e2e-spec.ts b/app/backend/test/security.e2e-spec.ts index 0bd7b7f7..23f4f4a4 100644 --- a/app/backend/test/security.e2e-spec.ts +++ b/app/backend/test/security.e2e-spec.ts @@ -250,7 +250,9 @@ describe('Security (e2e)', () => { now += windowMs + 1; - const resetResponse = await request(server).get('/api/v1/deprecated-test'); + const resetResponse = await request(server).get( + '/api/v1/deprecated-test', + ); expect(resetResponse.status).toBe(200); }); @@ -280,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[] = []; @@ -312,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/'); @@ -355,7 +361,9 @@ describe('Security (e2e)', () => { .set('Authorization', 'Bearer testtoken123'); expect(response.status).toBe(401); - expect(response.body.message).toContain('Unexpected authorization credentials'); + expect(response.body.message).toContain( + 'Unexpected authorization credentials', + ); 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 });