diff --git a/apps/api/src/modules/health/__tests__/health.controller.spec.ts b/apps/api/src/modules/health/__tests__/health.controller.spec.ts new file mode 100644 index 0000000..90479e7 --- /dev/null +++ b/apps/api/src/modules/health/__tests__/health.controller.spec.ts @@ -0,0 +1,77 @@ +import { type HealthCheckService } from '@nestjs/terminus'; +import { HealthController } from '../health.controller'; +import { type PrismaHealthIndicator } from '../infrastructure/prisma.health'; +import { type RedisHealthIndicator } from '../infrastructure/redis.health'; + +describe('HealthController', () => { + let controller: HealthController; + let mockHealthCheckService: { check: ReturnType }; + let mockPrismaHealth: PrismaHealthIndicator; + let mockRedisHealth: RedisHealthIndicator; + + beforeEach(() => { + mockHealthCheckService = { check: vi.fn() }; + mockPrismaHealth = { isHealthy: vi.fn() } as unknown as PrismaHealthIndicator; + mockRedisHealth = { isHealthy: vi.fn() } as unknown as RedisHealthIndicator; + + controller = new HealthController( + mockHealthCheckService as unknown as HealthCheckService, + mockPrismaHealth, + mockRedisHealth, + ); + }); + + describe('liveness', () => { + it('calls health.check with an empty array', () => { + const expected = { status: 'ok', details: {} }; + mockHealthCheckService.check.mockReturnValue(expected); + + const result = controller.liveness(); + + expect(mockHealthCheckService.check).toHaveBeenCalledOnce(); + expect(mockHealthCheckService.check).toHaveBeenCalledWith([]); + expect(result).toBe(expected); + }); + }); + + describe('readiness', () => { + it('calls health.check with prisma and redis health indicators', async () => { + const expected = { + status: 'ok', + details: { database: { status: 'up' }, redis: { status: 'up' } }, + }; + mockHealthCheckService.check.mockImplementation( + async (indicators: Array<() => Promise>) => { + // Execute the indicator callbacks to verify they call the right methods + for (const indicator of indicators) { + await indicator(); + } + return expected; + }, + ); + (mockPrismaHealth.isHealthy as ReturnType).mockResolvedValue({ + database: { status: 'up' }, + }); + (mockRedisHealth.isHealthy as ReturnType).mockResolvedValue({ + redis: { status: 'up' }, + }); + + const result = await controller.readiness(); + + expect(result).toBe(expected); + expect(mockPrismaHealth.isHealthy).toHaveBeenCalledWith('database'); + expect(mockRedisHealth.isHealthy).toHaveBeenCalledWith('redis'); + }); + + it('passes two health indicator callbacks to health.check', () => { + mockHealthCheckService.check.mockReturnValue({ status: 'ok' }); + + controller.readiness(); + + const callbacks = mockHealthCheckService.check.mock.calls[0][0]; + expect(callbacks).toHaveLength(2); + expect(typeof callbacks[0]).toBe('function'); + expect(typeof callbacks[1]).toBe('function'); + }); + }); +}); diff --git a/apps/api/src/modules/health/infrastructure/__tests__/prisma.health.spec.ts b/apps/api/src/modules/health/infrastructure/__tests__/prisma.health.spec.ts new file mode 100644 index 0000000..3d65b0a --- /dev/null +++ b/apps/api/src/modules/health/infrastructure/__tests__/prisma.health.spec.ts @@ -0,0 +1,39 @@ +import { HealthCheckError } from '@nestjs/terminus'; +import { PrismaHealthIndicator } from '../prisma.health'; + +describe('PrismaHealthIndicator', () => { + let indicator: PrismaHealthIndicator; + let mockPrisma: { $queryRawUnsafe: ReturnType }; + + beforeEach(() => { + mockPrisma = { $queryRawUnsafe: vi.fn() }; + indicator = new PrismaHealthIndicator(mockPrisma as any); + }); + + it('returns healthy status when database responds to SELECT 1', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValue([{ '?column?': 1 }]); + + const result = await indicator.isHealthy('database'); + + expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith('SELECT 1'); + expect(result).toEqual({ database: { status: 'up' } }); + }); + + it('throws HealthCheckError when database query fails', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('Connection refused')); + + await expect(indicator.isHealthy('database')).rejects.toThrow(HealthCheckError); + }); + + it('includes the key name in the error status', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('timeout')); + + try { + await indicator.isHealthy('db'); + expect.unreachable('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HealthCheckError); + expect((error as HealthCheckError).causes).toEqual({ db: { status: 'down' } }); + } + }); +}); diff --git a/apps/api/src/modules/health/infrastructure/__tests__/redis.health.spec.ts b/apps/api/src/modules/health/infrastructure/__tests__/redis.health.spec.ts new file mode 100644 index 0000000..05517da --- /dev/null +++ b/apps/api/src/modules/health/infrastructure/__tests__/redis.health.spec.ts @@ -0,0 +1,60 @@ +import { HealthCheckError } from '@nestjs/terminus'; +import { RedisHealthIndicator } from '../redis.health'; + +describe('RedisHealthIndicator', () => { + let indicator: RedisHealthIndicator; + let mockClient: { ping: ReturnType }; + let mockRedisService: { getClient: ReturnType }; + + beforeEach(() => { + mockClient = { ping: vi.fn() }; + mockRedisService = { getClient: vi.fn().mockReturnValue(mockClient) }; + indicator = new RedisHealthIndicator(mockRedisService as any); + }); + + it('returns healthy status when Redis responds with PONG', async () => { + mockClient.ping.mockResolvedValue('PONG'); + + const result = await indicator.isHealthy('redis'); + + expect(mockRedisService.getClient).toHaveBeenCalledOnce(); + expect(mockClient.ping).toHaveBeenCalledOnce(); + expect(result).toEqual({ redis: { status: 'up' } }); + }); + + it('throws HealthCheckError when Redis does not respond with PONG', async () => { + mockClient.ping.mockResolvedValue('NOT_PONG'); + + await expect(indicator.isHealthy('redis')).rejects.toThrow(HealthCheckError); + }); + + it('throws HealthCheckError with ping-failed message for non-PONG response', async () => { + mockClient.ping.mockResolvedValue(''); + + try { + await indicator.isHealthy('redis'); + expect.unreachable('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HealthCheckError); + expect((error as HealthCheckError).message).toBe('Redis ping failed'); + } + }); + + it('throws HealthCheckError when client.ping throws', async () => { + mockClient.ping.mockRejectedValue(new Error('Connection refused')); + + await expect(indicator.isHealthy('redis')).rejects.toThrow(HealthCheckError); + }); + + it('includes the key name in error status when connection fails', async () => { + mockClient.ping.mockRejectedValue(new Error('ECONNREFUSED')); + + try { + await indicator.isHealthy('cache'); + expect.unreachable('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HealthCheckError); + expect((error as HealthCheckError).causes).toEqual({ cache: { status: 'down' } }); + } + }); +}); diff --git a/apps/api/src/modules/health/infrastructure/prisma.health.ts b/apps/api/src/modules/health/infrastructure/prisma.health.ts index 1ab24cc..470004d 100644 --- a/apps/api/src/modules/health/infrastructure/prisma.health.ts +++ b/apps/api/src/modules/health/infrastructure/prisma.health.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { PrismaService } from '@modules/shared'; @Injectable() export class PrismaHealthIndicator extends HealthIndicator { diff --git a/apps/api/src/modules/health/infrastructure/redis.health.ts b/apps/api/src/modules/health/infrastructure/redis.health.ts index 0b656db..c4a5e7c 100644 --- a/apps/api/src/modules/health/infrastructure/redis.health.ts +++ b/apps/api/src/modules/health/infrastructure/redis.health.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { RedisService } from '@modules/shared/infrastructure/redis.service'; +import { RedisService } from '@modules/shared'; @Injectable() export class RedisHealthIndicator extends HealthIndicator { diff --git a/apps/api/src/modules/mcp/mcp.module.ts b/apps/api/src/modules/mcp/mcp.module.ts index db0b830..d9b4d8c 100644 --- a/apps/api/src/modules/mcp/mcp.module.ts +++ b/apps/api/src/modules/mcp/mcp.module.ts @@ -1,9 +1,8 @@ import { McpModule as McpCoreModule, type McpRegistryService } from '@goodgo/mcp-servers'; import { Module, type OnModuleInit } from '@nestjs/common'; import { AuthModule } from '@modules/auth'; -import { SearchModule } from '@modules/search'; -import { type TypesenseClientService } from '@modules/search/infrastructure/services/typesense-client.service'; -import { type LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { SearchModule, type TypesenseClientService } from '@modules/search'; +import { type LoggerService } from '@modules/shared'; import { McpTransportController } from './presentation/mcp-transport.controller'; @Module({ diff --git a/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts b/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts new file mode 100644 index 0000000..e6ca2ec --- /dev/null +++ b/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts @@ -0,0 +1,120 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { McpTransportController } from '../mcp-transport.controller'; + +// Mock SSEServerTransport as a class +vi.mock('@goodgo/mcp-servers', () => { + return { + SSEServerTransport: class MockSSEServerTransport { + sessionId = 'mock-session-id'; + handlePostMessage = vi.fn().mockResolvedValue(undefined); + constructor(public path: string, public res: unknown) {} + }, + }; +}); + +describe('McpTransportController', () => { + let controller: McpTransportController; + let mockRegistry: { + getServerNames: ReturnType; + getServer: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockRegistry = { + getServerNames: vi.fn(), + getServer: vi.fn(), + }; + controller = new McpTransportController(mockRegistry as any); + }); + + describe('listServers', () => { + it('returns list of server names from registry', () => { + mockRegistry.getServerNames.mockReturnValue(['search', 'listings']); + + const result = controller.listServers(); + + expect(result).toEqual({ servers: ['search', 'listings'] }); + expect(mockRegistry.getServerNames).toHaveBeenCalledOnce(); + }); + + it('returns empty array when no servers registered', () => { + mockRegistry.getServerNames.mockReturnValue([]); + + const result = controller.listServers(); + + expect(result).toEqual({ servers: [] }); + }); + }); + + describe('handleSse', () => { + const mockUser = { sub: 'user-1', email: 'test@example.com' }; + let mockReq: { on: ReturnType }; + let mockRes: Record; + + beforeEach(() => { + mockReq = { on: vi.fn() }; + mockRes = {}; + }); + + it('throws NOT_FOUND when server does not exist', async () => { + mockRegistry.getServer.mockReturnValue(null); + + await expect( + controller.handleSse('nonexistent', mockUser as any, mockReq as any, mockRes as any), + ).rejects.toThrow(HttpException); + + try { + await controller.handleSse('nonexistent', mockUser as any, mockReq as any, mockRes as any); + } catch (error) { + expect((error as HttpException).getStatus()).toBe(HttpStatus.NOT_FOUND); + expect((error as HttpException).message).toContain('nonexistent'); + } + }); + + it('creates transport and connects to server', async () => { + const mockServer = { connect: vi.fn().mockResolvedValue(undefined) }; + mockRegistry.getServer.mockReturnValue(mockServer); + + await controller.handleSse('search', mockUser as any, mockReq as any, mockRes as any); + + expect(mockRegistry.getServer).toHaveBeenCalledWith('search'); + expect(mockServer.connect).toHaveBeenCalledOnce(); + expect(mockReq.on).toHaveBeenCalledWith('close', expect.any(Function)); + }); + }); + + describe('handleMessage', () => { + const mockUser = { sub: 'user-1', email: 'test@example.com' }; + + it('throws BAD_REQUEST when sessionId query parameter is missing', async () => { + const mockReq = { query: {} } as any; + const mockRes = {} as any; + + await expect( + controller.handleMessage('search', mockUser as any, mockReq, mockRes), + ).rejects.toThrow(HttpException); + + try { + await controller.handleMessage('search', mockUser as any, mockReq, mockRes); + } catch (error) { + expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); + } + }); + + it('throws NOT_FOUND when session does not exist', async () => { + const mockReq = { query: { sessionId: 'nonexistent-session' } } as any; + const mockRes = {} as any; + + await expect( + controller.handleMessage('search', mockUser as any, mockReq, mockRes), + ).rejects.toThrow(HttpException); + + try { + await controller.handleMessage('search', mockUser as any, mockReq, mockRes); + } catch (error) { + expect((error as HttpException).getStatus()).toBe(HttpStatus.NOT_FOUND); + } + }); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/field-encryption.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/field-encryption.spec.ts new file mode 100644 index 0000000..6e30932 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/field-encryption.spec.ts @@ -0,0 +1,127 @@ +import crypto from 'node:crypto'; +import { describe, it, expect } from 'vitest'; +import { + encryptField, + decryptField, + isEncrypted, + type FieldEncryptionConfig, +} from '../field-encryption'; + +const TEST_KEY = crypto.randomBytes(32).toString('hex'); // 64 hex chars +const config: FieldEncryptionConfig = { key: TEST_KEY, keyVersion: 1 }; + +describe('field-encryption', () => { + describe('encryptField / decryptField round-trip', () => { + it('encrypts and decrypts a simple object', () => { + const original = { name: 'Nguyen Van A', idNumber: '012345678901' }; + const encrypted = encryptField(original, config); + const decrypted = decryptField(encrypted, config); + + expect(decrypted).toEqual(original); + }); + + it('encrypts and decrypts a string value', () => { + const original = 'sensitive-pii-data'; + const encrypted = encryptField(original, config); + const decrypted = decryptField(encrypted, config); + + expect(decrypted).toBe(original); + }); + + it('encrypts and decrypts nested objects', () => { + const original = { + identity: { type: 'CCCD', number: '012345678901' }, + address: { city: 'Ho Chi Minh', district: '1' }, + documents: [{ url: 'https://example.com/front.jpg' }], + }; + const encrypted = encryptField(original, config); + const decrypted = decryptField(encrypted, config); + + expect(decrypted).toEqual(original); + }); + + it('encrypts and decrypts null-containing objects', () => { + const original = { name: 'Test', optional: null }; + const encrypted = encryptField(original, config); + const decrypted = decryptField(encrypted, config); + + expect(decrypted).toEqual(original); + }); + + it('produces different ciphertext for same input (random IV)', () => { + const original = { name: 'Test' }; + const enc1 = encryptField(original, config); + const enc2 = encryptField(original, config); + + expect(enc1).not.toBe(enc2); + expect(decryptField(enc1, config)).toEqual(original); + expect(decryptField(enc2, config)).toEqual(original); + }); + }); + + describe('encrypted format', () => { + it('starts with enc: prefix', () => { + const encrypted = encryptField({ test: true }, config); + expect(encrypted).toMatch(/^enc:v\d+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it('includes key version', () => { + const v2Config = { ...config, keyVersion: 2 }; + const encrypted = encryptField('data', v2Config); + expect(encrypted).toMatch(/^enc:v2:/); + }); + }); + + describe('isEncrypted', () => { + it('returns true for encrypted values', () => { + const encrypted = encryptField('test', config); + expect(isEncrypted(encrypted)).toBe(true); + }); + + it('returns false for plaintext strings', () => { + expect(isEncrypted('plain text')).toBe(false); + expect(isEncrypted('{"name":"test"}')).toBe(false); + }); + + it('returns false for non-string values', () => { + expect(isEncrypted(null)).toBe(false); + expect(isEncrypted(undefined)).toBe(false); + expect(isEncrypted(42)).toBe(false); + expect(isEncrypted({ name: 'test' })).toBe(false); + }); + }); + + describe('decryptField — plaintext passthrough', () => { + it('returns non-string values as-is', () => { + const obj = { name: 'test' }; + expect(decryptField(obj, config)).toBe(obj); + expect(decryptField(null, config)).toBe(null); + expect(decryptField(42, config)).toBe(42); + }); + + it('returns non-encrypted strings as-is', () => { + expect(decryptField('plain text', config)).toBe('plain text'); + }); + }); + + describe('error handling', () => { + it('rejects invalid key length', () => { + const badConfig = { key: 'tooshort', keyVersion: 1 }; + expect(() => encryptField('test', badConfig)).toThrow('32 bytes'); + }); + + it('rejects tampered ciphertext', () => { + const encrypted = encryptField('secret', config); + // Flip a character in the ciphertext portion + const tampered = encrypted.slice(0, -2) + 'ff'; + expect(() => decryptField(tampered, config)).toThrow(); + }); + + it('rejects decryption with wrong key', () => { + const encrypted = encryptField('secret', config); + const wrongKey = crypto.randomBytes(32).toString('hex'); + const wrongConfig = { key: wrongKey, keyVersion: 1 }; + expect(() => decryptField(encrypted, wrongConfig)).toThrow(); + }); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/field-encryption.ts b/apps/api/src/modules/shared/infrastructure/field-encryption.ts new file mode 100644 index 0000000..64e7f0d --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/field-encryption.ts @@ -0,0 +1,86 @@ +/** + * AES-256-GCM field-level encryption for sensitive database fields. + * + * Encrypted values are stored as: `enc:v{version}:{iv}:{authTag}:{ciphertext}` + * All segments are hex-encoded. The `enc:` prefix lets us distinguish encrypted + * from plaintext values (important for migration). + */ + +import crypto from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit IV recommended for GCM +const AUTH_TAG_LENGTH = 16; +const PREFIX = 'enc:'; + +export interface FieldEncryptionConfig { + /** 32-byte hex-encoded encryption key (64 hex chars). */ + key: string; + /** Key version for rotation support. Defaults to 1. */ + keyVersion?: number; +} + +function deriveKeyBuffer(hexKey: string): Buffer { + const buf = Buffer.from(hexKey, 'hex'); + if (buf.length !== 32) { + throw new Error( + `KYC_ENCRYPTION_KEY must be exactly 32 bytes (64 hex chars), got ${buf.length} bytes`, + ); + } + return buf; +} + +/** + * Encrypts a JSON-serializable value using AES-256-GCM. + * Returns a prefixed string suitable for database storage. + */ +export function encryptField(value: unknown, config: FieldEncryptionConfig): string { + const keyBuf = deriveKeyBuffer(config.key); + const version = config.keyVersion ?? 1; + const plaintext = JSON.stringify(value); + + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, keyBuf, iv, { authTagLength: AUTH_TAG_LENGTH }); + + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return `${PREFIX}v${version}:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; +} + +/** + * Decrypts a previously encrypted field value. + * Returns the original JSON-parsed value, or the raw input if it is not encrypted + * (supports transparent migration from plaintext). + */ +export function decryptField(stored: unknown, config: FieldEncryptionConfig): unknown { + if (typeof stored !== 'string' || !stored.startsWith(PREFIX)) { + // Not encrypted — return as-is (plaintext migration path) + return stored; + } + + const keyBuf = deriveKeyBuffer(config.key); + // Format: enc:v{version}:{iv}:{authTag}:{ciphertext} + const parts = stored.slice(PREFIX.length).split(':'); + if (parts.length !== 4) { + throw new Error('Malformed encrypted field: expected 4 segments after prefix'); + } + + const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts; + const iv = Buffer.from(ivHex!, 'hex'); + const authTag = Buffer.from(authTagHex!, 'hex'); + const ciphertext = Buffer.from(ciphertextHex!, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuf, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return JSON.parse(decrypted.toString('utf8')); +} + +/** Check if a stored value is already encrypted. */ +export function isEncrypted(value: unknown): boolean { + return typeof value === 'string' && value.startsWith(PREFIX); +} diff --git a/scripts/encrypt-existing-kyc.ts b/scripts/encrypt-existing-kyc.ts new file mode 100644 index 0000000..8735629 --- /dev/null +++ b/scripts/encrypt-existing-kyc.ts @@ -0,0 +1,79 @@ +/** + * One-time migration: encrypt existing plaintext kycData records. + * + * Usage: + * KYC_ENCRYPTION_KEY= npx tsx scripts/encrypt-existing-kyc.ts [--dry-run] + * + * This script: + * 1. Reads all User rows where kycData is not null + * 2. Skips rows that are already encrypted (have the `enc:` prefix) + * 3. Encrypts plaintext kycData using AES-256-GCM + * 4. Updates each row in a transaction + */ + +import { PrismaClient } from '@prisma/client'; +import { + encryptField, + isEncrypted, + type FieldEncryptionConfig, +} from '../apps/api/src/modules/shared/infrastructure/field-encryption'; + +async function main() { + const key = process.env['KYC_ENCRYPTION_KEY']; + if (!key) { + console.error('ERROR: KYC_ENCRYPTION_KEY env var is required.'); + process.exit(1); + } + + const dryRun = process.argv.includes('--dry-run'); + const config: FieldEncryptionConfig = { + key, + keyVersion: parseInt(process.env['KYC_ENCRYPTION_KEY_VERSION'] ?? '1', 10), + }; + + // Use raw PrismaClient without encryption middleware to read plaintext + const prisma = new PrismaClient(); + await prisma.$connect(); + + try { + const users = await prisma.user.findMany({ + where: { kycData: { not: null } }, + select: { id: true, kycData: true }, + }); + + console.log(`Found ${users.length} users with kycData.`); + + let encrypted = 0; + let skipped = 0; + + for (const user of users) { + if (isEncrypted(user.kycData)) { + skipped++; + continue; + } + + const encryptedValue = encryptField(user.kycData, config); + + if (dryRun) { + console.log(`[DRY RUN] Would encrypt kycData for user ${user.id}`); + } else { + await prisma.user.update({ + where: { id: user.id }, + data: { kycData: encryptedValue }, + }); + } + encrypted++; + } + + console.log( + `${dryRun ? '[DRY RUN] ' : ''}Done. Encrypted: ${encrypted}, Already encrypted: ${skipped}`, + ); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +});