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 {