/** * MPC Service End-to-End Tests * * These tests simulate the full flow of MPC operations from API to database, * using mocked external services (MPC Coordinator, Message Router, TSS Library). */ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; import { PrismaService } from '../../src/infrastructure/persistence/prisma/prisma.service'; import { JwtService } from '@nestjs/jwt'; import { MPCCoordinatorClient } from '../../src/infrastructure/external/mpc-system/coordinator-client'; import { MPCMessageRouterClient } from '../../src/infrastructure/external/mpc-system/message-router-client'; import { TSS_PROTOCOL_SERVICE } from '../../src/domain/services/tss-protocol.domain-service'; import { EventPublisherService } from '../../src/infrastructure/messaging/kafka/event-publisher.service'; import { SessionCacheService } from '../../src/infrastructure/redis/cache/session-cache.service'; import { DistributedLockService } from '../../src/infrastructure/redis/lock/distributed-lock.service'; import { PartyShareType } from '../../src/domain/enums'; describe('MPC Service E2E Tests', () => { let app: INestApplication; let prismaService: any; let jwtService: JwtService; let authToken: string; // Mock services let mockCoordinatorClient: any; let mockMessageRouterClient: any; let mockTssProtocolService: any; let mockEventPublisher: any; let mockSessionCache: any; let mockDistributedLock: any; beforeAll(async () => { // Setup mock implementations mockCoordinatorClient = { joinSession: jest.fn(), reportCompletion: jest.fn(), reportRoundComplete: jest.fn(), reportSessionComplete: jest.fn(), reportSessionFailed: jest.fn(), }; mockMessageRouterClient = { connect: jest.fn(), disconnect: jest.fn(), sendMessage: jest.fn(), subscribeMessages: jest.fn().mockResolvedValue({ next: jest.fn().mockResolvedValue({ done: true, value: undefined }), }), onMessage: jest.fn(), waitForMessages: jest.fn(), }; mockTssProtocolService = { runKeygen: jest.fn(), runSigning: jest.fn(), runRefresh: jest.fn(), initializeKeygen: jest.fn(), processKeygenRound: jest.fn(), finalizeKeygen: jest.fn(), initializeSigning: jest.fn(), processSigningRound: jest.fn(), finalizeSigning: jest.fn(), initializeRefresh: jest.fn(), processRefreshRound: jest.fn(), finalizeRefresh: jest.fn(), }; mockEventPublisher = { publish: jest.fn(), publishAll: jest.fn(), publishBatch: jest.fn(), onModuleInit: jest.fn(), onModuleDestroy: jest.fn(), }; mockSessionCache = { setSessionState: jest.fn(), getSessionState: jest.fn(), deleteSessionState: jest.fn(), setTssLocalState: jest.fn(), getTssLocalState: jest.fn(), }; mockDistributedLock = { acquireLock: jest.fn().mockResolvedValue(true), releaseLock: jest.fn().mockResolvedValue(true), withLock: jest.fn().mockImplementation(async (_key: string, fn: () => Promise) => fn()), }; // Setup mock Prisma prismaService = { partyShare: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), count: jest.fn(), }, sessionState: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), }, $connect: jest.fn(), $disconnect: jest.fn(), $transaction: jest.fn((fn) => fn(prismaService)), cleanDatabase: jest.fn(), }; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(PrismaService) .useValue(prismaService) .overrideProvider(MPCCoordinatorClient) .useValue(mockCoordinatorClient) .overrideProvider(MPCMessageRouterClient) .useValue(mockMessageRouterClient) .overrideProvider(TSS_PROTOCOL_SERVICE) .useValue(mockTssProtocolService) .overrideProvider(EventPublisherService) .useValue(mockEventPublisher) .overrideProvider(SessionCacheService) .useValue(mockSessionCache) .overrideProvider(DistributedLockService) .useValue(mockDistributedLock) .compile(); app = moduleFixture.createNestApplication(); // Set global prefix to match production setup app.setGlobalPrefix('api/v1'); app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, }), ); await app.init(); jwtService = moduleFixture.get(JwtService); // Generate test auth token with correct payload structure authToken = jwtService.sign({ sub: 'test-user-id', type: 'access', partyId: 'user123-server', }); }); afterAll(async () => { await app.close(); }); beforeEach(() => { jest.clearAllMocks(); }); describe('Health Check', () => { it('GET /api/v1/mpc-party/health - should return healthy status', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/mpc-party/health') .expect(200); // The response might be wrapped in { success, data } or might be raw based on interceptor const body = response.body.data || response.body; expect(body.status).toBe('ok'); expect(body.service).toBe('mpc-party-service'); expect(body.timestamp).toBeDefined(); }); }); describe('Keygen Flow', () => { const keygenSessionId = '550e8400-e29b-41d4-a716-446655440000'; it('POST /api/v1/mpc-party/keygen/participate - should accept keygen participation', async () => { // Async endpoint returns 202 immediately const response = await request(app.getHttpServer()) .post('/api/v1/mpc-party/keygen/participate') .set('Authorization', `Bearer ${authToken}`) .send({ sessionId: keygenSessionId, partyId: 'user123-server', joinToken: 'join-token-abc', shareType: PartyShareType.WALLET, userId: 'test-user-id', }) .expect(202); // Response might be wrapped in { success, data } const body = response.body.data || response.body; expect(body.message).toBe('Keygen participation started'); expect(body.sessionId).toBe(keygenSessionId); expect(body.partyId).toBe('user123-server'); }); it('POST /api/v1/mpc-party/keygen/participate - should validate required fields', async () => { await request(app.getHttpServer()) .post('/api/v1/mpc-party/keygen/participate') .set('Authorization', `Bearer ${authToken}`) .send({ // Missing required fields sessionId: keygenSessionId, }) .expect(400); }); }); describe('Signing Flow', () => { const signingSessionId = '660e8400-e29b-41d4-a716-446655440001'; it('POST /api/v1/mpc-party/signing/participate - should accept signing participation', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/mpc-party/signing/participate') .set('Authorization', `Bearer ${authToken}`) .send({ sessionId: signingSessionId, partyId: 'user123-server', joinToken: 'join-token-abc', messageHash: 'a'.repeat(64), publicKey: '03' + '0'.repeat(64), }) .expect(202); // Response might be wrapped in { success, data } const body = response.body.data || response.body; expect(body.message).toBe('Signing participation started'); expect(body.sessionId).toBe(signingSessionId); expect(body.partyId).toBe('user123-server'); }); it('POST /api/v1/mpc-party/signing/participate - should validate message hash format', async () => { await request(app.getHttpServer()) .post('/api/v1/mpc-party/signing/participate') .set('Authorization', `Bearer ${authToken}`) .send({ sessionId: signingSessionId, partyId: 'user123-server', joinToken: 'join-token-abc', messageHash: 'invalid-not-64-hex-chars', publicKey: '03' + '0'.repeat(64), }) .expect(400); }); }); describe('Share Rotation Flow', () => { const rotateSessionId = '770e8400-e29b-41d4-a716-446655440002'; it('POST /api/v1/mpc-party/share/rotate - should accept rotation request', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/mpc-party/share/rotate') .set('Authorization', `Bearer ${authToken}`) .send({ sessionId: rotateSessionId, partyId: 'user123-server', joinToken: 'join-token-abc', publicKey: '03' + '0'.repeat(64), }) .expect(202); // Response can be wrapped or raw depending on interceptor const body = response.body.data || response.body; expect(body.message).toBe('Share rotation started'); expect(body.sessionId).toBe(rotateSessionId); expect(body.partyId).toBe('user123-server'); }); }); describe('Share Management', () => { const testShareId = 'share_1699887766123_abc123xyz'; beforeEach(() => { const mockShareRecord = { id: testShareId, partyId: 'user123-server', sessionId: '550e8400-e29b-41d4-a716-446655440000', shareType: 'wallet', shareData: JSON.stringify({ data: Buffer.from('encrypted-share-data').toString('base64'), iv: Buffer.from('123456789012').toString('base64'), authTag: Buffer.from('1234567890123456').toString('base64'), }), publicKey: '03' + '0'.repeat(64), thresholdT: 2, thresholdN: 3, status: 'active', createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), lastUsedAt: null, }; prismaService.partyShare.findUnique.mockResolvedValue(mockShareRecord); prismaService.partyShare.findMany.mockResolvedValue([mockShareRecord]); prismaService.partyShare.count.mockResolvedValue(1); }); it('GET /api/v1/mpc-party/shares - should list shares', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/mpc-party/shares') .set('Authorization', `Bearer ${authToken}`) .query({ page: 1, limit: 10 }) .expect(200); // Response is wrapped in { success, data } const body = response.body.data || response.body; expect(body).toHaveProperty('items'); expect(body).toHaveProperty('total'); expect(body.items).toBeInstanceOf(Array); }); it('GET /api/v1/mpc-party/shares/:shareId - should get share info', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/mpc-party/shares/${testShareId}`) .set('Authorization', `Bearer ${authToken}`) .expect(200); // Response is wrapped in { success, data } const body = response.body.data || response.body; expect(body).toHaveProperty('id'); expect(body).toHaveProperty('status'); }); it('GET /api/v1/mpc-party/shares/:shareId - should return error for invalid share id format', async () => { prismaService.partyShare.findUnique.mockResolvedValue(null); // Invalid shareId format will return 500 (validation fails in domain layer) await request(app.getHttpServer()) .get('/api/v1/mpc-party/shares/non-existent-share') .set('Authorization', `Bearer ${authToken}`) .expect(500); }); }); describe('Authentication', () => { it('should reject requests without token', async () => { await request(app.getHttpServer()) .get('/api/v1/mpc-party/shares') .expect(401); }); it('should reject requests with invalid token', async () => { await request(app.getHttpServer()) .get('/api/v1/mpc-party/shares') .set('Authorization', 'Bearer invalid-token') .expect(401); }); it('should reject requests with expired token', async () => { const expiredToken = jwtService.sign( { sub: 'test-user', type: 'access' }, { expiresIn: '-1h' }, ); await request(app.getHttpServer()) .get('/api/v1/mpc-party/shares') .set('Authorization', `Bearer ${expiredToken}`) .expect(401); }); it('should allow public endpoints without token', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/mpc-party/health') .expect(200); // Response might be wrapped in { success, data } const body = response.body.data || response.body; expect(body.status).toBe('ok'); }); }); describe('Error Handling', () => { it('should return structured error for validation failures', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/mpc-party/keygen/participate') .set('Authorization', `Bearer ${authToken}`) .send({ // Missing required fields }) .expect(400); expect(response.body).toHaveProperty('message'); }); it('should handle internal server errors gracefully', async () => { prismaService.partyShare.findMany.mockRejectedValue( new Error('Database connection lost'), ); const response = await request(app.getHttpServer()) .get('/api/v1/mpc-party/shares') .set('Authorization', `Bearer ${authToken}`) .expect(500); expect(response.body).toHaveProperty('message'); }); }); });