rwadurian/backend/services/backup-service/test/e2e/backup-share.e2e-spec.ts

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');
});
});
});