434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import request from 'supertest';
|
|
import { ConfigModule } from '@nestjs/config';
|
|
import { ApiModule } from '../../src/api/api.module';
|
|
import { ApplicationModule } from '../../src/application/application.module';
|
|
import { InfrastructureModule } from '../../src/infrastructure/infrastructure.module';
|
|
import { DomainModule } from '../../src/domain/domain.module';
|
|
import { PrismaService } from '../../src/infrastructure/persistence/prisma/prisma.service';
|
|
import { GlobalExceptionFilter } from '../../src/shared/filters/global-exception.filter';
|
|
import { MockPrismaService } from '../utils/mock-prisma.service';
|
|
import {
|
|
generateServiceToken,
|
|
generatePublicKey,
|
|
createStoreSharePayload,
|
|
createRetrieveSharePayload,
|
|
createRevokeSharePayload,
|
|
setupTestEnv,
|
|
TEST_SERVICE_JWT_SECRET,
|
|
} from '../utils/test-utils';
|
|
|
|
describe('BackupShare E2E (Mock Database)', () => {
|
|
let app: INestApplication;
|
|
let mockPrisma: MockPrismaService;
|
|
let serviceToken: string;
|
|
|
|
beforeAll(async () => {
|
|
setupTestEnv();
|
|
|
|
mockPrisma = new MockPrismaService();
|
|
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
isGlobal: true,
|
|
load: [
|
|
() => ({
|
|
app: { port: 3002, env: 'test' },
|
|
security: {
|
|
serviceJwtSecret: TEST_SERVICE_JWT_SECRET,
|
|
allowedServices: ['identity-service', 'recovery-service'],
|
|
},
|
|
encryption: {
|
|
key: process.env.BACKUP_ENCRYPTION_KEY,
|
|
keyId: process.env.BACKUP_ENCRYPTION_KEY_ID,
|
|
},
|
|
rateLimit: { maxRetrievePerDay: 3 },
|
|
}),
|
|
],
|
|
}),
|
|
DomainModule,
|
|
InfrastructureModule,
|
|
ApplicationModule,
|
|
ApiModule,
|
|
],
|
|
})
|
|
.overrideProvider(PrismaService)
|
|
.useValue(mockPrisma)
|
|
.compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
|
|
|
await app.init();
|
|
|
|
serviceToken = generateServiceToken('identity-service');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockPrisma.reset();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
});
|
|
|
|
describe('Complete Workflow Tests', () => {
|
|
it('should complete full lifecycle: store -> retrieve -> revoke', async () => {
|
|
const publicKey = generatePublicKey('x');
|
|
const storePayload = createStoreSharePayload({
|
|
userId: '11111',
|
|
accountSequence: 1111,
|
|
publicKey,
|
|
});
|
|
|
|
// Step 1: Store
|
|
const storeResponse = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(storePayload)
|
|
.expect(201);
|
|
|
|
expect(storeResponse.body.success).toBe(true);
|
|
expect(storeResponse.body.shareId).toBeDefined();
|
|
|
|
// Step 2: Retrieve
|
|
const retrieveResponse = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '11111', publicKey }))
|
|
.expect(200);
|
|
|
|
expect(retrieveResponse.body.success).toBe(true);
|
|
expect(retrieveResponse.body.partyIndex).toBe(2);
|
|
|
|
// Step 3: Revoke
|
|
const revokeResponse = await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({ userId: '11111', publicKey, reason: 'ROTATION' }))
|
|
.expect(200);
|
|
|
|
expect(revokeResponse.body.success).toBe(true);
|
|
|
|
// Step 4: Verify cannot retrieve after revoke
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '11111', publicKey }))
|
|
.expect(400);
|
|
});
|
|
|
|
it('should allow recovery-service to access shares', async () => {
|
|
const recoveryToken = generateServiceToken('recovery-service');
|
|
const publicKey = generatePublicKey('r');
|
|
|
|
// Store with identity-service
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ userId: '22222', accountSequence: 2222, publicKey }))
|
|
.expect(201);
|
|
|
|
// Retrieve with recovery-service
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', recoveryToken)
|
|
.send(createRetrieveSharePayload({ userId: '22222', publicKey }))
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Validation Tests', () => {
|
|
it('should reject store with missing userId', async () => {
|
|
const payload = { ...createStoreSharePayload(), userId: undefined };
|
|
delete (payload as any).userId;
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(400);
|
|
});
|
|
|
|
it('should reject store with missing accountSequence', async () => {
|
|
const payload = { ...createStoreSharePayload(), accountSequence: undefined };
|
|
delete (payload as any).accountSequence;
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(400);
|
|
});
|
|
|
|
it('should reject store with invalid public key length', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ publicKey: 'too-short' }))
|
|
.expect(400);
|
|
});
|
|
|
|
it('should reject retrieve with missing recoveryToken', async () => {
|
|
const payload = { ...createRetrieveSharePayload(), recoveryToken: undefined };
|
|
delete (payload as any).recoveryToken;
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(400);
|
|
});
|
|
|
|
it('should reject revoke with invalid reason', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({ reason: 'INVALID_REASON' }))
|
|
.expect(400);
|
|
});
|
|
|
|
it('should accept all valid revoke reasons', async () => {
|
|
const validReasons = ['ROTATION', 'ACCOUNT_CLOSED', 'SECURITY_BREACH', 'USER_REQUEST'];
|
|
|
|
for (let i = 0; i < validReasons.length; i++) {
|
|
const publicKey = generatePublicKey(String(i));
|
|
const userId = String(30000 + i);
|
|
|
|
// Store first
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ userId, accountSequence: 30000 + i, publicKey }))
|
|
.expect(201);
|
|
|
|
// Then revoke with valid reason
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({ userId, publicKey, reason: validReasons[i] }))
|
|
.expect(200);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Authentication Tests', () => {
|
|
it('should reject requests without token', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.send(createRetrieveSharePayload())
|
|
.expect(401);
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.send(createRevokeSharePayload())
|
|
.expect(401);
|
|
});
|
|
|
|
it('should reject requests with malformed token', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', 'not.a.valid.jwt')
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
});
|
|
|
|
it('should reject requests from unknown services', async () => {
|
|
const unknownToken = generateServiceToken('malicious-service');
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', unknownToken)
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
});
|
|
|
|
it('should reject tokens signed with wrong secret', async () => {
|
|
const badToken = generateServiceToken('identity-service', 'wrong-secret');
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', badToken)
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling Tests', () => {
|
|
it('should return SHARE_NOT_FOUND for non-existent share', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '99999999', publicKey: generatePublicKey('z') }))
|
|
.expect(404);
|
|
|
|
expect(response.body.code).toBe('SHARE_NOT_FOUND');
|
|
});
|
|
|
|
it('should return SHARE_ALREADY_EXISTS for duplicate store', async () => {
|
|
const publicKey = generatePublicKey('d');
|
|
const payload = createStoreSharePayload({ userId: '44444', accountSequence: 4444, publicKey });
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(201);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(409);
|
|
|
|
expect(response.body.code).toBe('SHARE_ALREADY_EXISTS');
|
|
});
|
|
|
|
it('should return SHARE_NOT_ACTIVE for revoked share retrieval', async () => {
|
|
const publicKey = generatePublicKey('e');
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ userId: '55555', accountSequence: 5555, publicKey }))
|
|
.expect(201);
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({ userId: '55555', publicKey }))
|
|
.expect(200);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '55555', publicKey }))
|
|
.expect(400);
|
|
|
|
expect(response.body.code).toBe('SHARE_NOT_ACTIVE');
|
|
});
|
|
});
|
|
|
|
describe('Optional Parameters Tests', () => {
|
|
it('should accept custom threshold and totalParties', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({
|
|
userId: '66666',
|
|
accountSequence: 6666,
|
|
publicKey: generatePublicKey('f'),
|
|
threshold: 3,
|
|
totalParties: 5,
|
|
}))
|
|
.expect(201);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
});
|
|
|
|
it('should accept deviceId in retrieve', async () => {
|
|
const publicKey = generatePublicKey('g');
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ userId: '77777', accountSequence: 7777, publicKey }))
|
|
.expect(201);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '77777', publicKey, deviceId: 'device-abc-123' }))
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Response Structure Tests', () => {
|
|
it('should return correct store response structure', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({
|
|
userId: '80001',
|
|
accountSequence: 80001,
|
|
publicKey: generatePublicKey('s'),
|
|
}))
|
|
.expect(201);
|
|
|
|
expect(response.body).toHaveProperty('success', true);
|
|
expect(response.body).toHaveProperty('shareId');
|
|
expect(response.body).toHaveProperty('message', 'Backup share stored successfully');
|
|
});
|
|
|
|
it('should return correct retrieve response structure', async () => {
|
|
const publicKey = generatePublicKey('t');
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ userId: '80002', accountSequence: 80002, publicKey }))
|
|
.expect(201);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '80002', publicKey }))
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('success', true);
|
|
expect(response.body).toHaveProperty('encryptedShareData');
|
|
expect(response.body).toHaveProperty('partyIndex', 2);
|
|
expect(response.body).toHaveProperty('publicKey', publicKey);
|
|
});
|
|
|
|
it('should return correct revoke response structure', async () => {
|
|
const publicKey = generatePublicKey('u');
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({ userId: '80003', accountSequence: 80003, publicKey }))
|
|
.expect(201);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({ userId: '80003', publicKey }))
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('success', true);
|
|
expect(response.body).toHaveProperty('message', 'Backup share revoked successfully');
|
|
});
|
|
|
|
it('should return correct error response structure', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId: '99999', publicKey: generatePublicKey('v') }))
|
|
.expect(404);
|
|
|
|
expect(response.body).toHaveProperty('success', false);
|
|
expect(response.body).toHaveProperty('error');
|
|
expect(response.body).toHaveProperty('code');
|
|
expect(response.body).toHaveProperty('timestamp');
|
|
expect(response.body).toHaveProperty('path');
|
|
});
|
|
});
|
|
});
|