rwadurian/backend/services/mpc-service/tests/e2e/mpc-service.e2e-spec.ts

409 lines
14 KiB
TypeScript

/**
* 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<any>) => 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>(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');
});
});
});