518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import request from 'supertest';
|
|
import { AppModule } from '../../src/app.module';
|
|
import { PrismaService } from '../../src/infrastructure/persistence/prisma/prisma.service';
|
|
import {
|
|
generateServiceToken,
|
|
generatePublicKey,
|
|
createStoreSharePayload,
|
|
createRetrieveSharePayload,
|
|
createRevokeSharePayload,
|
|
TEST_SERVICE_JWT_SECRET,
|
|
} from '../utils/test-utils';
|
|
|
|
describe('BackupShare E2E (Real Database)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaService;
|
|
let serviceToken: string;
|
|
|
|
beforeAll(async () => {
|
|
// Environment variables are loaded by jest-e2e-setup.ts
|
|
process.env.SERVICE_JWT_SECRET = TEST_SERVICE_JWT_SECRET;
|
|
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
await app.init();
|
|
|
|
prisma = app.get(PrismaService);
|
|
serviceToken = generateServiceToken('identity-service');
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean database before each test
|
|
if (prisma) {
|
|
await prisma.shareAccessLog.deleteMany();
|
|
await prisma.backupShare.deleteMany();
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
});
|
|
|
|
describe('Health Check', () => {
|
|
it('GET /health should return ok', () => {
|
|
return request(app.getHttpServer())
|
|
.get('/health')
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.status).toBe('ok');
|
|
expect(res.body.service).toBe('backup-service');
|
|
});
|
|
});
|
|
|
|
it('GET /health/live should return alive', () => {
|
|
return request(app.getHttpServer())
|
|
.get('/health/live')
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.status).toBe('alive');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /backup-share/store', () => {
|
|
it('should store backup share successfully', async () => {
|
|
const payload = createStoreSharePayload({
|
|
userId: '10001',
|
|
accountSequence: 10001,
|
|
publicKey: generatePublicKey('a'),
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(201);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.shareId).toBeDefined();
|
|
expect(response.body.message).toBe('Backup share stored successfully');
|
|
|
|
// Verify data persisted to database
|
|
const share = await prisma.backupShare.findUnique({
|
|
where: { userId: BigInt(payload.userId) },
|
|
});
|
|
expect(share).not.toBeNull();
|
|
expect(share?.publicKey).toBe(payload.publicKey);
|
|
expect(share?.status).toBe('ACTIVE');
|
|
});
|
|
|
|
it('should reject duplicate share for same user', async () => {
|
|
const payload = createStoreSharePayload({
|
|
userId: '10002',
|
|
accountSequence: 10002,
|
|
publicKey: generatePublicKey('b'),
|
|
});
|
|
|
|
// First store
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(201);
|
|
|
|
// Duplicate attempt
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(409);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.code).toBe('SHARE_ALREADY_EXISTS');
|
|
});
|
|
|
|
it('should reject request without service token', () => {
|
|
return request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
});
|
|
|
|
it('should reject request with invalid service token', () => {
|
|
return request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', 'invalid-token')
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
});
|
|
|
|
it('should reject request from unauthorized service', () => {
|
|
const unauthorizedToken = generateServiceToken('unknown-service');
|
|
|
|
return request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', unauthorizedToken)
|
|
.send(createStoreSharePayload())
|
|
.expect(401);
|
|
});
|
|
|
|
it('should validate required fields', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send({})
|
|
.expect(400);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
|
|
it('should validate public key length', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send({
|
|
...createStoreSharePayload(),
|
|
publicKey: 'short',
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /backup-share/retrieve', () => {
|
|
const storePayload = {
|
|
userId: '20001',
|
|
accountSequence: 20001,
|
|
publicKey: generatePublicKey('c'),
|
|
encryptedShareData: 'test-encrypted-data',
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
// Store a share first
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(storePayload);
|
|
});
|
|
|
|
it('should retrieve backup share successfully', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
}))
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.encryptedShareData).toBeDefined();
|
|
expect(response.body.partyIndex).toBe(2);
|
|
expect(response.body.publicKey).toBe(storePayload.publicKey);
|
|
|
|
// Verify access log created for RETRIEVE action
|
|
const accessLogs = await prisma.shareAccessLog.findMany({
|
|
where: {
|
|
userId: BigInt(storePayload.userId),
|
|
action: 'RETRIEVE',
|
|
},
|
|
});
|
|
expect(accessLogs.length).toBeGreaterThan(0);
|
|
expect(accessLogs[0].action).toBe('RETRIEVE');
|
|
});
|
|
|
|
it('should return 404 for non-existent share', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({
|
|
userId: '99999',
|
|
publicKey: generatePublicKey('z'),
|
|
}))
|
|
.expect(404);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.code).toBe('SHARE_NOT_FOUND');
|
|
});
|
|
|
|
it('should increment access count on retrieve', async () => {
|
|
// First retrieve
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
}))
|
|
.expect(200);
|
|
|
|
// Second retrieve
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
}))
|
|
.expect(200);
|
|
|
|
// Verify access count
|
|
const share = await prisma.backupShare.findUnique({
|
|
where: { userId: BigInt(storePayload.userId) },
|
|
});
|
|
expect(share?.accessCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('POST /backup-share/revoke', () => {
|
|
const storePayload = {
|
|
userId: '30001',
|
|
accountSequence: 30001,
|
|
publicKey: generatePublicKey('e'),
|
|
encryptedShareData: 'test-data',
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(storePayload);
|
|
});
|
|
|
|
it('should revoke backup share successfully', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
reason: 'ROTATION',
|
|
}))
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.message).toBe('Backup share revoked successfully');
|
|
|
|
// Verify share status in database
|
|
const share = await prisma.backupShare.findUnique({
|
|
where: { userId: BigInt(storePayload.userId) },
|
|
});
|
|
expect(share?.status).toBe('REVOKED');
|
|
});
|
|
|
|
it('should not allow retrieval of revoked share', async () => {
|
|
// Revoke first
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
reason: 'SECURITY_BREACH',
|
|
}))
|
|
.expect(200);
|
|
|
|
// Try to retrieve
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
}))
|
|
.expect(400);
|
|
|
|
expect(response.body.code).toBe('SHARE_NOT_ACTIVE');
|
|
});
|
|
|
|
it('should validate reason field', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send({
|
|
userId: storePayload.userId,
|
|
publicKey: storePayload.publicKey,
|
|
reason: 'INVALID_REASON',
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
|
|
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 payload = {
|
|
userId: String(40000 + i),
|
|
accountSequence: 40000 + i,
|
|
publicKey: generatePublicKey(String(i)),
|
|
encryptedShareData: 'test-data',
|
|
};
|
|
|
|
// Store share
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(201);
|
|
|
|
// Revoke with valid reason
|
|
const response = await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send({
|
|
userId: payload.userId,
|
|
publicKey: payload.publicKey,
|
|
reason: validReasons[i],
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Complete Workflow Tests', () => {
|
|
it('should complete full lifecycle: store -> retrieve -> revoke', async () => {
|
|
const publicKey = generatePublicKey('x');
|
|
const storePayload = createStoreSharePayload({
|
|
userId: '50001',
|
|
accountSequence: 50001,
|
|
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: '50001', 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: '50001', 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: '50001', 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: '50002', accountSequence: 50002, 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: '50002', publicKey }))
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Data Persistence Tests', () => {
|
|
it('should persist encrypted data correctly', async () => {
|
|
const encryptedData = 'base64-encoded-encrypted-share-data-12345';
|
|
const payload = createStoreSharePayload({
|
|
userId: '60001',
|
|
accountSequence: 60001,
|
|
publicKey: generatePublicKey('p'),
|
|
encryptedShareData: encryptedData,
|
|
});
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(payload)
|
|
.expect(201);
|
|
|
|
// Verify data in database (it should be double-encrypted)
|
|
const share = await prisma.backupShare.findUnique({
|
|
where: { userId: BigInt(payload.userId) },
|
|
});
|
|
|
|
expect(share).not.toBeNull();
|
|
// The stored data should be different from input (encrypted)
|
|
expect(share?.encryptedShareData).not.toBe(encryptedData);
|
|
// But retrieving should give us back the original
|
|
const retrieveResponse = await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({
|
|
userId: payload.userId,
|
|
publicKey: payload.publicKey,
|
|
}))
|
|
.expect(200);
|
|
|
|
expect(retrieveResponse.body.encryptedShareData).toBe(encryptedData);
|
|
});
|
|
|
|
it('should create audit logs for all operations', async () => {
|
|
const publicKey = generatePublicKey('q');
|
|
const userId = '60003';
|
|
|
|
// Store
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/store')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createStoreSharePayload({
|
|
userId,
|
|
accountSequence: 60003,
|
|
publicKey,
|
|
}))
|
|
.expect(201);
|
|
|
|
// Retrieve
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/retrieve')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRetrieveSharePayload({ userId, publicKey }))
|
|
.expect(200);
|
|
|
|
// Revoke
|
|
await request(app.getHttpServer())
|
|
.post('/backup-share/revoke')
|
|
.set('X-Service-Token', serviceToken)
|
|
.send(createRevokeSharePayload({ userId, publicKey }))
|
|
.expect(200);
|
|
|
|
// Check audit logs
|
|
const logs = await prisma.shareAccessLog.findMany({
|
|
where: { userId: BigInt(userId) },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
|
|
expect(logs.length).toBe(3);
|
|
expect(logs[0].action).toBe('STORE');
|
|
expect(logs[1].action).toBe('RETRIEVE');
|
|
expect(logs[2].action).toBe('REVOKE');
|
|
});
|
|
});
|
|
});
|