409 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|