feat(api): add field encryption, health check specs, and KYC encryption script

- Add field-level encryption service for PII data with AES-256-GCM
- Add health check specs for Prisma and Redis indicators
- Add MCP controller specs
- Add encrypt-existing-kyc migration script for existing KYC data

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:44:00 +07:00
parent e927385ed5
commit 2250e17a09
10 changed files with 592 additions and 5 deletions

View File

@@ -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<typeof vi.fn> };
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<unknown>>) => {
// Execute the indicator callbacks to verify they call the right methods
for (const indicator of indicators) {
await indicator();
}
return expected;
},
);
(mockPrismaHealth.isHealthy as ReturnType<typeof vi.fn>).mockResolvedValue({
database: { status: 'up' },
});
(mockRedisHealth.isHealthy as ReturnType<typeof vi.fn>).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');
});
});
});

View File

@@ -0,0 +1,39 @@
import { HealthCheckError } from '@nestjs/terminus';
import { PrismaHealthIndicator } from '../prisma.health';
describe('PrismaHealthIndicator', () => {
let indicator: PrismaHealthIndicator;
let mockPrisma: { $queryRawUnsafe: ReturnType<typeof vi.fn> };
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' } });
}
});
});

View File

@@ -0,0 +1,60 @@
import { HealthCheckError } from '@nestjs/terminus';
import { RedisHealthIndicator } from '../redis.health';
describe('RedisHealthIndicator', () => {
let indicator: RedisHealthIndicator;
let mockClient: { ping: ReturnType<typeof vi.fn> };
let mockRedisService: { getClient: ReturnType<typeof vi.fn> };
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' } });
}
});
});

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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<typeof vi.fn>;
getServer: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn> };
let mockRes: Record<string, unknown>;
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);
}
});
});
});

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
/**
* One-time migration: encrypt existing plaintext kycData records.
*
* Usage:
* KYC_ENCRYPTION_KEY=<hex-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);
});