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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user