feat(backup-service): Implement MPC backup share storage service

- Add DDD + Hexagonal architecture with NestJS 11.x
- Implement store/retrieve/revoke backup share endpoints
- Add AES-256-GCM double encryption for secure storage
- Add service-to-service JWT authentication
- Add rate limiting (3 retrieves per user per day)
- Add comprehensive audit logging
- Add test suite (37 unit + 21 mock E2E + 20 real DB E2E = 78 tests)
- Add documentation (architecture, API, development, testing, deployment)
- Add Docker and Kubernetes deployment configuration
- Add Prisma 7.x with @prisma/adapter-pg for PostgreSQL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Developer 2025-11-29 22:12:41 -08:00
parent 083db83c96
commit 66199cc93e
88 changed files with 20513 additions and 901 deletions

View File

@ -0,0 +1,32 @@
{
"permissions": {
"allow": [
"Bash(npx @nestjs/cli new:*)",
"Bash(npm install:*)",
"Bash(npx prisma:*)",
"Bash(npm run build:*)",
"Bash(npm run test:unit:*)",
"Bash(npm test:*)",
"Bash(npm run test:e2e:mock:*)",
"Bash(docker info:*)",
"Bash(where:*)",
"Bash(npx ts-node:*)",
"Bash(node -e:*)",
"Bash(npm run test:all:*)",
"Bash(wsl docker info:*)",
"Bash(npx dotenv:*)",
"Bash(wsl docker ps:*)",
"Bash(wsl bash:*)",
"Bash(set \"PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION=yes\")",
"Bash(wsl docker logs:*)",
"Bash(wsl hostname:*)",
"Bash(timeout:*)",
"Bash(findstr:*)",
"Bash(tree:*)",
"Bash(powershell -Command:*)",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -0,0 +1,16 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
*.md
!README.md
test
coverage
.nyc_output
.vscode
.idea
*.log
npm-debug.log*

View File

@ -0,0 +1,25 @@
# Database (必须是独立的数据库实例,不能与 identity-service 共享!)
DATABASE_URL="postgresql://postgres:password@backup-db-server:5432/rwa_backup?schema=public"
# Server
APP_PORT=3002
APP_ENV="development"
# Service Authentication
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
ALLOWED_SERVICES="identity-service,recovery-service"
# Encryption (用于二次加密备份分片)
BACKUP_ENCRYPTION_KEY="your-256-bit-encryption-key-in-hex"
BACKUP_ENCRYPTION_KEY_ID="key-v1"
# Rate Limiting
MAX_RETRIEVE_PER_DAY=3 # 每用户每天最多获取 3 次
MAX_STORE_PER_MINUTE=10 # 每分钟最多存储 10 个
# Audit
AUDIT_LOG_RETENTION_DAYS=365 # 审计日志保留 365 天
# Monitoring
PROMETHEUS_ENABLED=true
PROMETHEUS_PORT=9102

View File

@ -0,0 +1,21 @@
# Test Environment Configuration
DATABASE_URL="postgresql://postgres:testpassword@localhost:5434/rwa_backup_test?schema=public"
# Server
APP_PORT=3003
APP_ENV="test"
# Service Authentication
SERVICE_JWT_SECRET="test-super-secret-service-jwt-key-for-e2e-testing"
ALLOWED_SERVICES="identity-service,recovery-service"
# Encryption
BACKUP_ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
BACKUP_ENCRYPTION_KEY_ID="test-key-v1"
# Rate Limiting (higher for tests)
MAX_RETRIEVE_PER_DAY=100
MAX_STORE_PER_MINUTE=100
# Audit
AUDIT_LOG_RETENTION_DAYS=1

View File

@ -0,0 +1,16 @@
node_modules
dist
coverage
# Keep environment variables out of version control
.env
/generated/prisma
# Windows artifacts
nul
*.log
# IDE
.vscode
.idea

View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -0,0 +1,56 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production && npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/prisma ./prisma
# Set ownership
RUN chown -R nestjs:nodejs /app
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3002
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
# Start the application
CMD ["node", "dist/main.js"]

View File

@ -1,901 +0,0 @@
# Backup Service 实施指导
## 1. 概述
### 1.1 服务定位
`backup-service` 是 RWA Durian 平台的 MPC 备份分片存储服务,负责安全存储用户的 Backup Share (Party 2)。
**核心职责:**
- 安全存储 MPC 2-of-3 的第三个分片 (Backup Share)
- 提供分片的存取 API
- 支持用户账户恢复时的分片验证和获取
- 审计日志记录
### 1.2 安全要求
**关键安全原则:必须部署在与 identity-service 不同的物理服务器上!**
```
┌──────────────────────────────────────────────────────────────────┐
│ MPC 分片物理隔离架构 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 物理服务器 A 物理服务器 B 用户设备 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │
│ │identity-service │ │ backup-service │ │Flutter App │ │
│ │Server Share (0) │ │Backup Share (2) │ │Client (1) │ │
│ └─────────────────┘ └─────────────────┘ └────────────┘ │
│ │
│ 任意单点被攻破,攻击者只能获得 1 个分片,无法重建私钥 │
└──────────────────────────────────────────────────────────────────┘
```
如果 Server Share 和 Backup Share 在同一台物理服务器,攻击者攻破该服务器后可获得 2 个分片,直接重建私钥,**MPC 安全性完全失效**
### 1.3 技术栈
与 identity-service 保持一致:
- **框架**: NestJS 10.x
- **语言**: TypeScript 5.x
- **数据库**: PostgreSQL 15+ (独立数据库实例)
- **ORM**: Prisma 5.x
- **缓存**: Redis (可选)
- **消息队列**: Kafka (可选,用于接收 identity-service 的事件)
---
## 2. 目录结构
参考 identity-service 的 DDD + Hexagonal 架构:
```
backup-service/
├── prisma/
│ ├── schema.prisma # 数据库模型
│ └── migrations/ # 数据库迁移
├── src/
│ ├── api/ # 接口层 (Adapters - Driving)
│ │ ├── controllers/
│ │ │ └── backup-share.controller.ts
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ ├── store-share.dto.ts
│ │ │ │ └── retrieve-share.dto.ts
│ │ │ └── response/
│ │ │ └── share-info.dto.ts
│ │ └── api.module.ts
│ │
│ ├── application/ # 应用层 (Use Cases)
│ │ ├── commands/
│ │ │ ├── store-backup-share/
│ │ │ │ ├── store-backup-share.command.ts
│ │ │ │ └── store-backup-share.handler.ts
│ │ │ └── revoke-share/
│ │ │ ├── revoke-share.command.ts
│ │ │ └── revoke-share.handler.ts
│ │ ├── queries/
│ │ │ └── get-backup-share/
│ │ │ ├── get-backup-share.query.ts
│ │ │ └── get-backup-share.handler.ts
│ │ ├── services/
│ │ │ └── backup-share-application.service.ts
│ │ └── application.module.ts
│ │
│ ├── domain/ # 领域层 (Core Business Logic)
│ │ ├── entities/
│ │ │ └── backup-share.entity.ts
│ │ ├── repositories/
│ │ │ └── backup-share.repository.interface.ts
│ │ ├── services/
│ │ │ └── share-encryption.domain-service.ts
│ │ ├── value-objects/
│ │ │ ├── share-id.vo.ts
│ │ │ └── encrypted-data.vo.ts
│ │ └── domain.module.ts
│ │
│ ├── infrastructure/ # 基础设施层 (Adapters - Driven)
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts
│ │ │ └── repositories/
│ │ │ └── backup-share.repository.impl.ts
│ │ ├── crypto/
│ │ │ └── aes-encryption.service.ts
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/ # 共享模块
│ │ ├── guards/
│ │ │ └── service-auth.guard.ts # 服务间认证
│ │ ├── filters/
│ │ │ └── global-exception.filter.ts
│ │ └── interceptors/
│ │ └── audit-log.interceptor.ts
│ │
│ ├── config/
│ │ └── index.ts
│ ├── app.module.ts
│ └── main.ts
├── test/
│ ├── unit/
│ └── e2e/
│ └── backup-share.e2e-spec.ts
├── .env.example
├── .env.development
├── package.json
├── tsconfig.json
├── nest-cli.json
└── Dockerfile
```
---
## 3. 数据库设计
### 3.1 Prisma Schema
```prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 备份分片存储
model BackupShare {
shareId BigInt @id @default(autoincrement()) @map("share_id")
// 用户标识 (来自 identity-service)
userId BigInt @unique @map("user_id")
accountSequence BigInt @unique @map("account_sequence")
// MPC 密钥信息
publicKey String @unique @map("public_key") @db.VarChar(130)
partyIndex Int @default(2) @map("party_index") // Backup = Party 2
threshold Int @default(2)
totalParties Int @default(3) @map("total_parties")
// 加密的分片数据 (AES-256-GCM 加密)
encryptedShareData String @map("encrypted_share_data") @db.Text
encryptionKeyId String @map("encryption_key_id") @db.VarChar(64) // 密钥轮换支持
// 状态管理
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, ROTATED
// 访问控制
accessCount Int @default(0) @map("access_count") // 访问次数限制
lastAccessedAt DateTime? @map("last_accessed_at")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
revokedAt DateTime? @map("revoked_at")
// 索引
@@index([publicKey], name: "idx_backup_public_key")
@@index([status], name: "idx_backup_status")
@@index([createdAt], name: "idx_backup_created")
@@map("backup_shares")
}
// 访问审计日志
model ShareAccessLog {
logId BigInt @id @default(autoincrement()) @map("log_id")
shareId BigInt @map("share_id")
userId BigInt @map("user_id")
action String @db.VarChar(20) // STORE, RETRIEVE, REVOKE, ROTATE
sourceService String @map("source_service") @db.VarChar(50) // identity-service, recovery-service
sourceIp String @map("source_ip") @db.VarChar(45)
success Boolean @default(true)
errorMessage String? @map("error_message") @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([shareId], name: "idx_log_share")
@@index([userId], name: "idx_log_user")
@@index([action], name: "idx_log_action")
@@index([createdAt], name: "idx_log_created")
@@map("share_access_logs")
}
```
---
## 4. API 设计
### 4.1 存储备份分片
**POST /backup-share/store**
由 identity-service 在创建账户时调用。
```typescript
// Request DTO
class StoreBackupShareDto {
@IsNotEmpty()
userId: string; // identity-service 的用户ID
@IsNotEmpty()
accountSequence: number; // 账户序列号
@IsNotEmpty()
publicKey: string; // MPC 公钥
@IsNotEmpty()
encryptedShareData: string; // 已加密的分片数据
@IsOptional()
threshold?: number; // 默认 2
@IsOptional()
totalParties?: number; // 默认 3
}
// Response
{
"success": true,
"shareId": "123",
"message": "Backup share stored successfully"
}
```
### 4.2 获取备份分片
**POST /backup-share/retrieve**
用于账户恢复场景,需要额外的身份验证。
```typescript
// Request DTO
class RetrieveBackupShareDto {
@IsNotEmpty()
userId: string;
@IsNotEmpty()
publicKey: string;
@IsNotEmpty()
recoveryToken: string; // 恢复令牌 (由 identity-service 签发)
@IsOptional()
deviceId?: string; // 新设备ID
}
// Response
{
"success": true,
"encryptedShareData": "...",
"partyIndex": 2,
"publicKey": "..."
}
```
### 4.3 撤销分片
**POST /backup-share/revoke**
用于密钥轮换或账户注销。
```typescript
// Request DTO
class RevokeShareDto {
@IsNotEmpty()
userId: string;
@IsNotEmpty()
publicKey: string;
@IsNotEmpty()
reason: string; // ROTATION, ACCOUNT_CLOSED, SECURITY_BREACH
}
```
---
## 5. 服务间认证
### 5.1 认证机制
backup-service 不对外公开,只接受来自内部服务的请求。使用 **服务间 JWT****mTLS** 进行认证。
```typescript
// src/shared/guards/service-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class ServiceAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const serviceToken = request.headers['x-service-token'];
if (!serviceToken) {
throw new UnauthorizedException('Missing service token');
}
try {
const secret = this.configService.get<string>('SERVICE_JWT_SECRET');
const payload = jwt.verify(serviceToken, secret) as { service: string };
// 只允许特定服务访问
const allowedServices = ['identity-service', 'recovery-service'];
if (!allowedServices.includes(payload.service)) {
throw new UnauthorizedException('Service not authorized');
}
request.sourceService = payload.service;
return true;
} catch (error) {
throw new UnauthorizedException('Invalid service token');
}
}
}
```
### 5.2 identity-service 调用示例
在 identity-service 中添加调用 backup-service 的客户端:
```typescript
// identity-service/src/infrastructure/external/backup/backup-client.service.ts
@Injectable()
export class BackupClientService {
private readonly backupServiceUrl: string;
private readonly serviceToken: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.backupServiceUrl = this.configService.get('BACKUP_SERVICE_URL');
this.serviceToken = this.generateServiceToken();
}
async storeBackupShare(params: {
userId: string;
accountSequence: number;
publicKey: string;
encryptedShareData: string;
}): Promise<void> {
await firstValueFrom(
this.httpService.post(
`${this.backupServiceUrl}/backup-share/store`,
params,
{
headers: {
'Content-Type': 'application/json',
'X-Service-Token': this.serviceToken,
},
},
),
);
}
private generateServiceToken(): string {
const secret = this.configService.get<string>('SERVICE_JWT_SECRET');
return jwt.sign(
{ service: 'identity-service', iat: Math.floor(Date.now() / 1000) },
secret,
{ expiresIn: '1h' },
);
}
}
```
---
## 6. 核心实现
### 6.1 领域实体
```typescript
// src/domain/entities/backup-share.entity.ts
export class BackupShare {
private constructor(
private readonly _shareId: bigint | null,
private readonly _userId: bigint,
private readonly _accountSequence: bigint,
private readonly _publicKey: string,
private readonly _partyIndex: number,
private readonly _threshold: number,
private readonly _totalParties: number,
private _encryptedShareData: string,
private _encryptionKeyId: string,
private _status: BackupShareStatus,
private _accessCount: number,
private readonly _createdAt: Date,
) {}
static create(params: {
userId: bigint;
accountSequence: bigint;
publicKey: string;
encryptedShareData: string;
encryptionKeyId: string;
}): BackupShare {
return new BackupShare(
null,
params.userId,
params.accountSequence,
params.publicKey,
2, // Backup = Party 2
2, // threshold
3, // totalParties
params.encryptedShareData,
params.encryptionKeyId,
BackupShareStatus.ACTIVE,
0,
new Date(),
);
}
recordAccess(): void {
if (this._status !== BackupShareStatus.ACTIVE) {
throw new DomainError('Cannot access revoked share');
}
this._accessCount++;
}
revoke(reason: string): void {
if (this._status === BackupShareStatus.REVOKED) {
throw new DomainError('Share already revoked');
}
this._status = BackupShareStatus.REVOKED;
}
rotate(newEncryptedData: string, newKeyId: string): void {
this._encryptedShareData = newEncryptedData;
this._encryptionKeyId = newKeyId;
this._status = BackupShareStatus.ACTIVE;
}
// Getters...
get shareId(): bigint | null { return this._shareId; }
get userId(): bigint { return this._userId; }
get publicKey(): string { return this._publicKey; }
get encryptedShareData(): string { return this._encryptedShareData; }
get status(): BackupShareStatus { return this._status; }
get accessCount(): number { return this._accessCount; }
}
export enum BackupShareStatus {
ACTIVE = 'ACTIVE',
REVOKED = 'REVOKED',
ROTATED = 'ROTATED',
}
```
### 6.2 应用服务
```typescript
// src/application/services/backup-share-application.service.ts
@Injectable()
export class BackupShareApplicationService {
private readonly logger = new Logger(BackupShareApplicationService.name);
constructor(
@Inject(BACKUP_SHARE_REPOSITORY)
private readonly repository: BackupShareRepository,
private readonly encryptionService: AesEncryptionService,
private readonly auditService: AuditLogService,
) {}
async storeBackupShare(command: StoreBackupShareCommand): Promise<string> {
this.logger.log(`Storing backup share for user: ${command.userId}`);
// 检查是否已存在
const existing = await this.repository.findByUserId(BigInt(command.userId));
if (existing) {
throw new ApplicationError('Backup share already exists for this user');
}
// 二次加密 (identity-service 已加密一次,这里再加密一次)
const { encrypted, keyId } = await this.encryptionService.encrypt(
command.encryptedShareData,
);
// 创建实体
const share = BackupShare.create({
userId: BigInt(command.userId),
accountSequence: BigInt(command.accountSequence),
publicKey: command.publicKey,
encryptedShareData: encrypted,
encryptionKeyId: keyId,
});
// 保存
const saved = await this.repository.save(share);
// 记录审计日志
await this.auditService.log({
shareId: saved.shareId!,
userId: BigInt(command.userId),
action: 'STORE',
sourceService: command.sourceService,
sourceIp: command.sourceIp,
success: true,
});
this.logger.log(`Backup share stored: shareId=${saved.shareId}`);
return saved.shareId!.toString();
}
async retrieveBackupShare(query: GetBackupShareQuery): Promise<BackupShareDto> {
this.logger.log(`Retrieving backup share for user: ${query.userId}`);
const share = await this.repository.findByUserIdAndPublicKey(
BigInt(query.userId),
query.publicKey,
);
if (!share) {
throw new ApplicationError('Backup share not found');
}
if (share.status !== BackupShareStatus.ACTIVE) {
throw new ApplicationError('Backup share is not active');
}
// 记录访问
share.recordAccess();
await this.repository.save(share);
// 解密
const decrypted = await this.encryptionService.decrypt(
share.encryptedShareData,
share.encryptionKeyId,
);
// 记录审计日志
await this.auditService.log({
shareId: share.shareId!,
userId: BigInt(query.userId),
action: 'RETRIEVE',
sourceService: query.sourceService,
sourceIp: query.sourceIp,
success: true,
});
return {
encryptedShareData: decrypted,
partyIndex: share.partyIndex,
publicKey: share.publicKey,
};
}
}
```
---
## 7. 环境配置
### 7.1 .env.example
```bash
# Database (必须是独立的数据库实例,不能与 identity-service 共享!)
DATABASE_URL="postgresql://postgres:password@backup-db-server:5432/rwa_backup?schema=public"
# Server
APP_PORT=3002
APP_ENV="development"
# Service Authentication
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
ALLOWED_SERVICES="identity-service,recovery-service"
# Encryption (用于二次加密备份分片)
BACKUP_ENCRYPTION_KEY="your-256-bit-encryption-key-in-hex"
BACKUP_ENCRYPTION_KEY_ID="key-v1"
# Rate Limiting
MAX_RETRIEVE_PER_DAY=3 # 每用户每天最多获取 3 次
MAX_STORE_PER_MINUTE=10 # 每分钟最多存储 10 个
# Audit
AUDIT_LOG_RETENTION_DAYS=365 # 审计日志保留 365 天
# Monitoring
PROMETHEUS_ENABLED=true
PROMETHEUS_PORT=9102
```
---
## 8. 部署要求
### 8.1 物理隔离
**强制要求:**
```yaml
# docker-compose.yml (仅供参考架构,生产环境必须分开部署)
# ❌ 错误示例 - 同一台机器
services:
identity-service:
...
backup-service: # 不能在这里!
...
# ✅ 正确示例 - 分开部署
# 机器 A: docker-compose-main.yml
services:
identity-service:
...
# 机器 B: docker-compose-backup.yml (异地机房)
services:
backup-service:
...
```
### 8.2 网络安全
```yaml
# backup-service 的网络策略
- 只允许来自 identity-service 的入站连接
- 禁止任何公网访问
- 使用 VPN 或专线连接主机房和备份机房
```
### 8.3 数据库安全
```
- 使用独立的 PostgreSQL 实例
- 启用 TLS 加密连接
- 定期备份到第三方存储
- 启用审计日志
```
---
## 9. 测试
### 9.1 E2E 测试
```typescript
// test/e2e/backup-share.e2e-spec.ts
describe('BackupShare (e2e)', () => {
let app: INestApplication;
let serviceToken: string;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// 生成测试用的服务令牌
serviceToken = jwt.sign(
{ service: 'identity-service' },
process.env.SERVICE_JWT_SECRET,
);
});
describe('POST /backup-share/store', () => {
it('should store backup share successfully', async () => {
const response = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '12345',
accountSequence: 1001,
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-share-data-base64',
})
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.shareId).toBeDefined();
});
it('should reject duplicate share', async () => {
// First store
await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '99999',
accountSequence: 9999,
publicKey: '02' + 'b'.repeat(64),
encryptedShareData: 'data',
});
// Duplicate
await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '99999',
accountSequence: 9999,
publicKey: '02' + 'b'.repeat(64),
encryptedShareData: 'data',
})
.expect(400);
});
it('should reject unauthorized service', async () => {
await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', 'invalid-token')
.send({})
.expect(401);
});
});
describe('POST /backup-share/retrieve', () => {
it('should retrieve backup share with valid recovery token', async () => {
// Setup: store a share first
const storeResponse = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '77777',
accountSequence: 7777,
publicKey: '02' + 'c'.repeat(64),
encryptedShareData: 'test-encrypted-data',
});
// Retrieve
const response = await request(app.getHttpServer())
.post('/backup-share/retrieve')
.set('X-Service-Token', serviceToken)
.send({
userId: '77777',
publicKey: '02' + 'c'.repeat(64),
recoveryToken: 'valid-recovery-token',
})
.expect(200);
expect(response.body.encryptedShareData).toBeDefined();
expect(response.body.partyIndex).toBe(2);
});
});
});
```
---
## 10. 与 identity-service 的集成
### 10.1 修改 identity-service
在 identity-service 的 `autoCreateAccount` 中添加调用 backup-service
```typescript
// identity-service/src/application/services/user-application.service.ts
// 在保存服务端分片后,调用 backup-service 存储备份分片
await this.mpcKeyShareRepository.saveServerShare({...});
// 新增: 调用 backup-service 存储备份分片
if (mpcResult.backupShareData) {
try {
await this.backupClient.storeBackupShare({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
publicKey: mpcResult.publicKey,
encryptedShareData: mpcResult.backupShareData,
});
this.logger.log(`Backup share stored to backup-service for user: ${account.userId}`);
} catch (error) {
this.logger.error(`Failed to store backup share: ${error.message}`);
// 根据业务需求决定是否回滚或继续
// 建议: 记录失败,后续通过补偿任务重试
}
}
```
### 10.2 配置更新
在 identity-service 的 `.env` 中添加:
```bash
# Backup Service
BACKUP_SERVICE_URL="http://backup-server:3002"
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
```
---
## 11. 监控和告警
### 11.1 关键指标
```
- backup_share_store_total # 存储总数
- backup_share_retrieve_total # 获取总数
- backup_share_revoke_total # 撤销总数
- backup_share_store_latency_ms # 存储延迟
- backup_share_retrieve_latency_ms # 获取延迟
- backup_share_error_total # 错误总数
```
### 11.2 告警规则
```yaml
# 异常访问告警
- alert: HighBackupShareRetrieveRate
expr: rate(backup_share_retrieve_total[5m]) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "Backup share retrieve rate is high"
# 服务不可用告警
- alert: BackupServiceDown
expr: up{job="backup-service"} == 0
for: 1m
labels:
severity: critical
```
---
## 12. 开发步骤
### 12.1 初始化项目
```bash
# 1. 创建 NestJS 项目
cd backend/services/backup-service
npx @nestjs/cli new . --skip-git
# 2. 安装依赖
npm install @nestjs/config @prisma/client class-validator class-transformer
npm install -D prisma
# 3. 初始化 Prisma
npx prisma init
# 4. 复制 schema 并生成客户端
npx prisma generate
npx prisma migrate dev --name init
```
### 12.2 开发顺序
1. **基础设施层** - Prisma 配置、数据库连接
2. **领域层** - BackupShare 实体、Repository 接口
3. **应用层** - 存储/获取/撤销命令处理
4. **接口层** - REST API Controller
5. **安全层** - ServiceAuthGuard、审计日志
6. **测试** - 单元测试、E2E 测试
---
## 13. 检查清单
开发完成后,确保以下事项:
- [ ] 数据库部署在独立服务器
- [ ] 服务间认证已实现
- [ ] 分片数据已二次加密
- [ ] 审计日志已启用
- [ ] 访问频率限制已实现
- [ ] E2E 测试通过
- [ ] 与 identity-service 集成测试通过
- [ ] 监控指标已配置
- [ ] 告警规则已配置
- [ ] 生产环境部署在异地机房

View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@ -0,0 +1,25 @@
version: '3.8'
services:
test-db:
image: postgres:15-alpine
container_name: backup-service-test-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: rwa_backup_test
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
tmpfs:
- /var/lib/postgresql/data
command: >
postgres
-c fsync=off
-c synchronous_commit=off
-c full_page_writes=off
-c random_page_cost=1.0

View File

@ -0,0 +1,53 @@
version: '3.8'
services:
backup-service:
build:
context: .
dockerfile: Dockerfile
container_name: backup-service
ports:
- "${APP_PORT:-3002}:3002"
environment:
- DATABASE_URL=postgresql://postgres:password@backup-db:5432/rwa_backup?schema=public
- APP_PORT=3002
- APP_ENV=development
- SERVICE_JWT_SECRET=${SERVICE_JWT_SECRET}
- ALLOWED_SERVICES=identity-service,recovery-service
- BACKUP_ENCRYPTION_KEY=${BACKUP_ENCRYPTION_KEY}
- BACKUP_ENCRYPTION_KEY_ID=${BACKUP_ENCRYPTION_KEY_ID:-key-v1}
- MAX_RETRIEVE_PER_DAY=3
- MAX_STORE_PER_MINUTE=10
depends_on:
backup-db:
condition: service_healthy
networks:
- backup-network
restart: unless-stopped
backup-db:
image: postgres:15-alpine
container_name: backup-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: rwa_backup
volumes:
- backup-db-data:/var/lib/postgresql/data
ports:
- "5433:5432" # Different port to avoid conflict with main db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backup-network
restart: unless-stopped
volumes:
backup-db-data:
networks:
backup-network:
driver: bridge

View File

@ -0,0 +1,613 @@
# Backup Service API Reference
## Overview
The backup-service exposes RESTful APIs for managing MPC backup shares. All endpoints (except health checks) require service-to-service JWT authentication.
**Base URL:** `http://localhost:3002`
---
## Authentication
All `/backup-share/*` endpoints require a service JWT token in the `X-Service-Token` header.
### Token Format
```json
{
"service": "identity-service",
"iat": 1704067200,
"exp": 1704153600
}
```
### Generating Service Tokens
```typescript
import jwt from 'jsonwebtoken';
const token = jwt.sign(
{ service: 'identity-service' },
process.env.SERVICE_JWT_SECRET,
{ expiresIn: '24h' }
);
```
### Allowed Services
Only the following services can access the backup-service:
- `identity-service` - Primary service for MPC operations
- `recovery-service` - Account recovery operations
Configure via `ALLOWED_SERVICES` environment variable.
---
## API Endpoints
### 1. Store Backup Share
Store an encrypted MPC backup share for a user.
**Endpoint:** `POST /backup-share/store`
**Headers:**
| Header | Required | Description |
|--------|----------|-------------|
| `X-Service-Token` | Yes | Service JWT token |
| `Content-Type` | Yes | `application/json` |
**Request Body:**
```json
{
"userId": "12345",
"accountSequence": 1001,
"publicKey": "02aabbccdd...",
"encryptedShareData": "base64-encoded-encrypted-data",
"threshold": 2,
"totalParties": 3
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `userId` | string | Yes | User identifier from identity-service |
| `accountSequence` | number | Yes | Account sequence number (min: 1) |
| `publicKey` | string | Yes | MPC public key (66-130 characters) |
| `encryptedShareData` | string | Yes | Pre-encrypted share data (AES-256-GCM by identity-service) |
| `threshold` | number | No | Reconstruction threshold (default: 2, range: 2-10) |
| `totalParties` | number | No | Total MPC parties (default: 3, range: 2-10) |
**Success Response (201 Created):**
```json
{
"success": true,
"shareId": "1",
"message": "Backup share stored successfully"
}
```
**Error Responses:**
| Status | Code | Description |
|--------|------|-------------|
| 400 | `VALIDATION_ERROR` | Invalid request body |
| 401 | `UNAUTHORIZED` | Missing or invalid service token |
| 409 | `SHARE_ALREADY_EXISTS` | Share already exists for this user |
**Example:**
```bash
curl -X POST http://localhost:3002/backup-share/store \
-H "X-Service-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"accountSequence": 1001,
"publicKey": "02aabbccddee...",
"encryptedShareData": "encrypted-share-data-here"
}'
```
---
### 2. Retrieve Backup Share
Retrieve an encrypted MPC backup share for account recovery.
**Endpoint:** `POST /backup-share/retrieve`
**Headers:**
| Header | Required | Description |
|--------|----------|-------------|
| `X-Service-Token` | Yes | Service JWT token |
| `Content-Type` | Yes | `application/json` |
**Request Body:**
```json
{
"userId": "12345",
"publicKey": "02aabbccdd...",
"recoveryToken": "valid-recovery-token",
"deviceId": "device-uuid"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `userId` | string | Yes | User identifier |
| `publicKey` | string | Yes | MPC public key (66-130 characters) |
| `recoveryToken` | string | Yes | Recovery token verified by identity-service |
| `deviceId` | string | No | Device identifier for audit logging |
**Success Response (200 OK):**
```json
{
"success": true,
"encryptedShareData": "base64-encoded-encrypted-data",
"partyIndex": 2,
"publicKey": "02aabbccdd..."
}
```
| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Operation success status |
| `encryptedShareData` | string | Decrypted share data (still encrypted by identity-service) |
| `partyIndex` | number | Party index (always 2 for backup share) |
| `publicKey` | string | MPC public key |
**Error Responses:**
| Status | Code | Description |
|--------|------|-------------|
| 400 | `SHARE_NOT_ACTIVE` | Share has been revoked |
| 401 | `UNAUTHORIZED` | Missing or invalid service token |
| 404 | `SHARE_NOT_FOUND` | No share found for user/publicKey |
| 429 | `RATE_LIMIT_EXCEEDED` | Max 3 retrievals per day exceeded |
**Rate Limiting:**
- Maximum 3 retrieves per user per day
- Configurable via `MAX_RETRIEVE_PER_DAY` environment variable
- Counter resets at midnight UTC
**Example:**
```bash
curl -X POST http://localhost:3002/backup-share/retrieve \
-H "X-Service-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"publicKey": "02aabbccddee...",
"recoveryToken": "valid-token-from-identity-service"
}'
```
---
### 3. Revoke Backup Share
Revoke an existing backup share (for key rotation or account closure).
**Endpoint:** `POST /backup-share/revoke`
**Headers:**
| Header | Required | Description |
|--------|----------|-------------|
| `X-Service-Token` | Yes | Service JWT token |
| `Content-Type` | Yes | `application/json` |
**Request Body:**
```json
{
"userId": "12345",
"publicKey": "02aabbccdd...",
"reason": "ROTATION"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `userId` | string | Yes | User identifier |
| `publicKey` | string | Yes | MPC public key (66-130 characters) |
| `reason` | string | Yes | Revocation reason (see below) |
**Valid Revocation Reasons:**
| Reason | Description |
|--------|-------------|
| `ROTATION` | Key rotation (new share will be stored) |
| `ACCOUNT_CLOSED` | User account permanently closed |
| `SECURITY_BREACH` | Security incident requiring key invalidation |
| `USER_REQUEST` | User requested share removal |
**Success Response (200 OK):**
```json
{
"success": true,
"message": "Backup share revoked successfully"
}
```
**Error Responses:**
| Status | Code | Description |
|--------|------|-------------|
| 400 | `VALIDATION_ERROR` | Invalid reason value |
| 401 | `UNAUTHORIZED` | Missing or invalid service token |
| 404 | `SHARE_NOT_FOUND` | No share found for user/publicKey |
**Example:**
```bash
curl -X POST http://localhost:3002/backup-share/revoke \
-H "X-Service-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"publicKey": "02aabbccddee...",
"reason": "ROTATION"
}'
```
---
### 4. Health Check
Basic health check endpoint (no authentication required).
**Endpoint:** `GET /health`
**Response (200 OK):**
```json
{
"status": "ok",
"timestamp": "2025-01-15T10:30:45.123Z",
"service": "backup-service"
}
```
---
### 5. Readiness Probe
Kubernetes readiness probe with database connectivity check.
**Endpoint:** `GET /health/ready`
**Success Response (200 OK):**
```json
{
"status": "ready",
"database": "connected",
"timestamp": "2025-01-15T10:30:45.123Z"
}
```
**Failure Response (503 Service Unavailable):**
```json
{
"status": "not ready",
"database": "disconnected",
"error": "Connection refused",
"timestamp": "2025-01-15T10:30:45.123Z"
}
```
---
### 6. Liveness Probe
Kubernetes liveness probe.
**Endpoint:** `GET /health/live`
**Response (200 OK):**
```json
{
"status": "alive",
"timestamp": "2025-01-15T10:30:45.123Z"
}
```
---
## Error Response Format
All error responses follow this standardized format:
```json
{
"success": false,
"error": "Human-readable error message",
"code": "MACHINE_READABLE_CODE",
"timestamp": "2025-01-15T10:30:45.123Z",
"path": "/backup-share/store"
}
```
### Error Codes Reference
| Code | HTTP Status | Description |
|------|-------------|-------------|
| `VALIDATION_ERROR` | 400 | Request validation failed |
| `SHARE_NOT_ACTIVE` | 400 | Share has been revoked |
| `UNAUTHORIZED` | 401 | Authentication failed |
| `FORBIDDEN` | 403 | Service not authorized |
| `SHARE_NOT_FOUND` | 404 | Share not found |
| `SHARE_ALREADY_EXISTS` | 409 | Duplicate share |
| `RATE_LIMIT_EXCEEDED` | 429 | Rate limit exceeded |
| `INTERNAL_ERROR` | 500 | Internal server error |
---
## Data Validation Rules
### Public Key
- Length: 66-130 characters
- Format: Hexadecimal string
- 66 chars = compressed ECDSA public key (02/03 prefix + 64 hex chars)
- 130 chars = uncompressed ECDSA public key (04 prefix + 128 hex chars)
### User ID
- Type: String (numeric format)
- Required: Yes
- Pattern: Positive integer as string
### Account Sequence
- Type: Number
- Minimum: 1
- Required: Yes
### Encrypted Share Data
- Type: String
- Required: Yes
- Format: Base64-encoded encrypted data (from identity-service)
---
## Workflow Examples
### Complete Account Setup Flow
```
1. User creates account on identity-service
2. identity-service generates MPC key shares (3 parties)
3. identity-service stores Party 0 (Server Share) locally
4. identity-service sends Party 1 (Client Share) to user device
5. identity-service calls backup-service to store Party 2 (Backup Share)
POST /backup-share/store
{
"userId": "12345",
"accountSequence": 1001,
"publicKey": "02...",
"encryptedShareData": "encrypted-party-2-data"
}
```
### Account Recovery Flow
```
1. User loses device and initiates recovery
2. User verifies identity through identity-service
3. identity-service generates recovery token
4. identity-service retrieves backup share
POST /backup-share/retrieve
{
"userId": "12345",
"publicKey": "02...",
"recoveryToken": "valid-recovery-token"
}
5. identity-service combines Party 0 + Party 2 to reconstruct key
6. New Party 1 share generated for new device
```
### Key Rotation Flow
```
1. Security event triggers key rotation
2. New key shares generated
3. Old backup share revoked
POST /backup-share/revoke
{
"userId": "12345",
"publicKey": "02...",
"reason": "ROTATION"
}
4. New backup share stored
POST /backup-share/store
{
"userId": "12345",
"accountSequence": 1002,
"publicKey": "03...",
"encryptedShareData": "new-encrypted-data"
}
```
---
## Security Considerations
### Double Encryption
Data is encrypted twice:
1. **First layer:** identity-service encrypts share data before sending
2. **Second layer:** backup-service encrypts again using AES-256-GCM
This ensures data remains protected even if one system is compromised.
### Rate Limiting
- Retrieves are limited to prevent brute-force attacks
- Default: 3 retrieves per user per day
- Configurable via environment variable
### Audit Logging
All operations are logged with:
- Timestamp
- User ID
- Action (STORE/RETRIEVE/REVOKE)
- Source service
- Source IP address
- Success/failure status
### Sensitive Data Handling
The following fields are redacted in logs:
- `encryptedShareData`
- `recoveryToken`
- `password`
- `secret`
---
## SDK Examples
### TypeScript/JavaScript
```typescript
import axios from 'axios';
import jwt from 'jsonwebtoken';
const BACKUP_SERVICE_URL = 'http://localhost:3002';
const SERVICE_JWT_SECRET = process.env.SERVICE_JWT_SECRET;
function generateServiceToken(): string {
return jwt.sign(
{ service: 'identity-service' },
SERVICE_JWT_SECRET,
{ expiresIn: '1h' }
);
}
async function storeBackupShare(
userId: string,
accountSequence: number,
publicKey: string,
encryptedShareData: string
): Promise<string> {
const response = await axios.post(
`${BACKUP_SERVICE_URL}/backup-share/store`,
{
userId,
accountSequence,
publicKey,
encryptedShareData,
},
{
headers: {
'X-Service-Token': generateServiceToken(),
'Content-Type': 'application/json',
},
}
);
return response.data.shareId;
}
async function retrieveBackupShare(
userId: string,
publicKey: string,
recoveryToken: string
): Promise<{ encryptedShareData: string; partyIndex: number }> {
const response = await axios.post(
`${BACKUP_SERVICE_URL}/backup-share/retrieve`,
{
userId,
publicKey,
recoveryToken,
},
{
headers: {
'X-Service-Token': generateServiceToken(),
'Content-Type': 'application/json',
},
}
);
return {
encryptedShareData: response.data.encryptedShareData,
partyIndex: response.data.partyIndex,
};
}
async function revokeBackupShare(
userId: string,
publicKey: string,
reason: 'ROTATION' | 'ACCOUNT_CLOSED' | 'SECURITY_BREACH' | 'USER_REQUEST'
): Promise<void> {
await axios.post(
`${BACKUP_SERVICE_URL}/backup-share/revoke`,
{
userId,
publicKey,
reason,
},
{
headers: {
'X-Service-Token': generateServiceToken(),
'Content-Type': 'application/json',
},
}
);
}
```
### cURL Examples
```bash
# Store a backup share
curl -X POST http://localhost:3002/backup-share/store \
-H "X-Service-Token: $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"accountSequence": 1001,
"publicKey": "02aabbccddee1122334455667788990011223344556677889900112233445566",
"encryptedShareData": "encrypted-data-here"
}'
# Retrieve a backup share
curl -X POST http://localhost:3002/backup-share/retrieve \
-H "X-Service-Token: $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"publicKey": "02aabbccddee1122334455667788990011223344556677889900112233445566",
"recoveryToken": "recovery-token-here"
}'
# Revoke a backup share
curl -X POST http://localhost:3002/backup-share/revoke \
-H "X-Service-Token: $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"publicKey": "02aabbccddee1122334455667788990011223344556677889900112233445566",
"reason": "ROTATION"
}'
# Health check
curl http://localhost:3002/health
```

View File

@ -0,0 +1,430 @@
# Backup Service Architecture
## Overview
**Service Name:** `backup-service`
**Version:** 1.0.0
**Description:** RWA Durian MPC Backup Share Storage Service
**Primary Purpose:** Securely store and manage MPC backup shares (Party 2/3) for account recovery
## Core Responsibilities
- Store encrypted MPC backup share data (Party 2)
- Provide share retrieval for account recovery scenarios
- Support share revocation for key rotation or account closure
- Maintain comprehensive audit logs for all operations
- Implement rate limiting and access controls
## Critical Security Requirement
**Physical server isolation from identity-service is MANDATORY.** The backup-service must be deployed on a physically separate server to maintain MPC security. If compromised alone, attackers can only obtain 1 of 3 shares, making key reconstruction impossible.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ MPC Key Distribution │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Party 0 (Server Share) Party 1 (Client Share) Party 2 (Backup) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ identity-service│ │ User Device │ │backup-service│ │
│ │ (Server A) │ │ (Mobile/Web) │ │ (Server B) │ │
│ └─────────────────┘ └─────────────────┘ └──────────────┘ │
│ │
│ 2-of-3 Threshold: Any 2 shares can reconstruct the private key │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## DDD + Hexagonal Architecture
The service follows a layered architecture with clear separation of concerns:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ API Layer (Adapters) │
│ Controllers, DTOs, HTTP Request/Response Handling │
├─────────────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ Use Cases, Commands, Queries, Handlers, Services │
├─────────────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ Entities, Value Objects, Repositories (Interfaces), Business Logic │
├─────────────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer (Adapters) │
│ Persistence (Prisma/PostgreSQL), Encryption, Crypto │
└─────────────────────────────────────────────────────────────────────────┘
```
### Layer Dependencies (Dependency Rule)
```
API Layer ──────────────▶ Application Layer
Domain Layer ◀─────── Infrastructure Layer
│ │
▼ ▼
(Interfaces defined) (Implementations)
```
**Key Principle:** Dependencies point inward. Domain layer has no external dependencies.
---
## Design Patterns
| Pattern | Implementation | Files |
|---------|----------------|-------|
| **Command Pattern** | Store, Revoke operations | `store-backup-share.command.ts`, `revoke-share.command.ts` |
| **Query Pattern** | Retrieve operation | `get-backup-share.query.ts` |
| **Repository Pattern** | Data access abstraction | `backup-share.repository.interface.ts`, `backup-share.repository.impl.ts` |
| **Dependency Injection** | NestJS DI Container | `*.module.ts` |
| **Guard Pattern** | Authentication & Authorization | `service-auth.guard.ts` |
| **Filter Pattern** | Global exception handling | `global-exception.filter.ts` |
| **Interceptor Pattern** | Request/response processing | `audit-log.interceptor.ts` |
| **Value Objects** | Immutable domain concepts | `share-id.vo.ts`, `encrypted-data.vo.ts` |
---
## Directory Structure
```
backup-service/
├── prisma/
│ ├── schema.prisma # Database schema definition
│ └── migrations/ # Database migration history
├── src/
│ ├── api/ # Adapter Layer (External Interface)
│ │ ├── controllers/
│ │ │ ├── backup-share.controller.ts # Main API endpoints
│ │ │ └── health.controller.ts # Health check endpoints
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ ├── store-share.dto.ts # Store share request
│ │ │ │ ├── retrieve-share.dto.ts # Retrieve share request
│ │ │ │ └── revoke-share.dto.ts # Revoke share request
│ │ │ └── response/
│ │ │ └── share-info.dto.ts # Response DTOs
│ │ └── api.module.ts
│ │
│ ├── application/ # Use Cases Layer
│ │ ├── commands/
│ │ │ ├── store-backup-share/
│ │ │ │ ├── store-backup-share.command.ts
│ │ │ │ └── store-backup-share.handler.ts
│ │ │ └── revoke-share/
│ │ │ ├── revoke-share.command.ts
│ │ │ └── revoke-share.handler.ts
│ │ ├── queries/
│ │ │ └── get-backup-share/
│ │ │ ├── get-backup-share.query.ts
│ │ │ └── get-backup-share.handler.ts
│ │ ├── services/
│ │ │ └── backup-share-application.service.ts
│ │ ├── errors/
│ │ │ └── application.error.ts
│ │ └── application.module.ts
│ │
│ ├── domain/ # Core Business Logic
│ │ ├── entities/
│ │ │ └── backup-share.entity.ts # BackupShare aggregate root
│ │ ├── repositories/
│ │ │ └── backup-share.repository.interface.ts
│ │ ├── value-objects/
│ │ │ ├── share-id.vo.ts # Immutable share identifier
│ │ │ └── encrypted-data.vo.ts # Encrypted data structure
│ │ ├── errors/
│ │ │ └── domain.error.ts
│ │ └── domain.module.ts
│ │
│ ├── infrastructure/ # Adapter Layer (Services)
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts # Prisma ORM service
│ │ │ └── repositories/
│ │ │ ├── backup-share.repository.impl.ts # Repository implementation
│ │ │ └── audit-log.repository.ts # Audit logging
│ │ ├── crypto/
│ │ │ └── aes-encryption.service.ts # AES-256-GCM encryption
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/ # Cross-cutting Concerns
│ │ ├── guards/
│ │ │ └── service-auth.guard.ts # JWT service authentication
│ │ ├── filters/
│ │ │ └── global-exception.filter.ts # Exception handling
│ │ └── interceptors/
│ │ └── audit-log.interceptor.ts # Request/response logging
│ │
│ ├── config/
│ │ └── index.ts # Centralized configuration
│ ├── app.module.ts # Root NestJS module
│ └── main.ts # Application entry point
├── test/ # Test files
│ ├── unit/
│ ├── integration/
│ ├── e2e/
│ ├── setup/
│ └── utils/
└── docs/ # Documentation
```
---
## Domain Layer Details
### BackupShare Entity (Aggregate Root)
**File:** `src/domain/entities/backup-share.entity.ts`
```typescript
class BackupShare {
// Identity
shareId: bigint | null // Auto-increment primary key
userId: bigint // From identity-service
accountSequence: bigint // Account identifier
// MPC Configuration
publicKey: string // MPC public key (66-130 chars)
partyIndex: number // Always 2 for backup share
threshold: number // Default 2 (for 2-of-3 scheme)
totalParties: number // Default 3
// Encrypted Data
encryptedShareData: string // AES-256-GCM encrypted data
encryptionKeyId: string // For key rotation support
// State Management
status: BackupShareStatus // ACTIVE | REVOKED | ROTATED
accessCount: number // Track access frequency
lastAccessedAt: Date | null
// Timestamps
createdAt: Date
updatedAt: Date
revokedAt: Date | null
}
// Factory Methods
BackupShare.create(params): BackupShare
BackupShare.reconstitute(props): BackupShare
// Domain Methods
recordAccess(): void // Increment access counter
revoke(reason: string): void // Mark as revoked
rotate(newData, newKeyId): void // Key rotation support
isActive(): boolean
```
### Value Objects
#### ShareId
```typescript
class ShareId {
static create(value: bigint | string | number): ShareId
get value(): bigint
toString(): string
equals(other: ShareId): boolean
}
```
#### EncryptedData
```typescript
class EncryptedData {
ciphertext: string // Base64 encoded
iv: string // Base64 encoded
authTag: string // Base64 encoded
keyId: string
static create(params): EncryptedData
static fromSerializedString(serialized, keyId): EncryptedData
toSerializedString(): string
}
```
### Repository Interface
```typescript
interface BackupShareRepository {
save(share: BackupShare): Promise<BackupShare>
findById(shareId: bigint): Promise<BackupShare | null>
findByUserId(userId: bigint): Promise<BackupShare | null>
findByPublicKey(publicKey: string): Promise<BackupShare | null>
findByUserIdAndPublicKey(userId: bigint, publicKey: string): Promise<BackupShare | null>
findByAccountSequence(accountSequence: bigint): Promise<BackupShare | null>
delete(shareId: bigint): Promise<void>
}
```
---
## Application Layer Details
### Command Handlers
#### StoreBackupShareHandler
**Flow:**
1. Check if share already exists for user (uniqueness constraint)
2. Check if share already exists for public key (uniqueness constraint)
3. Apply double encryption (AES-256-GCM)
4. Create BackupShare domain entity
5. Save to repository
6. Log audit event
7. Return shareId
#### RevokeShareHandler
**Flow:**
1. Find share by userId and publicKey
2. Call entity's `revoke()` method
3. Save changes to repository
4. Log audit event
### Query Handlers
#### GetBackupShareHandler
**Flow:**
1. Check rate limit (max 3 retrieves per day per user)
2. Find share by userId and publicKey
3. Verify share is ACTIVE
4. Record access in entity
5. Save entity state
6. Decrypt share data (removes our encryption layer)
7. Log audit event
8. Return decrypted data
---
## Infrastructure Layer Details
### Encryption Service
**Algorithm:** AES-256-GCM (authenticated encryption)
**IV Length:** 12 bytes (96 bits)
**Key Size:** 32 bytes (256 bits)
**Output Format:** `{ciphertext}:{iv}:{authTag}` (colon-separated base64)
```typescript
class AesEncryptionService {
async encrypt(plaintext: string): Promise<EncryptionResult>
async decrypt(encryptedData: string, keyId: string): Promise<string>
addKey(keyId: string, keyHex: string): void
getCurrentKeyId(): string
}
```
### Prisma ORM Service
Uses `@prisma/adapter-pg` for Prisma 7.x compatibility with PostgreSQL.
```typescript
class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() // Connect on startup
async onModuleDestroy() // Disconnect on shutdown
async cleanDatabase() // Test utility - delete all tables
}
```
---
## Database Schema
### BackupShare Table
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| share_id | BIGSERIAL | PK | Auto-increment ID |
| user_id | BIGINT | UNIQUE, NOT NULL | User identifier |
| account_sequence | BIGINT | UNIQUE, NOT NULL | Account sequence |
| public_key | VARCHAR(130) | UNIQUE, NOT NULL | MPC public key |
| party_index | INT | DEFAULT 2 | Party index (always 2) |
| threshold | INT | DEFAULT 2 | Threshold for reconstruction |
| total_parties | INT | DEFAULT 3 | Total parties |
| encrypted_share_data | TEXT | NOT NULL | Encrypted share data |
| encryption_key_id | VARCHAR(64) | NOT NULL | Encryption key ID |
| status | VARCHAR(20) | DEFAULT 'ACTIVE' | Share status |
| access_count | INT | DEFAULT 0 | Access counter |
| last_accessed_at | TIMESTAMP | NULLABLE | Last access time |
| created_at | TIMESTAMP | DEFAULT NOW() | Creation time |
| updated_at | TIMESTAMP | AUTO | Update time |
| revoked_at | TIMESTAMP | NULLABLE | Revocation time |
### ShareAccessLog Table
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| log_id | BIGSERIAL | PK | Auto-increment ID |
| share_id | BIGINT | NOT NULL | Reference to share |
| user_id | BIGINT | NOT NULL | User identifier |
| action | VARCHAR(20) | NOT NULL | STORE/RETRIEVE/REVOKE/ROTATE |
| source_service | VARCHAR(50) | NOT NULL | Calling service |
| source_ip | VARCHAR(45) | NOT NULL | Client IP |
| success | BOOLEAN | DEFAULT TRUE | Operation success |
| error_message | TEXT | NULLABLE | Error details |
| created_at | TIMESTAMP | DEFAULT NOW() | Log time |
---
## Key Architectural Decisions
### 1. Double Encryption
- Identity-service encrypts data once
- Backup-service encrypts again (AES-256-GCM)
- Defense-in-depth: even if one system is compromised, data remains encrypted
### 2. Physical Server Isolation
- MPC scheme is 2-of-3: requires at least 2 shares to reconstruct key
- Party 0 (Server Share) on identity-service
- Party 2 (Backup Share) on separate backup-service
- Party 1 (Client Share) on user device
- If only one server is compromised, MPC security remains intact
### 3. Audit Logging
- All operations logged with timestamp, user, action, source service, source IP
- Non-blocking writes (errors don't affect main operations)
- Supports compliance and security investigations
### 4. Rate Limiting
- Max 3 retrieves per user per day (prevents brute force recovery attempts)
- Configurable via `MAX_RETRIEVE_PER_DAY`
- Tracked in database, can be monitored for anomalies
### 5. Service-to-Service Auth
- JWT tokens with service identity
- No user authentication on backup-service (identity-service responsible)
- Simplified client trust model: only trust from known services
### 6. Error Handling
- Structured error codes for programmatic handling
- Sensitive data redacted from logs
- Standard error response format
---
## Key Files Reference
| File Path | Purpose |
|-----------|---------|
| `src/main.ts` | Entry point, NestFactory bootstrap |
| `src/app.module.ts` | Root module, global filters/interceptors |
| `src/config/index.ts` | Centralized configuration |
| `src/domain/entities/backup-share.entity.ts` | Core domain entity |
| `src/domain/repositories/backup-share.repository.interface.ts` | Repository interface |
| `src/application/commands/store-backup-share/` | Store use case |
| `src/application/queries/get-backup-share/` | Retrieve use case |
| `src/infrastructure/crypto/aes-encryption.service.ts` | Encryption service |
| `src/shared/guards/service-auth.guard.ts` | Authentication guard |
| `prisma/schema.prisma` | Database schema |

View File

@ -0,0 +1,696 @@
# Backup Service Deployment Guide
## Overview
This guide covers deploying the backup-service to various environments. The service is designed to run in Docker containers with PostgreSQL as the database.
**Critical Security Requirement:** The backup-service MUST be deployed on a physically separate server from identity-service to maintain MPC security.
---
## Deployment Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Production Architecture │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Server A (Identity) Server B (Backup) │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ identity-service │ │ backup-service │ │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ PostgreSQL │ │ │ │ PostgreSQL │ │ │
│ │ │ (identity-db) │ │ │ │ (backup-db) │ │ │
│ │ └───────────────┘ │ │ └───────────────┘ │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │ ▲ │
│ │ Internal Network │ │
│ └───────────────────────────────┘ │
│ (Service-to-Service JWT) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Docker Deployment
### Production Dockerfile
```dockerfile
# Dockerfile
# Multi-stage build for smaller image size
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build application
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production && npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/prisma ./prisma
# Change ownership to non-root user
RUN chown -R nestjs:nodejs /app
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3002
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
# Start application
CMD ["node", "dist/main.js"]
```
### Build and Push Image
```bash
# Build image
docker build -t rwa-durian/backup-service:latest .
# Tag for registry
docker tag rwa-durian/backup-service:latest registry.example.com/backup-service:v1.0.0
# Push to registry
docker push registry.example.com/backup-service:v1.0.0
```
---
## Docker Compose Deployment
### Production Compose File
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
backup-service:
image: rwa-durian/backup-service:latest
container_name: backup-service
restart: unless-stopped
ports:
- "3002:3002"
environment:
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@backup-db:5432/rwa_backup?schema=public
- APP_PORT=3002
- APP_ENV=production
- SERVICE_JWT_SECRET=${SERVICE_JWT_SECRET}
- ALLOWED_SERVICES=${ALLOWED_SERVICES}
- BACKUP_ENCRYPTION_KEY=${BACKUP_ENCRYPTION_KEY}
- BACKUP_ENCRYPTION_KEY_ID=${BACKUP_ENCRYPTION_KEY_ID}
- MAX_RETRIEVE_PER_DAY=3
- MAX_STORE_PER_MINUTE=10
- AUDIT_LOG_RETENTION_DAYS=365
depends_on:
backup-db:
condition: service_healthy
networks:
- backup-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
backup-db:
image: postgres:15-alpine
container_name: backup-db
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: rwa_backup
volumes:
- backup-db-data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5433:5432" # Different port to avoid conflicts
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backup-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
backup-db-data:
driver: local
networks:
backup-network:
driver: bridge
```
### Environment File
```bash
# .env.production
DB_PASSWORD=your-strong-database-password-here
SERVICE_JWT_SECRET=your-super-secret-service-jwt-key-min-32-chars
ALLOWED_SERVICES=identity-service,recovery-service
BACKUP_ENCRYPTION_KEY=your-256-bit-encryption-key-in-hex-64-chars
BACKUP_ENCRYPTION_KEY_ID=key-v1
```
### Deploy Commands
```bash
# Pull latest image
docker-compose -f docker-compose.prod.yml pull
# Start services
docker-compose -f docker-compose.prod.yml up -d
# Run database migrations
docker-compose -f docker-compose.prod.yml exec backup-service \
npx prisma migrate deploy
# View logs
docker-compose -f docker-compose.prod.yml logs -f backup-service
# Stop services
docker-compose -f docker-compose.prod.yml down
```
---
## Kubernetes Deployment
### Namespace and ConfigMap
```yaml
# kubernetes/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: rwa-backup
---
# kubernetes/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: backup-service-config
namespace: rwa-backup
data:
APP_PORT: "3002"
APP_ENV: "production"
ALLOWED_SERVICES: "identity-service,recovery-service"
MAX_RETRIEVE_PER_DAY: "3"
MAX_STORE_PER_MINUTE: "10"
AUDIT_LOG_RETENTION_DAYS: "365"
```
### Secrets
```yaml
# kubernetes/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: backup-service-secrets
namespace: rwa-backup
type: Opaque
stringData:
DATABASE_URL: "postgresql://postgres:password@backup-db:5432/rwa_backup?schema=public"
SERVICE_JWT_SECRET: "your-super-secret-service-jwt-key-min-32-chars"
BACKUP_ENCRYPTION_KEY: "your-256-bit-encryption-key-in-hex-64-chars"
BACKUP_ENCRYPTION_KEY_ID: "key-v1"
```
### Deployment
```yaml
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backup-service
namespace: rwa-backup
spec:
replicas: 2
selector:
matchLabels:
app: backup-service
template:
metadata:
labels:
app: backup-service
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
containers:
- name: backup-service
image: registry.example.com/backup-service:v1.0.0
ports:
- containerPort: 3002
envFrom:
- configMapRef:
name: backup-service-config
- secretRef:
name: backup-service-secrets
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 3002
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /health/ready
port: 3002
initialDelaySeconds: 5
periodSeconds: 10
```
### Service
```yaml
# kubernetes/service.yaml
apiVersion: v1
kind: Service
metadata:
name: backup-service
namespace: rwa-backup
spec:
selector:
app: backup-service
ports:
- protocol: TCP
port: 3002
targetPort: 3002
type: ClusterIP
```
### PostgreSQL StatefulSet
```yaml
# kubernetes/postgres.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: backup-db
namespace: rwa-backup
spec:
serviceName: backup-db
replicas: 1
selector:
matchLabels:
app: backup-db
template:
metadata:
labels:
app: backup-db
spec:
containers:
- name: postgres
image: postgres:15-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: rwa_backup
- name: POSTGRES_USER
value: postgres
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: backup-db-secrets
key: password
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
```
### Deploy to Kubernetes
```bash
# Apply all manifests
kubectl apply -f kubernetes/
# Check deployment status
kubectl -n rwa-backup get pods
# View logs
kubectl -n rwa-backup logs -f deployment/backup-service
# Run migrations
kubectl -n rwa-backup exec -it deployment/backup-service -- \
npx prisma migrate deploy
```
---
## Database Management
### Initial Setup
```bash
# Run migrations on first deployment
npx prisma migrate deploy
# Or push schema directly (development only)
npx prisma db push
```
### Backup and Restore
```bash
# Backup database
docker-compose exec backup-db pg_dump -U postgres rwa_backup > backup.sql
# Restore database
docker-compose exec -T backup-db psql -U postgres rwa_backup < backup.sql
```
### Migration in Production
```bash
# Generate migration (development)
npx prisma migrate dev --name add_new_field
# Apply migration (production)
npx prisma migrate deploy
```
---
## Environment Variables Reference
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/db` |
| `SERVICE_JWT_SECRET` | JWT secret for service auth (min 32 chars) | Random 64+ char string |
| `ALLOWED_SERVICES` | Comma-separated allowed services | `identity-service,recovery-service` |
| `BACKUP_ENCRYPTION_KEY` | 256-bit key in hex (64 chars) | 64 hex characters |
| `BACKUP_ENCRYPTION_KEY_ID` | Key identifier | `key-v1` |
### Optional Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_PORT` | `3002` | Server port |
| `APP_ENV` | `development` | Environment (development/production) |
| `MAX_RETRIEVE_PER_DAY` | `3` | Max retrieves per user per day |
| `MAX_STORE_PER_MINUTE` | `10` | Max stores per minute |
| `AUDIT_LOG_RETENTION_DAYS` | `365` | Audit log retention period |
---
## Security Considerations
### Network Security
1. **Isolate backup-service network**
- Use private subnets
- Restrict access to identity-service only
- Use VPN or VPC peering for cross-server communication
2. **Firewall rules**
```bash
# Allow only identity-service IP
iptables -A INPUT -p tcp --dport 3002 -s identity-service-ip -j ACCEPT
iptables -A INPUT -p tcp --dport 3002 -j DROP
```
3. **TLS/SSL**
- Use reverse proxy (nginx/traefik) for TLS termination
- Enable mutual TLS for service-to-service communication
### Secret Management
1. **Use secret management services**
- AWS Secrets Manager
- HashiCorp Vault
- Kubernetes Secrets with encryption at rest
2. **Rotate secrets regularly**
- Rotate encryption keys annually
- Rotate JWT secrets quarterly
- Use key versioning for encryption keys
### Database Security
1. **Use strong passwords**
2. **Enable SSL for database connections**
3. **Regular backups with encryption**
4. **Limit database user permissions**
---
## Monitoring and Logging
### Health Endpoints
| Endpoint | Purpose |
|----------|---------|
| `GET /health` | Basic health check |
| `GET /health/ready` | Readiness probe (includes DB check) |
| `GET /health/live` | Liveness probe |
### Prometheus Metrics (Optional)
```yaml
# Add to deployment
- name: PROMETHEUS_ENABLED
value: "true"
- name: PROMETHEUS_PORT
value: "9102"
```
### Log Aggregation
Configure log driver for centralized logging:
```yaml
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "backup-service"
```
---
## Troubleshooting
### Common Issues
#### Service won't start
```bash
# Check logs
docker-compose logs backup-service
# Common causes:
# 1. Database not ready
# 2. Missing environment variables
# 3. Invalid encryption key format
```
#### Database connection failed
```bash
# Check database is running
docker-compose ps backup-db
# Check database logs
docker-compose logs backup-db
# Test connection
docker-compose exec backup-service \
npx prisma db pull
```
#### Authentication errors
```bash
# Verify JWT secret matches between services
# Check ALLOWED_SERVICES includes calling service
# Verify token format and expiration
```
### Recovery Procedures
#### Database Recovery
```bash
# Stop service
docker-compose stop backup-service
# Restore from backup
docker-compose exec -T backup-db psql -U postgres rwa_backup < backup.sql
# Run migrations
docker-compose exec backup-service npx prisma migrate deploy
# Start service
docker-compose start backup-service
```
#### Key Rotation
1. Add new key to encryption service
2. Re-encrypt existing data with new key
3. Update `BACKUP_ENCRYPTION_KEY_ID`
4. Remove old key after transition period
---
## Scaling
### Horizontal Scaling
The service is stateless and can be horizontally scaled:
```yaml
# Docker Compose scale
docker-compose up -d --scale backup-service=3
# Kubernetes replicas
kubectl -n rwa-backup scale deployment/backup-service --replicas=3
```
### Load Balancing
Use a load balancer in front of multiple instances:
```nginx
# nginx.conf
upstream backup_service {
least_conn;
server backup-service-1:3002;
server backup-service-2:3002;
server backup-service-3:3002;
}
server {
listen 443 ssl;
server_name backup-api.example.com;
location / {
proxy_pass http://backup_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Database Scaling
For high availability:
1. Use managed PostgreSQL (AWS RDS, GCP Cloud SQL)
2. Configure read replicas for read scaling
3. Use connection pooling (PgBouncer)
---
## Maintenance
### Regular Tasks
| Task | Frequency | Command |
|------|-----------|---------|
| Database backup | Daily | `pg_dump rwa_backup > backup.sql` |
| Log rotation | Weekly | Automatic with log driver config |
| Security updates | Monthly | Rebuild and redeploy image |
| Audit log cleanup | Monthly | `DELETE FROM share_access_logs WHERE created_at < NOW() - INTERVAL '365 days'` |
### Update Procedure
```bash
# 1. Build new image
docker build -t rwa-durian/backup-service:v1.1.0 .
# 2. Push to registry
docker push registry.example.com/backup-service:v1.1.0
# 3. Update deployment
docker-compose pull
docker-compose up -d
# 4. Run migrations if needed
docker-compose exec backup-service npx prisma migrate deploy
# 5. Verify health
curl http://localhost:3002/health
```

View File

@ -0,0 +1,513 @@
# Backup Service Development Guide
## Prerequisites
- **Node.js:** v20.x or higher
- **npm:** v10.x or higher
- **Docker:** For running PostgreSQL locally
- **WSL2:** (Windows users) For running Docker and tests
---
## Quick Start
### 1. Clone and Install
```bash
# Navigate to service directory
cd backend/services/backup-service
# Install dependencies
npm install
```
### 2. Environment Setup
Copy the example environment file:
```bash
cp .env.example .env
```
Configure the following environment variables:
```bash
# Database Configuration
DATABASE_URL="postgresql://postgres:password@localhost:5433/rwa_backup?schema=public"
# Server Configuration
APP_PORT=3002
APP_ENV="development"
# Service-to-Service Authentication
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key-min-32-chars"
ALLOWED_SERVICES="identity-service,recovery-service"
# Encryption
BACKUP_ENCRYPTION_KEY="your-256-bit-encryption-key-in-hex-64-chars"
BACKUP_ENCRYPTION_KEY_ID="key-v1"
# Rate Limiting
MAX_RETRIEVE_PER_DAY=3
MAX_STORE_PER_MINUTE=10
# Audit
AUDIT_LOG_RETENTION_DAYS=365
```
### 3. Start Database
```bash
# Start PostgreSQL container
docker-compose up -d
# Verify database is running
docker-compose ps
```
### 4. Setup Database Schema
```bash
# Generate Prisma client
npm run prisma:generate
# Run migrations
npm run prisma:migrate
```
### 5. Start Development Server
```bash
# Start with hot-reload
npm run start:dev
# Or start in debug mode
npm run start:debug
```
The service will be available at `http://localhost:3002`.
---
## Project Scripts
### Development
| Script | Description |
|--------|-------------|
| `npm run start` | Start the service |
| `npm run start:dev` | Start with hot-reload |
| `npm run start:debug` | Start with debugger |
| `npm run start:prod` | Start production build |
| `npm run build` | Build for production |
### Database
| Script | Description |
|--------|-------------|
| `npm run prisma:generate` | Generate Prisma client |
| `npm run prisma:migrate` | Run development migrations |
| `npm run prisma:migrate:prod` | Run production migrations |
| `npm run prisma:studio` | Open Prisma Studio GUI |
### Testing
| Script | Description |
|--------|-------------|
| `npm run test` | Run all tests |
| `npm run test:unit` | Run unit tests only |
| `npm run test:e2e:mock` | Run E2E tests with mocks |
| `npm run test:e2e:db` | Run E2E tests with real DB |
| `npm run test:cov` | Generate coverage report |
| `npm run test:watch` | Run tests in watch mode |
### Docker
| Script | Description |
|--------|-------------|
| `npm run docker:build` | Build Docker image |
| `npm run docker:up` | Start Docker compose stack |
| `npm run docker:down` | Stop Docker compose stack |
| `npm run db:test:up` | Start test database |
| `npm run db:test:down` | Stop test database |
---
## Code Structure
### Adding a New Feature
Follow the DDD + Hexagonal architecture pattern:
#### 1. Domain Layer (Core Business Logic)
Create entities and value objects first:
```typescript
// src/domain/entities/new-entity.entity.ts
export class NewEntity {
// Properties
private id: bigint;
private data: string;
// Factory method
static create(params: CreateParams): NewEntity {
// Validation and creation logic
return new NewEntity(params);
}
// Domain methods
performAction(): void {
// Business logic
}
}
```
#### 2. Repository Interface
Define the data access contract:
```typescript
// src/domain/repositories/new-entity.repository.interface.ts
export interface NewEntityRepository {
save(entity: NewEntity): Promise<NewEntity>;
findById(id: bigint): Promise<NewEntity | null>;
// ... other methods
}
```
#### 3. Application Layer (Use Cases)
Create commands/queries and handlers:
```typescript
// src/application/commands/create-new-entity/create-new-entity.command.ts
export class CreateNewEntityCommand {
constructor(
public readonly data: string,
// ... other fields
) {}
}
// src/application/commands/create-new-entity/create-new-entity.handler.ts
@Injectable()
export class CreateNewEntityHandler {
constructor(
@Inject('NewEntityRepository')
private readonly repository: NewEntityRepository,
) {}
async execute(command: CreateNewEntityCommand): Promise<Result> {
const entity = NewEntity.create({ data: command.data });
await this.repository.save(entity);
return { id: entity.id.toString() };
}
}
```
#### 4. Infrastructure Layer
Implement the repository:
```typescript
// src/infrastructure/persistence/repositories/new-entity.repository.impl.ts
@Injectable()
export class NewEntityRepositoryImpl implements NewEntityRepository {
constructor(private readonly prisma: PrismaService) {}
async save(entity: NewEntity): Promise<NewEntity> {
const data = await this.prisma.newEntity.create({
data: this.toDatabase(entity),
});
return this.toDomain(data);
}
// Mapping methods
private toDatabase(entity: NewEntity) { /* ... */ }
private toDomain(data: PrismaModel) { /* ... */ }
}
```
#### 5. API Layer
Create controller and DTOs:
```typescript
// src/api/dto/request/create-new-entity.dto.ts
export class CreateNewEntityDto {
@IsNotEmpty()
@IsString()
data: string;
}
// src/api/controllers/new-entity.controller.ts
@Controller('new-entity')
@UseGuards(ServiceAuthGuard)
export class NewEntityController {
constructor(
private readonly service: NewEntityApplicationService,
) {}
@Post()
async create(@Body() dto: CreateNewEntityDto) {
return this.service.create(dto);
}
}
```
---
## Environment Variables
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://...` |
| `SERVICE_JWT_SECRET` | JWT secret (min 32 chars) | `your-secret-key...` |
| `ALLOWED_SERVICES` | Comma-separated service list | `identity-service,recovery-service` |
| `BACKUP_ENCRYPTION_KEY` | 256-bit key in hex (64 chars) | `0123456789abcdef...` |
| `BACKUP_ENCRYPTION_KEY_ID` | Key identifier for rotation | `key-v1` |
### Optional Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_PORT` | `3002` | Server port |
| `APP_ENV` | `development` | Environment mode |
| `MAX_RETRIEVE_PER_DAY` | `3` | Rate limit for retrieves |
| `MAX_STORE_PER_MINUTE` | `10` | Rate limit for stores |
| `AUDIT_LOG_RETENTION_DAYS` | `365` | Audit log retention |
---
## Database Management
### Prisma CLI Commands
```bash
# Generate Prisma client after schema changes
npx prisma generate
# Create a new migration
npx prisma migrate dev --name migration_name
# Apply migrations in production
npx prisma migrate deploy
# Reset database (development only)
npx prisma migrate reset
# Open Prisma Studio
npx prisma studio
# Push schema without migrations (development)
npx prisma db push
```
### Schema Changes Workflow
1. Modify `prisma/schema.prisma`
2. Create migration: `npx prisma migrate dev --name descriptive_name`
3. Test locally
4. Commit migration files
5. Apply in staging/production: `npx prisma migrate deploy`
---
## Debugging
### VSCode Launch Configuration
Add to `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Nest.js",
"runtimeArgs": [
"-r",
"ts-node/register",
"-r",
"tsconfig-paths/register"
],
"args": ["${workspaceFolder}/src/main.ts"],
"envFile": "${workspaceFolder}/.env"
},
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--config", "jest.config.js"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
```
### Debug Logging
Enable verbose logging:
```typescript
// In main.ts or app.module.ts
app.useLogger(['log', 'error', 'warn', 'debug', 'verbose']);
```
---
## Code Style
### TypeScript Configuration
The project uses strict TypeScript settings:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
```
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Files | kebab-case | `backup-share.entity.ts` |
| Classes | PascalCase | `BackupShareEntity` |
| Interfaces | PascalCase with I prefix | `IBackupShareRepository` |
| Functions | camelCase | `storeBackupShare()` |
| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| Environment | UPPER_SNAKE_CASE | `DATABASE_URL` |
### File Organization
```
feature/
├── feature.command.ts # Command object
├── feature.handler.ts # Command handler
├── feature.spec.ts # Unit tests
└── index.ts # Barrel export
```
---
## Common Development Tasks
### Adding a New Endpoint
1. Create DTO in `src/api/dto/request/`
2. Add validation decorators
3. Create handler in `src/application/commands/` or `queries/`
4. Add method to controller
5. Write unit tests
6. Write E2E tests
### Adding a New Environment Variable
1. Add to `.env.example` with description
2. Add to `src/config/index.ts`
3. Update this documentation
4. Update deployment configs
### Updating Database Schema
1. Modify `prisma/schema.prisma`
2. Run `npx prisma migrate dev --name description`
3. Update domain entities if needed
4. Update repository mappings
5. Write migration tests
---
## Troubleshooting
### Common Issues
#### Prisma Client Not Generated
```bash
Error: @prisma/client did not initialize yet
```
**Solution:**
```bash
npm run prisma:generate
```
#### Database Connection Failed
```bash
Error: Can't reach database server
```
**Solution:**
1. Check if Docker is running: `docker ps`
2. Check DATABASE_URL in `.env`
3. Restart database: `docker-compose restart backup-db`
#### Port Already in Use
```bash
Error: listen EADDRINUSE: address already in use :::3002
```
**Solution:**
```bash
# Find and kill the process
netstat -ano | findstr :3002
taskkill /PID <PID> /F
```
#### TypeScript Compilation Errors
```bash
# Clear build cache
rm -rf dist
npm run build
```
### Getting Help
- Check existing issues on GitHub
- Review NestJS documentation
- Review Prisma documentation
- Ask in team Slack channel
---
## Best Practices
### Security
1. Never commit secrets to git
2. Use environment variables for all configs
3. Validate all inputs with class-validator
4. Sanitize logs (no sensitive data)
5. Use parameterized queries (Prisma handles this)
### Performance
1. Use database indexes for frequently queried fields
2. Implement pagination for list endpoints
3. Use connection pooling (Prisma default)
4. Cache frequently accessed data if needed
### Code Quality
1. Write unit tests for all handlers
2. Write E2E tests for all endpoints
3. Use TypeScript strict mode
4. Follow DDD principles
5. Keep controllers thin, logic in handlers

View File

@ -0,0 +1,77 @@
# Backup Service Documentation
Welcome to the backup-service documentation. This service is responsible for securely storing MPC backup shares (Party 2/3) for the RWA Durian platform.
## Documentation Index
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | DDD + Hexagonal architecture, design patterns, directory structure, domain layer details |
| [API.md](./API.md) | API endpoints reference, authentication, request/response formats, SDK examples |
| [DEVELOPMENT.md](./DEVELOPMENT.md) | Development setup, environment configuration, adding features, debugging |
| [TESTING.md](./TESTING.md) | Unit tests, E2E tests, test utilities, running tests, writing good tests |
| [DEPLOYMENT.md](./DEPLOYMENT.md) | Docker, Kubernetes deployment, environment variables, security, monitoring |
## Quick Links
### Getting Started
1. [Development Setup](./DEVELOPMENT.md#quick-start)
2. [Environment Variables](./DEVELOPMENT.md#environment-variables)
3. [Running Tests](./TESTING.md#running-tests)
### API Reference
1. [Store Backup Share](./API.md#1-store-backup-share)
2. [Retrieve Backup Share](./API.md#2-retrieve-backup-share)
3. [Revoke Backup Share](./API.md#3-revoke-backup-share)
4. [Health Endpoints](./API.md#4-health-check)
### Architecture
1. [Hexagonal Architecture](./ARCHITECTURE.md#ddd--hexagonal-architecture)
2. [Domain Layer](./ARCHITECTURE.md#domain-layer-details)
3. [Database Schema](./ARCHITECTURE.md#database-schema)
4. [Key Decisions](./ARCHITECTURE.md#key-architectural-decisions)
### Deployment
1. [Docker Deployment](./DEPLOYMENT.md#docker-deployment)
2. [Kubernetes Deployment](./DEPLOYMENT.md#kubernetes-deployment)
3. [Security Considerations](./DEPLOYMENT.md#security-considerations)
## Service Overview
**Purpose:** Securely store and manage MPC backup shares (Party 2) for account recovery
**Key Features:**
- Double encryption (AES-256-GCM)
- Service-to-service JWT authentication
- Rate limiting (3 retrieves per user per day)
- Comprehensive audit logging
- Physical server isolation from identity-service
**Technology Stack:**
- NestJS 11.x (TypeScript)
- Prisma 7.x ORM
- PostgreSQL 15
- Docker / Kubernetes
## Test Summary
| Category | Tests |
|----------|-------|
| Unit Tests | 37 |
| Mock E2E Tests | 21 |
| Real DB E2E Tests | 20 |
| **Total** | **78** |
## Critical Security Note
The backup-service MUST be deployed on a **physically separate server** from identity-service. This is mandatory for maintaining MPC security:
- Party 0 (Server Share): identity-service (Server A)
- Party 1 (Client Share): User device
- Party 2 (Backup Share): backup-service (Server B)
If only one server is compromised, attackers can only obtain 1 of 3 shares, making key reconstruction impossible (2-of-3 threshold).

View File

@ -0,0 +1,809 @@
# Backup Service Testing Guide
## Overview
The backup-service implements a comprehensive testing strategy with three levels:
1. **Unit Tests** - Test individual components in isolation
2. **Integration Tests** - Test component interactions with real database
3. **E2E Tests** - Test complete API workflows
---
## Test Structure
```
test/
├── unit/ # Unit tests (37 tests)
│ ├── api/
│ │ ├── backup-share.controller.spec.ts
│ │ └── health.controller.spec.ts
│ ├── application/
│ │ ├── store-backup-share.handler.spec.ts
│ │ ├── get-backup-share.handler.spec.ts
│ │ └── revoke-share.handler.spec.ts
│ ├── domain/
│ │ ├── backup-share.entity.spec.ts
│ │ └── value-objects.spec.ts
│ ├── infrastructure/
│ │ └── aes-encryption.service.spec.ts
│ └── shared/
│ ├── audit-log.interceptor.spec.ts
│ ├── global-exception.filter.spec.ts
│ └── service-auth.guard.spec.ts
├── integration/ # Integration tests
│ ├── backup-share-repository.integration.spec.ts
│ └── audit-log-repository.integration.spec.ts
├── e2e/ # E2E tests (20 tests)
│ ├── backup-share.e2e-spec.ts # Real database
│ └── backup-share-mock.e2e-spec.ts # Mocked services
├── setup/ # Test infrastructure
│ ├── global-setup.ts
│ ├── global-teardown.ts
│ ├── jest-e2e-setup.ts
│ ├── jest-mock-setup.ts
│ └── test-database.helper.ts
└── utils/ # Test utilities
├── mock-prisma.service.ts
└── test-utils.ts
```
---
## Running Tests
### Quick Commands
```bash
# Run all unit tests
npm run test:unit
# Run E2E tests with mocked services (fast)
npm run test:e2e:mock
# Run E2E tests with real database
npm run test:e2e:db
# Run all tests (unit + mock E2E)
npm run test:all
# Generate coverage report
npm run test:cov
# Watch mode for development
npm run test:watch
```
### Test Configurations
| Config File | Purpose | Command |
|------------|---------|---------|
| `jest.config.js` | Unit tests | `npm run test:unit` |
| `test/jest-e2e-mock.json` | E2E with mocks | `npm run test:e2e:mock` |
| `test/jest-e2e-db.json` | E2E with real DB | `npm run test:e2e:db` |
---
## Unit Testing
### Philosophy
- Test each component in isolation
- Mock all dependencies
- Focus on business logic and edge cases
- Fast execution (< 5 seconds total)
### Example: Testing a Handler
```typescript
// test/unit/application/store-backup-share.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { StoreBackupShareHandler } from '../../../src/application/commands/store-backup-share/store-backup-share.handler';
import { StoreBackupShareCommand } from '../../../src/application/commands/store-backup-share/store-backup-share.command';
import { BackupShareRepository } from '../../../src/domain/repositories/backup-share.repository.interface';
import { AesEncryptionService } from '../../../src/infrastructure/crypto/aes-encryption.service';
import { AuditLogRepository } from '../../../src/infrastructure/persistence/repositories/audit-log.repository';
describe('StoreBackupShareHandler', () => {
let handler: StoreBackupShareHandler;
let mockRepository: jest.Mocked<BackupShareRepository>;
let mockEncryptionService: jest.Mocked<AesEncryptionService>;
let mockAuditLogRepository: jest.Mocked<AuditLogRepository>;
beforeEach(async () => {
mockRepository = {
save: jest.fn(),
findByUserId: jest.fn(),
findByPublicKey: jest.fn(),
// ... other methods
};
mockEncryptionService = {
encrypt: jest.fn().mockResolvedValue({
encrypted: 'encrypted-data',
keyId: 'key-v1',
}),
};
mockAuditLogRepository = {
log: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
StoreBackupShareHandler,
{ provide: 'BackupShareRepository', useValue: mockRepository },
{ provide: AesEncryptionService, useValue: mockEncryptionService },
{ provide: AuditLogRepository, useValue: mockAuditLogRepository },
],
}).compile();
handler = module.get<StoreBackupShareHandler>(StoreBackupShareHandler);
});
describe('execute', () => {
it('should store backup share successfully', async () => {
// Arrange
const command = new StoreBackupShareCommand(
'12345',
1001,
'02' + 'a'.repeat(64),
'encrypted-share-data',
'identity-service',
'127.0.0.1'
);
mockRepository.findByUserId.mockResolvedValue(null);
mockRepository.findByPublicKey.mockResolvedValue(null);
mockRepository.save.mockResolvedValue({
shareId: BigInt(1),
// ... other fields
});
// Act
const result = await handler.execute(command);
// Assert
expect(result.shareId).toBe('1');
expect(mockRepository.save).toHaveBeenCalled();
expect(mockEncryptionService.encrypt).toHaveBeenCalledWith('encrypted-share-data');
expect(mockAuditLogRepository.log).toHaveBeenCalled();
});
it('should throw error if share already exists for user', async () => {
// Arrange
const command = new StoreBackupShareCommand(/*...*/);
mockRepository.findByUserId.mockResolvedValue({ /* existing share */ });
// Act & Assert
await expect(handler.execute(command)).rejects.toThrow('SHARE_ALREADY_EXISTS');
});
});
});
```
### Example: Testing a Controller
```typescript
// test/unit/api/backup-share.controller.spec.ts
describe('BackupShareController', () => {
let controller: BackupShareController;
let mockService: jest.Mocked<BackupShareApplicationService>;
beforeEach(async () => {
mockService = {
storeBackupShare: jest.fn(),
getBackupShare: jest.fn(),
revokeShare: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [BackupShareController],
providers: [
{ provide: BackupShareApplicationService, useValue: mockService },
],
}).compile();
controller = module.get<BackupShareController>(BackupShareController);
});
describe('storeShare', () => {
it('should return success response', async () => {
// Arrange
const dto: StoreShareDto = {
userId: '12345',
accountSequence: 1001,
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'test-data',
};
const mockRequest = {
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
};
mockService.storeBackupShare.mockResolvedValue({ shareId: '1' });
// Act
const result = await controller.storeShare(dto, mockRequest);
// Assert
expect(result.success).toBe(true);
expect(result.shareId).toBe('1');
});
});
});
```
### Example: Testing Domain Entity
```typescript
// test/unit/domain/backup-share.entity.spec.ts
describe('BackupShare Entity', () => {
describe('create', () => {
it('should create a valid backup share', () => {
const share = BackupShare.create({
userId: BigInt(12345),
accountSequence: BigInt(1001),
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-data',
encryptionKeyId: 'key-v1',
});
expect(share.userId).toBe(BigInt(12345));
expect(share.status).toBe(BackupShareStatus.ACTIVE);
expect(share.partyIndex).toBe(2);
expect(share.accessCount).toBe(0);
});
it('should throw error for invalid public key length', () => {
expect(() => BackupShare.create({
// ... with short publicKey
publicKey: 'short',
})).toThrow();
});
});
describe('revoke', () => {
it('should mark share as revoked', () => {
const share = BackupShare.create({/*...*/});
share.revoke('ROTATION');
expect(share.status).toBe(BackupShareStatus.REVOKED);
expect(share.revokedAt).toBeDefined();
});
});
describe('recordAccess', () => {
it('should increment access count', () => {
const share = BackupShare.create({/*...*/});
share.recordAccess();
share.recordAccess();
expect(share.accessCount).toBe(2);
expect(share.lastAccessedAt).toBeDefined();
});
});
});
```
---
## E2E Testing
### Mock E2E Tests
Fast tests using mocked Prisma service:
```typescript
// test/e2e/backup-share-mock.e2e-spec.ts
describe('BackupShare E2E (Mocked)', () => {
let app: INestApplication;
let mockPrismaService: MockPrismaService;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useClass(MockPrismaService)
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
mockPrismaService = app.get(MockPrismaService);
});
it('should store backup share successfully', async () => {
mockPrismaService.backupShare.findUnique.mockResolvedValue(null);
mockPrismaService.backupShare.create.mockResolvedValue({
shareId: BigInt(1),
// ... mock data
});
const response = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', generateServiceToken('identity-service'))
.send({
userId: '12345',
accountSequence: 1001,
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'test-data',
})
.expect(201);
expect(response.body.success).toBe(true);
});
});
```
### Real Database E2E Tests
Tests with actual PostgreSQL database:
```typescript
// test/e2e/backup-share.e2e-spec.ts
describe('BackupShare E2E (Real Database)', () => {
let app: INestApplication;
let prisma: PrismaService;
let serviceToken: string;
beforeAll(async () => {
process.env.SERVICE_JWT_SECRET = TEST_SERVICE_JWT_SECRET;
const moduleFixture = 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
await prisma.shareAccessLog.deleteMany();
await prisma.backupShare.deleteMany();
});
afterAll(async () => {
await app?.close();
});
describe('Complete Workflow', () => {
it('should complete full lifecycle: store -> retrieve -> revoke', async () => {
const publicKey = generatePublicKey('x');
const payload = 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(payload)
.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);
});
});
});
```
---
## Test Utilities
### test-utils.ts
```typescript
// test/utils/test-utils.ts
import jwt from 'jsonwebtoken';
export const TEST_SERVICE_JWT_SECRET = 'test-super-secret-service-jwt-key-for-e2e-testing';
export const TEST_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
export const TEST_ENCRYPTION_KEY_ID = 'test-key-v1';
// Generate service JWT token
export function generateServiceToken(
service: string,
secret: string = TEST_SERVICE_JWT_SECRET,
expiresIn: string = '1h'
): string {
return jwt.sign({ service }, secret, { expiresIn });
}
// Generate expired token for testing
export function generateExpiredServiceToken(
service: string,
secret: string = TEST_SERVICE_JWT_SECRET
): string {
return jwt.sign({ service }, secret, { expiresIn: '-1h' });
}
// Generate valid public key (66 chars for compressed)
export function generatePublicKey(prefix: string = 'a'): string {
return '02' + prefix.repeat(64);
}
// Create store share payload with defaults
export function createStoreSharePayload(overrides: Partial<StoreShareDto> = {}): StoreShareDto {
return {
userId: '12345',
accountSequence: 1001,
publicKey: generatePublicKey('a'),
encryptedShareData: 'test-encrypted-share-data',
...overrides,
};
}
// Create retrieve share payload with defaults
export function createRetrieveSharePayload(overrides: Partial<RetrieveShareDto> = {}): RetrieveShareDto {
return {
userId: '12345',
publicKey: generatePublicKey('a'),
recoveryToken: 'valid-recovery-token',
...overrides,
};
}
// Create revoke share payload with defaults
export function createRevokeSharePayload(overrides: Partial<RevokeShareDto> = {}): RevokeShareDto {
return {
userId: '12345',
publicKey: generatePublicKey('a'),
reason: 'ROTATION',
...overrides,
};
}
```
### Mock Prisma Service
```typescript
// test/utils/mock-prisma.service.ts
export class MockPrismaService {
backupShare = {
create: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
};
shareAccessLog = {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
deleteMany: jest.fn(),
};
$connect = jest.fn();
$disconnect = jest.fn();
}
```
---
## Running E2E Tests with Real Database
### Using WSL (Windows)
```bash
# 1. Start test database in WSL
wsl docker run -d \
--name backup-service-test-db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=testpassword \
-e POSTGRES_DB=rwa_backup_test \
-p 5434:5432 \
postgres:15-alpine
# 2. Wait for database to be ready
wsl docker logs -f backup-service-test-db
# 3. Run tests (from Windows)
npm run test:e2e:db
# 4. Cleanup
wsl docker stop backup-service-test-db
wsl docker rm backup-service-test-db
```
### Using Docker Compose
```bash
# Start test database
npm run db:test:up
# Run tests
npm run test:e2e:db
# Stop and cleanup
npm run db:test:down
```
### Manual Setup
```bash
# 1. Set environment variables
export DATABASE_URL="postgresql://postgres:testpassword@localhost:5434/rwa_backup_test?schema=public"
export APP_ENV=test
export SERVICE_JWT_SECRET="test-super-secret-service-jwt-key-for-e2e-testing"
export BACKUP_ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
export BACKUP_ENCRYPTION_KEY_ID="test-key-v1"
# 2. Push schema to test database
npx prisma db push
# 3. Run tests
npm run test:e2e:db
```
---
## Test Coverage
### Current Coverage
| Category | Files | Tests |
|----------|-------|-------|
| Unit Tests | 10 | 37 |
| Mock E2E Tests | 1 | 21 |
| Real DB E2E Tests | 1 | 20 |
| **Total** | **12** | **78** |
### Coverage Report
```bash
# Generate coverage report
npm run test:cov
# View coverage in browser
open coverage/lcov-report/index.html
```
### Coverage Configuration
```json
// In package.json
"jest": {
"coveragePathIgnorePatterns": [
"/node_modules/",
".module.ts",
"main.ts"
],
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.module.ts",
"!src/main.ts"
]
}
```
---
## Writing Good Tests
### Do's
1. **Test behavior, not implementation**
```typescript
// Good: Tests what the method does
it('should encrypt share data before storing', async () => {
await handler.execute(command);
expect(mockEncryption.encrypt).toHaveBeenCalledWith('original-data');
});
// Bad: Tests internal implementation details
it('should call private method _processData', () => { /* ... */ });
```
2. **Use descriptive test names**
```typescript
// Good
it('should return 401 when service token is missing', () => {});
it('should increment access count on each retrieve', () => {});
// Bad
it('test1', () => {});
it('works correctly', () => {});
```
3. **Arrange-Act-Assert pattern**
```typescript
it('should store backup share successfully', async () => {
// Arrange
const command = createStoreCommand();
mockRepository.findByUserId.mockResolvedValue(null);
// Act
const result = await handler.execute(command);
// Assert
expect(result.shareId).toBeDefined();
});
```
4. **One assertion per test (when possible)**
```typescript
// Good: Focused tests
it('should return success true', () => { /* ... */ });
it('should return the share ID', () => { /* ... */ });
// Acceptable for E2E: Multiple assertions for a workflow
it('should complete full lifecycle', () => { /* ... */ });
```
### Don'ts
1. **Don't test external libraries**
```typescript
// Bad: Testing that jwt.sign works
it('should sign JWT token', () => {
const token = jwt.sign({}, 'secret');
expect(jwt.verify(token, 'secret')).toBeDefined();
});
```
2. **Don't share state between tests**
```typescript
// Bad: Tests depend on each other
let sharedData;
it('test 1', () => { sharedData = 'value'; });
it('test 2', () => { expect(sharedData).toBe('value'); });
```
3. **Don't make tests slow**
```typescript
// Bad: Real network calls
it('should fetch data', async () => {
const result = await fetch('https://api.example.com');
});
// Good: Mock external services
it('should fetch data', async () => {
mockFetch.mockResolvedValue({ data: 'test' });
});
```
---
## CI/CD Integration
### GitHub Actions Example
```yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:unit
e2e-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: rwa_backup_test
ports:
- 5434:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx prisma generate
- run: npx prisma db push
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5434/rwa_backup_test
- run: npm run test:e2e:db
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5434/rwa_backup_test
SERVICE_JWT_SECRET: test-secret
BACKUP_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
BACKUP_ENCRYPTION_KEY_ID: test-key-v1
```
---
## Debugging Tests
### VSCode Debug Configuration
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [
"--runInBand",
"--no-cache",
"${fileBasenameNoExtension}"
],
"console": "integratedTerminal"
}
]
}
```
### Running Single Test
```bash
# Run specific test file
npm test -- backup-share.entity.spec.ts
# Run tests matching pattern
npm test -- --testNamePattern="should store"
# Run with verbose output
npm test -- --verbose
```

View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,106 @@
{
"name": "backup-service",
"version": "1.0.0",
"description": "RWA Durian MPC Backup Share Storage Service",
"author": "RWA Durian Team",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e-mock.json",
"test:e2e:mock": "jest --config ./test/jest-e2e-mock.json",
"test:e2e:db": "jest --config ./test/jest-e2e-db.json",
"test:unit": "jest --testPathPatterns=test/unit",
"test:all": "npm run test:unit && npm run test:e2e:mock",
"db:test:up": "docker-compose -f docker-compose.test.yml up -d",
"db:test:down": "docker-compose -f docker-compose.test.yml down -v",
"db:test:setup": "npx ts-node scripts/setup-test-db.ts",
"db:test:migrate": "npx dotenv -e .env.test -- prisma migrate deploy",
"test:e2e:db:manual": "set USE_DOCKER=false && jest --config ./test/jest-e2e-db.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"docker:build": "docker build -t backup-service .",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-pg": "^7.0.1",
"@prisma/client": "^7.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.7",
"@types/pg": "^8.15.6",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"dotenv-cli": "^11.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"pg": "^8.16.3",
"prettier": "^3.4.2",
"prisma": "^7.0.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"src/**/*.(t|j)s",
"!src/main.ts",
"!src/**/*.module.ts"
],
"coverageDirectory": "./coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
}
}
}

View File

@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@ -0,0 +1,69 @@
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// 备份分片存储
model BackupShare {
shareId BigInt @id @default(autoincrement()) @map("share_id")
// 用户标识 (来自 identity-service)
userId BigInt @unique @map("user_id")
accountSequence BigInt @unique @map("account_sequence")
// MPC 密钥信息
publicKey String @unique @map("public_key") @db.VarChar(130)
partyIndex Int @default(2) @map("party_index") // Backup = Party 2
threshold Int @default(2)
totalParties Int @default(3) @map("total_parties")
// 加密的分片数据 (AES-256-GCM 加密)
encryptedShareData String @map("encrypted_share_data") @db.Text
encryptionKeyId String @map("encryption_key_id") @db.VarChar(64) // 密钥轮换支持
// 状态管理
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, ROTATED
// 访问控制
accessCount Int @default(0) @map("access_count") // 访问次数限制
lastAccessedAt DateTime? @map("last_accessed_at")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
revokedAt DateTime? @map("revoked_at")
// 索引
@@index([publicKey], name: "idx_backup_public_key")
@@index([status], name: "idx_backup_status")
@@index([createdAt], name: "idx_backup_created")
@@map("backup_shares")
}
// 访问审计日志
model ShareAccessLog {
logId BigInt @id @default(autoincrement()) @map("log_id")
shareId BigInt @map("share_id")
userId BigInt @map("user_id")
action String @db.VarChar(20) // STORE, RETRIEVE, REVOKE, ROTATE
sourceService String @map("source_service") @db.VarChar(50) // identity-service, recovery-service
sourceIp String @map("source_ip") @db.VarChar(45)
success Boolean @default(true)
errorMessage String? @map("error_message") @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([shareId], name: "idx_log_share")
@@index([userId], name: "idx_log_user")
@@index([action], name: "idx_log_action")
@@index([createdAt], name: "idx_log_created")
@@map("share_access_logs")
}

View File

@ -0,0 +1,89 @@
/**
* Manual Test Database Setup Script
*
* This script helps set up the test database when Docker is not available.
* It can connect to any PostgreSQL instance and prepare it for E2E testing.
*
* Usage:
* npx ts-node scripts/setup-test-db.ts
*
* Environment variables (or set in .env.test):
* DATABASE_URL - PostgreSQL connection string
*/
import { execSync } from 'child_process';
import * as path from 'path';
import * as dotenv from 'dotenv';
// Load test environment
dotenv.config({ path: path.resolve(__dirname, '../.env.test') });
async function main() {
console.log('🔧 Manual Test Database Setup');
console.log('============================\n');
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('❌ DATABASE_URL environment variable is not set');
console.log('\nPlease set DATABASE_URL in .env.test or as an environment variable');
console.log('Example: DATABASE_URL="postgresql://postgres:password@localhost:5432/rwa_backup_test"');
process.exit(1);
}
console.log(`📌 Database URL: ${databaseUrl.replace(/:[^:@]+@/, ':****@')}\n`);
// Test database connection
console.log('⏳ Testing database connection...');
try {
const { Client } = await import('pg');
const client = new Client({ connectionString: databaseUrl });
await client.connect();
const result = await client.query('SELECT version()');
console.log(`✅ Connected to PostgreSQL: ${result.rows[0].version.split(',')[0]}\n`);
await client.end();
} catch (error: any) {
console.error(`❌ Failed to connect to database: ${error.message}`);
console.log('\nMake sure:');
console.log(' 1. PostgreSQL is running');
console.log(' 2. The database exists');
console.log(' 3. The credentials are correct');
process.exit(1);
}
// Push Prisma schema
console.log('🔄 Pushing Prisma schema to database...');
try {
execSync('npx prisma db push --force-reset --accept-data-loss', {
cwd: path.resolve(__dirname, '..'),
stdio: 'inherit',
env: {
...process.env,
DATABASE_URL: databaseUrl,
},
});
console.log('');
} catch (error) {
console.error('❌ Failed to push Prisma schema');
process.exit(1);
}
// Generate Prisma client
console.log('🔧 Generating Prisma client...');
try {
execSync('npx prisma generate', {
cwd: path.resolve(__dirname, '..'),
stdio: 'inherit',
});
console.log('');
} catch (error) {
console.error('❌ Failed to generate Prisma client');
process.exit(1);
}
console.log('✅ Test database setup complete!\n');
console.log('You can now run E2E tests with:');
console.log(' USE_DOCKER=false npm run test:e2e:db\n');
}
main().catch(console.error);

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ApplicationModule } from '../application/application.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { BackupShareController } from './controllers/backup-share.controller';
import { HealthController } from './controllers/health.controller';
import { ServiceAuthGuard } from '../shared/guards/service-auth.guard';
@Module({
imports: [ConfigModule, ApplicationModule, InfrastructureModule],
controllers: [BackupShareController, HealthController],
providers: [ServiceAuthGuard],
})
export class ApiModule {}

View File

@ -0,0 +1,103 @@
import {
Controller,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { BackupShareApplicationService } from '../../application/services/backup-share-application.service';
import { StoreBackupShareCommand } from '../../application/commands/store-backup-share/store-backup-share.command';
import { RevokeShareCommand } from '../../application/commands/revoke-share/revoke-share.command';
import { GetBackupShareQuery } from '../../application/queries/get-backup-share/get-backup-share.query';
import { ServiceAuthGuard } from '../../shared/guards/service-auth.guard';
import { StoreShareDto } from '../dto/request/store-share.dto';
import { RetrieveShareDto } from '../dto/request/retrieve-share.dto';
import { RevokeShareDto } from '../dto/request/revoke-share.dto';
import {
StoreShareResponseDto,
RetrieveShareResponseDto,
RevokeShareResponseDto,
} from '../dto/response/share-info.dto';
@Controller('backup-share')
@UseGuards(ServiceAuthGuard)
export class BackupShareController {
constructor(
private readonly backupShareService: BackupShareApplicationService,
) {}
@Post('store')
@HttpCode(HttpStatus.CREATED)
async storeShare(
@Body() dto: StoreShareDto,
@Req() request: any,
): Promise<StoreShareResponseDto> {
const command = new StoreBackupShareCommand(
dto.userId,
dto.accountSequence,
dto.publicKey,
dto.encryptedShareData,
request.sourceService,
request.sourceIp,
dto.threshold,
dto.totalParties,
);
const result = await this.backupShareService.storeBackupShare(command);
return {
success: true,
shareId: result.shareId,
message: 'Backup share stored successfully',
};
}
@Post('retrieve')
@HttpCode(HttpStatus.OK)
async retrieveShare(
@Body() dto: RetrieveShareDto,
@Req() request: any,
): Promise<RetrieveShareResponseDto> {
const query = new GetBackupShareQuery(
dto.userId,
dto.publicKey,
dto.recoveryToken,
request.sourceService,
request.sourceIp,
dto.deviceId,
);
const result = await this.backupShareService.getBackupShare(query);
return {
success: true,
encryptedShareData: result.encryptedShareData,
partyIndex: result.partyIndex,
publicKey: result.publicKey,
};
}
@Post('revoke')
@HttpCode(HttpStatus.OK)
async revokeShare(
@Body() dto: RevokeShareDto,
@Req() request: any,
): Promise<RevokeShareResponseDto> {
const command = new RevokeShareCommand(
dto.userId,
dto.publicKey,
dto.reason,
request.sourceService,
request.sourceIp,
);
await this.backupShareService.revokeShare(command);
return {
success: true,
message: 'Backup share revoked successfully',
};
}
}

View File

@ -0,0 +1,44 @@
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'backup-service',
};
}
@Get('ready')
async readiness() {
try {
// Check database connectivity
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ready',
database: 'connected',
timestamp: new Date().toISOString(),
};
} catch (error) {
return {
status: 'not ready',
database: 'disconnected',
error: error.message,
timestamp: new Date().toISOString(),
};
}
}
@Get('live')
async liveness() {
return {
status: 'alive',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,25 @@
import {
IsNotEmpty,
IsString,
IsOptional,
Length,
} from 'class-validator';
export class RetrieveShareDto {
@IsNotEmpty()
@IsString()
userId: string;
@IsNotEmpty()
@IsString()
@Length(66, 130)
publicKey: string;
@IsNotEmpty()
@IsString()
recoveryToken: string;
@IsOptional()
@IsString()
deviceId?: string;
}

View File

@ -0,0 +1,22 @@
import {
IsNotEmpty,
IsString,
IsIn,
Length,
} from 'class-validator';
export class RevokeShareDto {
@IsNotEmpty()
@IsString()
userId: string;
@IsNotEmpty()
@IsString()
@Length(66, 130)
publicKey: string;
@IsNotEmpty()
@IsString()
@IsIn(['ROTATION', 'ACCOUNT_CLOSED', 'SECURITY_BREACH', 'USER_REQUEST'])
reason: string;
}

View File

@ -0,0 +1,41 @@
import {
IsNotEmpty,
IsString,
IsNumber,
IsOptional,
Min,
Max,
Length,
} from 'class-validator';
export class StoreShareDto {
@IsNotEmpty()
@IsString()
userId: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
accountSequence: number;
@IsNotEmpty()
@IsString()
@Length(66, 130) // Compressed or uncompressed public key
publicKey: string;
@IsNotEmpty()
@IsString()
encryptedShareData: string;
@IsOptional()
@IsNumber()
@Min(2)
@Max(10)
threshold?: number;
@IsOptional()
@IsNumber()
@Min(2)
@Max(10)
totalParties?: number;
}

View File

@ -0,0 +1,24 @@
export class StoreShareResponseDto {
success: boolean;
shareId: string;
message: string;
}
export class RetrieveShareResponseDto {
success: boolean;
encryptedShareData: string;
partyIndex: number;
publicKey: string;
}
export class RevokeShareResponseDto {
success: boolean;
message: string;
}
export class ErrorResponseDto {
success: boolean;
error: string;
code: string;
timestamp: string;
}

View File

@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { ApiModule } from './api/api.module';
import { ApplicationModule } from './application/application.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { DomainModule } from './domain/domain.module';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
import { AuditLogInterceptor } from './shared/interceptors/audit-log.interceptor';
import configuration from './config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
envFilePath: ['.env', '.env.development', '.env.local'],
}),
DomainModule,
InfrastructureModule,
ApplicationModule,
ApiModule,
],
providers: [
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
{
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { StoreBackupShareHandler } from './commands/store-backup-share/store-backup-share.handler';
import { RevokeShareHandler } from './commands/revoke-share/revoke-share.handler';
import { GetBackupShareHandler } from './queries/get-backup-share/get-backup-share.handler';
import { BackupShareApplicationService } from './services/backup-share-application.service';
@Module({
imports: [ConfigModule, InfrastructureModule],
providers: [
StoreBackupShareHandler,
RevokeShareHandler,
GetBackupShareHandler,
BackupShareApplicationService,
],
exports: [BackupShareApplicationService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,9 @@
export class RevokeShareCommand {
constructor(
public readonly userId: string,
public readonly publicKey: string,
public readonly reason: string,
public readonly sourceService: string,
public readonly sourceIp: string,
) {}
}

View File

@ -0,0 +1,54 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { RevokeShareCommand } from './revoke-share.command';
import { BACKUP_SHARE_REPOSITORY } from '../../../domain';
import type { BackupShareRepository } from '../../../domain';
import { AuditLogRepository } from '../../../infrastructure/persistence/repositories/audit-log.repository';
import { ApplicationError } from '../../errors/application.error';
@Injectable()
export class RevokeShareHandler {
private readonly logger = new Logger(RevokeShareHandler.name);
constructor(
@Inject(BACKUP_SHARE_REPOSITORY)
private readonly repository: BackupShareRepository,
private readonly auditLogRepository: AuditLogRepository,
) {}
async execute(command: RevokeShareCommand): Promise<void> {
this.logger.log(`Revoking backup share for user: ${command.userId}`);
const userId = BigInt(command.userId);
// Find share
const share = await this.repository.findByUserIdAndPublicKey(
userId,
command.publicKey,
);
if (!share) {
throw new ApplicationError(
'Backup share not found',
'SHARE_NOT_FOUND',
);
}
// Revoke the share
share.revoke(command.reason);
// Save changes
await this.repository.save(share);
// Log audit
await this.auditLogRepository.log({
shareId: share.shareId!,
userId,
action: 'REVOKE',
sourceService: command.sourceService,
sourceIp: command.sourceIp,
success: true,
});
this.logger.log(`Backup share revoked: shareId=${share.shareId}, reason=${command.reason}`);
}
}

View File

@ -0,0 +1,12 @@
export class StoreBackupShareCommand {
constructor(
public readonly userId: string,
public readonly accountSequence: number,
public readonly publicKey: string,
public readonly encryptedShareData: string,
public readonly sourceService: string,
public readonly sourceIp: string,
public readonly threshold?: number,
public readonly totalParties?: number,
) {}
}

View File

@ -0,0 +1,83 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { StoreBackupShareCommand } from './store-backup-share.command';
import { BackupShare, BACKUP_SHARE_REPOSITORY } from '../../../domain';
import type { BackupShareRepository } from '../../../domain';
import { AesEncryptionService } from '../../../infrastructure/crypto/aes-encryption.service';
import { AuditLogRepository } from '../../../infrastructure/persistence/repositories/audit-log.repository';
import { ApplicationError } from '../../errors/application.error';
export interface StoreBackupShareResult {
shareId: string;
}
@Injectable()
export class StoreBackupShareHandler {
private readonly logger = new Logger(StoreBackupShareHandler.name);
constructor(
@Inject(BACKUP_SHARE_REPOSITORY)
private readonly repository: BackupShareRepository,
private readonly encryptionService: AesEncryptionService,
private readonly auditLogRepository: AuditLogRepository,
) {}
async execute(command: StoreBackupShareCommand): Promise<StoreBackupShareResult> {
this.logger.log(`Storing backup share for user: ${command.userId}`);
const userId = BigInt(command.userId);
// Check if share already exists for this user
const existing = await this.repository.findByUserId(userId);
if (existing) {
throw new ApplicationError(
'Backup share already exists for this user',
'SHARE_ALREADY_EXISTS',
);
}
// Check if share already exists for this public key
const existingByPubKey = await this.repository.findByPublicKey(command.publicKey);
if (existingByPubKey) {
throw new ApplicationError(
'Backup share already exists for this public key',
'SHARE_ALREADY_EXISTS',
);
}
// Double encryption - the data is already encrypted by identity-service,
// we encrypt it again for defense in depth
const { encrypted, keyId } = await this.encryptionService.encrypt(
command.encryptedShareData,
);
// Create domain entity
const share = BackupShare.create({
userId,
accountSequence: BigInt(command.accountSequence),
publicKey: command.publicKey,
encryptedShareData: encrypted,
encryptionKeyId: keyId,
threshold: command.threshold,
totalParties: command.totalParties,
});
// Save to repository
const saved = await this.repository.save(share);
// Log audit
await this.auditLogRepository.log({
shareId: saved.shareId!,
userId,
action: 'STORE',
sourceService: command.sourceService,
sourceIp: command.sourceIp,
success: true,
});
this.logger.log(`Backup share stored successfully: shareId=${saved.shareId}`);
return {
shareId: saved.shareId!.toString(),
};
}
}

View File

@ -0,0 +1,10 @@
export class ApplicationError extends Error {
constructor(
message: string,
public readonly code: string,
) {
super(message);
this.name = 'ApplicationError';
Object.setPrototypeOf(this, ApplicationError.prototype);
}
}

View File

@ -0,0 +1,18 @@
// Commands
export * from './commands/store-backup-share/store-backup-share.command';
export * from './commands/store-backup-share/store-backup-share.handler';
export * from './commands/revoke-share/revoke-share.command';
export * from './commands/revoke-share/revoke-share.handler';
// Queries
export * from './queries/get-backup-share/get-backup-share.query';
export * from './queries/get-backup-share/get-backup-share.handler';
// Services
export * from './services/backup-share-application.service';
// Errors
export * from './errors/application.error';
// Module
export * from './application.module';

View File

@ -0,0 +1,120 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GetBackupShareQuery } from './get-backup-share.query';
import { BackupShareStatus, BACKUP_SHARE_REPOSITORY } from '../../../domain';
import type { BackupShareRepository } from '../../../domain';
import { AesEncryptionService } from '../../../infrastructure/crypto/aes-encryption.service';
import { AuditLogRepository } from '../../../infrastructure/persistence/repositories/audit-log.repository';
import { ApplicationError } from '../../errors/application.error';
export interface BackupShareDto {
encryptedShareData: string;
partyIndex: number;
publicKey: string;
}
@Injectable()
export class GetBackupShareHandler {
private readonly logger = new Logger(GetBackupShareHandler.name);
private readonly maxRetrievesPerDay: number;
constructor(
@Inject(BACKUP_SHARE_REPOSITORY)
private readonly repository: BackupShareRepository,
private readonly encryptionService: AesEncryptionService,
private readonly auditLogRepository: AuditLogRepository,
private readonly configService: ConfigService,
) {
this.maxRetrievesPerDay = this.configService.get<number>('MAX_RETRIEVE_PER_DAY') || 3;
}
async execute(query: GetBackupShareQuery): Promise<BackupShareDto> {
this.logger.log(`Retrieving backup share for user: ${query.userId}`);
const userId = BigInt(query.userId);
// Check rate limit
const retrievesToday = await this.auditLogRepository.countRetrievesByUserToday(userId);
if (retrievesToday >= this.maxRetrievesPerDay) {
await this.auditLogRepository.log({
shareId: 0n,
userId,
action: 'RETRIEVE',
sourceService: query.sourceService,
sourceIp: query.sourceIp,
success: false,
errorMessage: 'Rate limit exceeded',
});
throw new ApplicationError(
`Rate limit exceeded. Maximum ${this.maxRetrievesPerDay} retrieves per day.`,
'RATE_LIMIT_EXCEEDED',
);
}
// Find share
const share = await this.repository.findByUserIdAndPublicKey(
userId,
query.publicKey,
);
if (!share) {
await this.auditLogRepository.log({
shareId: 0n,
userId,
action: 'RETRIEVE',
sourceService: query.sourceService,
sourceIp: query.sourceIp,
success: false,
errorMessage: 'Share not found',
});
throw new ApplicationError(
'Backup share not found',
'SHARE_NOT_FOUND',
);
}
if (share.status !== BackupShareStatus.ACTIVE) {
await this.auditLogRepository.log({
shareId: share.shareId!,
userId,
action: 'RETRIEVE',
sourceService: query.sourceService,
sourceIp: query.sourceIp,
success: false,
errorMessage: `Share is ${share.status}`,
});
throw new ApplicationError(
'Backup share is not active',
'SHARE_NOT_ACTIVE',
);
}
// Record access
share.recordAccess();
await this.repository.save(share);
// Decrypt the data (removes our encryption layer, returns identity-service encrypted data)
const decrypted = await this.encryptionService.decrypt(
share.encryptedShareData,
share.encryptionKeyId,
);
// Log audit
await this.auditLogRepository.log({
shareId: share.shareId!,
userId,
action: 'RETRIEVE',
sourceService: query.sourceService,
sourceIp: query.sourceIp,
success: true,
});
this.logger.log(`Backup share retrieved: shareId=${share.shareId}`);
return {
encryptedShareData: decrypted,
partyIndex: share.partyIndex,
publicKey: share.publicKey,
};
}
}

View File

@ -0,0 +1,10 @@
export class GetBackupShareQuery {
constructor(
public readonly userId: string,
public readonly publicKey: string,
public readonly recoveryToken: string,
public readonly sourceService: string,
public readonly sourceIp: string,
public readonly deviceId?: string,
) {}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { StoreBackupShareCommand } from '../commands/store-backup-share/store-backup-share.command';
import { StoreBackupShareHandler, StoreBackupShareResult } from '../commands/store-backup-share/store-backup-share.handler';
import { RevokeShareCommand } from '../commands/revoke-share/revoke-share.command';
import { RevokeShareHandler } from '../commands/revoke-share/revoke-share.handler';
import { GetBackupShareQuery } from '../queries/get-backup-share/get-backup-share.query';
import { GetBackupShareHandler, BackupShareDto } from '../queries/get-backup-share/get-backup-share.handler';
@Injectable()
export class BackupShareApplicationService {
constructor(
private readonly storeHandler: StoreBackupShareHandler,
private readonly revokeHandler: RevokeShareHandler,
private readonly getHandler: GetBackupShareHandler,
) {}
async storeBackupShare(command: StoreBackupShareCommand): Promise<StoreBackupShareResult> {
return this.storeHandler.execute(command);
}
async revokeShare(command: RevokeShareCommand): Promise<void> {
return this.revokeHandler.execute(command);
}
async getBackupShare(query: GetBackupShareQuery): Promise<BackupShareDto> {
return this.getHandler.execute(query);
}
}

View File

@ -0,0 +1,30 @@
export default () => ({
app: {
port: parseInt(process.env.APP_PORT || '3002', 10),
env: process.env.APP_ENV || 'development',
},
database: {
url: process.env.DATABASE_URL,
},
security: {
serviceJwtSecret: process.env.SERVICE_JWT_SECRET,
allowedServices: (process.env.ALLOWED_SERVICES || 'identity-service,recovery-service')
.split(',')
.map((s) => s.trim()),
},
encryption: {
key: process.env.BACKUP_ENCRYPTION_KEY,
keyId: process.env.BACKUP_ENCRYPTION_KEY_ID || 'key-v1',
},
rateLimit: {
maxRetrievePerDay: parseInt(process.env.MAX_RETRIEVE_PER_DAY || '3', 10),
maxStorePerMinute: parseInt(process.env.MAX_STORE_PER_MINUTE || '10', 10),
},
audit: {
logRetentionDays: parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || '365', 10),
},
monitoring: {
prometheusEnabled: process.env.PROMETHEUS_ENABLED === 'true',
prometheusPort: parseInt(process.env.PROMETHEUS_PORT || '9102', 10),
},
});

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
@Module({
imports: [],
providers: [],
exports: [],
})
export class DomainModule {}

View File

@ -0,0 +1,201 @@
import { DomainError } from '../errors/domain.error';
export enum BackupShareStatus {
ACTIVE = 'ACTIVE',
REVOKED = 'REVOKED',
ROTATED = 'ROTATED',
}
export interface BackupShareProps {
shareId: bigint | null;
userId: bigint;
accountSequence: bigint;
publicKey: string;
partyIndex: number;
threshold: number;
totalParties: number;
encryptedShareData: string;
encryptionKeyId: string;
status: BackupShareStatus;
accessCount: number;
lastAccessedAt: Date | null;
createdAt: Date;
updatedAt: Date;
revokedAt: Date | null;
}
export class BackupShare {
private _shareId: bigint | null;
private readonly _userId: bigint;
private readonly _accountSequence: bigint;
private readonly _publicKey: string;
private readonly _partyIndex: number;
private readonly _threshold: number;
private readonly _totalParties: number;
private _encryptedShareData: string;
private _encryptionKeyId: string;
private _status: BackupShareStatus;
private _accessCount: number;
private _lastAccessedAt: Date | null;
private readonly _createdAt: Date;
private _updatedAt: Date;
private _revokedAt: Date | null;
private constructor(props: BackupShareProps) {
this._shareId = props.shareId;
this._userId = props.userId;
this._accountSequence = props.accountSequence;
this._publicKey = props.publicKey;
this._partyIndex = props.partyIndex;
this._threshold = props.threshold;
this._totalParties = props.totalParties;
this._encryptedShareData = props.encryptedShareData;
this._encryptionKeyId = props.encryptionKeyId;
this._status = props.status;
this._accessCount = props.accessCount;
this._lastAccessedAt = props.lastAccessedAt;
this._createdAt = props.createdAt;
this._updatedAt = props.updatedAt;
this._revokedAt = props.revokedAt;
}
static create(params: {
userId: bigint;
accountSequence: bigint;
publicKey: string;
encryptedShareData: string;
encryptionKeyId: string;
threshold?: number;
totalParties?: number;
}): BackupShare {
const now = new Date();
return new BackupShare({
shareId: null,
userId: params.userId,
accountSequence: params.accountSequence,
publicKey: params.publicKey,
partyIndex: 2, // Backup = Party 2
threshold: params.threshold ?? 2,
totalParties: params.totalParties ?? 3,
encryptedShareData: params.encryptedShareData,
encryptionKeyId: params.encryptionKeyId,
status: BackupShareStatus.ACTIVE,
accessCount: 0,
lastAccessedAt: null,
createdAt: now,
updatedAt: now,
revokedAt: null,
});
}
static reconstitute(props: BackupShareProps): BackupShare {
return new BackupShare(props);
}
recordAccess(): void {
if (this._status !== BackupShareStatus.ACTIVE) {
throw new DomainError('Cannot access revoked or rotated share');
}
this._accessCount++;
this._lastAccessedAt = new Date();
this._updatedAt = new Date();
}
revoke(reason: string): void {
if (this._status === BackupShareStatus.REVOKED) {
throw new DomainError('Share already revoked');
}
this._status = BackupShareStatus.REVOKED;
this._revokedAt = new Date();
this._updatedAt = new Date();
}
rotate(newEncryptedData: string, newKeyId: string): void {
if (this._status === BackupShareStatus.REVOKED) {
throw new DomainError('Cannot rotate revoked share');
}
this._encryptedShareData = newEncryptedData;
this._encryptionKeyId = newKeyId;
this._status = BackupShareStatus.ACTIVE;
this._updatedAt = new Date();
}
isActive(): boolean {
return this._status === BackupShareStatus.ACTIVE;
}
// Getters
get shareId(): bigint | null {
return this._shareId;
}
get userId(): bigint {
return this._userId;
}
get accountSequence(): bigint {
return this._accountSequence;
}
get publicKey(): string {
return this._publicKey;
}
get partyIndex(): number {
return this._partyIndex;
}
get threshold(): number {
return this._threshold;
}
get totalParties(): number {
return this._totalParties;
}
get encryptedShareData(): string {
return this._encryptedShareData;
}
get encryptionKeyId(): string {
return this._encryptionKeyId;
}
get status(): BackupShareStatus {
return this._status;
}
get accessCount(): number {
return this._accessCount;
}
get lastAccessedAt(): Date | null {
return this._lastAccessedAt;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
get revokedAt(): Date | null {
return this._revokedAt;
}
// For persistence
setShareId(id: bigint): void {
if (this._shareId !== null) {
throw new DomainError('Share ID already set');
}
this._shareId = id;
}
toProps(): BackupShareProps {
return {
shareId: this._shareId,
userId: this._userId,
accountSequence: this._accountSequence,
publicKey: this._publicKey,
partyIndex: this._partyIndex,
threshold: this._threshold,
totalParties: this._totalParties,
encryptedShareData: this._encryptedShareData,
encryptionKeyId: this._encryptionKeyId,
status: this._status,
accessCount: this._accessCount,
lastAccessedAt: this._lastAccessedAt,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
revokedAt: this._revokedAt,
};
}
}

View File

@ -0,0 +1,7 @@
export class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = 'DomainError';
Object.setPrototypeOf(this, DomainError.prototype);
}
}

View File

@ -0,0 +1,15 @@
// Entities
export * from './entities/backup-share.entity';
// Value Objects
export * from './value-objects/share-id.vo';
export * from './value-objects/encrypted-data.vo';
// Repositories
export * from './repositories/backup-share.repository.interface';
// Errors
export * from './errors/domain.error';
// Module
export * from './domain.module';

View File

@ -0,0 +1,16 @@
import { BackupShare } from '../entities/backup-share.entity';
export const BACKUP_SHARE_REPOSITORY = Symbol('BACKUP_SHARE_REPOSITORY');
export interface BackupShareRepository {
save(share: BackupShare): Promise<BackupShare>;
findById(shareId: bigint): Promise<BackupShare | null>;
findByUserId(userId: bigint): Promise<BackupShare | null>;
findByPublicKey(publicKey: string): Promise<BackupShare | null>;
findByUserIdAndPublicKey(
userId: bigint,
publicKey: string,
): Promise<BackupShare | null>;
findByAccountSequence(accountSequence: bigint): Promise<BackupShare | null>;
delete(shareId: bigint): Promise<void>;
}

View File

@ -0,0 +1,74 @@
import { DomainError } from '../errors/domain.error';
export class EncryptedData {
private readonly _ciphertext: string;
private readonly _iv: string;
private readonly _authTag: string;
private readonly _keyId: string;
private constructor(
ciphertext: string,
iv: string,
authTag: string,
keyId: string,
) {
this._ciphertext = ciphertext;
this._iv = iv;
this._authTag = authTag;
this._keyId = keyId;
}
static create(params: {
ciphertext: string;
iv: string;
authTag: string;
keyId: string;
}): EncryptedData {
if (!params.ciphertext || params.ciphertext.length === 0) {
throw new DomainError('Ciphertext cannot be empty');
}
if (!params.iv || params.iv.length === 0) {
throw new DomainError('IV cannot be empty');
}
if (!params.authTag || params.authTag.length === 0) {
throw new DomainError('Auth tag cannot be empty');
}
if (!params.keyId || params.keyId.length === 0) {
throw new DomainError('Key ID cannot be empty');
}
return new EncryptedData(
params.ciphertext,
params.iv,
params.authTag,
params.keyId,
);
}
static fromSerializedString(serialized: string, keyId: string): EncryptedData {
const parts = serialized.split(':');
if (parts.length !== 3) {
throw new DomainError('Invalid encrypted data format');
}
return new EncryptedData(parts[0], parts[1], parts[2], keyId);
}
get ciphertext(): string {
return this._ciphertext;
}
get iv(): string {
return this._iv;
}
get authTag(): string {
return this._authTag;
}
get keyId(): string {
return this._keyId;
}
toSerializedString(): string {
return `${this._ciphertext}:${this._iv}:${this._authTag}`;
}
}

View File

@ -0,0 +1,29 @@
import { DomainError } from '../errors/domain.error';
export class ShareId {
private readonly _value: bigint;
private constructor(value: bigint) {
this._value = value;
}
static create(value: bigint | string | number): ShareId {
const bigIntValue = typeof value === 'bigint' ? value : BigInt(value);
if (bigIntValue <= 0n) {
throw new DomainError('ShareId must be a positive number');
}
return new ShareId(bigIntValue);
}
get value(): bigint {
return this._value;
}
toString(): string {
return this._value.toString();
}
equals(other: ShareId): boolean {
return this._value === other._value;
}
}

View File

@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
export interface EncryptionResult {
encrypted: string;
keyId: string;
}
@Injectable()
export class AesEncryptionService {
private readonly logger = new Logger(AesEncryptionService.name);
private readonly algorithm = 'aes-256-gcm';
private readonly keyMap: Map<string, Buffer> = new Map();
private readonly currentKeyId: string;
constructor(private readonly configService: ConfigService) {
// Load encryption keys
const encryptionKey = this.configService.get<string>('BACKUP_ENCRYPTION_KEY');
const keyId = this.configService.get<string>('BACKUP_ENCRYPTION_KEY_ID') || 'key-v1';
if (!encryptionKey) {
throw new Error('BACKUP_ENCRYPTION_KEY is not configured');
}
// Convert hex string to buffer
const keyBuffer = Buffer.from(encryptionKey, 'hex');
if (keyBuffer.length !== 32) {
throw new Error('BACKUP_ENCRYPTION_KEY must be 256 bits (64 hex characters)');
}
this.keyMap.set(keyId, keyBuffer);
this.currentKeyId = keyId;
this.logger.log(`AES encryption service initialized with key: ${keyId}`);
}
async encrypt(plaintext: string): Promise<EncryptionResult> {
const key = this.keyMap.get(this.currentKeyId);
if (!key) {
throw new Error(`Encryption key not found: ${this.currentKeyId}`);
}
// Generate random IV (12 bytes for GCM)
const iv = crypto.randomBytes(12);
// Create cipher
const cipher = crypto.createCipheriv(this.algorithm, key, iv);
// Encrypt
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
// Get auth tag
const authTag = cipher.getAuthTag();
// Combine: base64(ciphertext):base64(iv):base64(authTag)
const combined = `${encrypted}:${iv.toString('base64')}:${authTag.toString('base64')}`;
return {
encrypted: combined,
keyId: this.currentKeyId,
};
}
async decrypt(encryptedData: string, keyId: string): Promise<string> {
const key = this.keyMap.get(keyId);
if (!key) {
throw new Error(`Decryption key not found: ${keyId}`);
}
// Parse combined string
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const [ciphertext, ivBase64, authTagBase64] = parts;
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
// Create decipher
const decipher = crypto.createDecipheriv(this.algorithm, key, iv);
decipher.setAuthTag(authTag);
// Decrypt
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// For key rotation - add a new key
addKey(keyId: string, keyHex: string): void {
const keyBuffer = Buffer.from(keyHex, 'hex');
if (keyBuffer.length !== 32) {
throw new Error('Key must be 256 bits (64 hex characters)');
}
this.keyMap.set(keyId, keyBuffer);
this.logger.log(`Added encryption key: ${keyId}`);
}
getCurrentKeyId(): string {
return this.currentKeyId;
}
}

View File

@ -0,0 +1,10 @@
// Persistence
export * from './persistence/prisma/prisma.service';
export * from './persistence/repositories/backup-share.repository.impl';
export * from './persistence/repositories/audit-log.repository';
// Crypto
export * from './crypto/aes-encryption.service';
// Module
export * from './infrastructure.module';

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaService } from './persistence/prisma/prisma.service';
import { BackupShareRepositoryImpl } from './persistence/repositories/backup-share.repository.impl';
import { AuditLogRepository } from './persistence/repositories/audit-log.repository';
import { AesEncryptionService } from './crypto/aes-encryption.service';
import { BACKUP_SHARE_REPOSITORY } from '../domain';
@Module({
imports: [ConfigModule],
providers: [
PrismaService,
AuditLogRepository,
AesEncryptionService,
{
provide: BACKUP_SHARE_REPOSITORY,
useClass: BackupShareRepositoryImpl,
},
],
exports: [
PrismaService,
AuditLogRepository,
AesEncryptionService,
BACKUP_SHARE_REPOSITORY,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,52 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
private pool: Pool;
constructor() {
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
super({
adapter,
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
this.pool = pool;
}
async onModuleInit() {
this.logger.log('Connecting to database...');
await this.$connect();
this.logger.log('Database connected successfully');
}
async onModuleDestroy() {
this.logger.log('Disconnecting from database...');
await this.$disconnect();
await this.pool.end();
this.logger.log('Database disconnected');
}
async cleanDatabase() {
if (process.env.APP_ENV !== 'test') {
throw new Error('cleanDatabase is only available in test environment');
}
await this.shareAccessLog.deleteMany();
await this.backupShare.deleteMany();
}
}

View File

@ -0,0 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface AuditLogEntry {
shareId: bigint;
userId: bigint;
action: 'STORE' | 'RETRIEVE' | 'REVOKE' | 'ROTATE';
sourceService: string;
sourceIp: string;
success: boolean;
errorMessage?: string;
}
@Injectable()
export class AuditLogRepository {
private readonly logger = new Logger(AuditLogRepository.name);
constructor(private readonly prisma: PrismaService) {}
async log(entry: AuditLogEntry): Promise<void> {
try {
await this.prisma.shareAccessLog.create({
data: {
shareId: entry.shareId,
userId: entry.userId,
action: entry.action,
sourceService: entry.sourceService,
sourceIp: entry.sourceIp,
success: entry.success,
errorMessage: entry.errorMessage,
},
});
this.logger.debug(
`Audit log created: ${entry.action} for share ${entry.shareId}`,
);
} catch (error) {
this.logger.error(`Failed to create audit log: ${error.message}`);
// Don't throw - audit log failures shouldn't affect main operations
}
}
async findByShareId(shareId: bigint, limit = 100) {
return this.prisma.shareAccessLog.findMany({
where: { shareId },
orderBy: { createdAt: 'desc' },
take: limit,
});
}
async findByUserId(userId: bigint, limit = 100) {
return this.prisma.shareAccessLog.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
});
}
async countRetrievesByUserToday(userId: bigint): Promise<number> {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
return this.prisma.shareAccessLog.count({
where: {
userId,
action: 'RETRIEVE',
createdAt: { gte: startOfDay },
},
});
}
}

View File

@ -0,0 +1,123 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
BackupShare,
BackupShareStatus,
BackupShareProps,
BackupShareRepository,
} from '../../../domain';
import { BackupShare as PrismaBackupShare } from '@prisma/client';
@Injectable()
export class BackupShareRepositoryImpl implements BackupShareRepository {
constructor(private readonly prisma: PrismaService) {}
async save(share: BackupShare): Promise<BackupShare> {
const props = share.toProps();
if (props.shareId === null) {
// Create new
const created = await this.prisma.backupShare.create({
data: {
userId: props.userId,
accountSequence: props.accountSequence,
publicKey: props.publicKey,
partyIndex: props.partyIndex,
threshold: props.threshold,
totalParties: props.totalParties,
encryptedShareData: props.encryptedShareData,
encryptionKeyId: props.encryptionKeyId,
status: props.status,
accessCount: props.accessCount,
lastAccessedAt: props.lastAccessedAt,
revokedAt: props.revokedAt,
},
});
return this.toDomain(created);
} else {
// Update existing
const updated = await this.prisma.backupShare.update({
where: { shareId: props.shareId },
data: {
encryptedShareData: props.encryptedShareData,
encryptionKeyId: props.encryptionKeyId,
status: props.status,
accessCount: props.accessCount,
lastAccessedAt: props.lastAccessedAt,
revokedAt: props.revokedAt,
},
});
return this.toDomain(updated);
}
}
async findById(shareId: bigint): Promise<BackupShare | null> {
const record = await this.prisma.backupShare.findUnique({
where: { shareId },
});
return record ? this.toDomain(record) : null;
}
async findByUserId(userId: bigint): Promise<BackupShare | null> {
const record = await this.prisma.backupShare.findUnique({
where: { userId },
});
return record ? this.toDomain(record) : null;
}
async findByPublicKey(publicKey: string): Promise<BackupShare | null> {
const record = await this.prisma.backupShare.findUnique({
where: { publicKey },
});
return record ? this.toDomain(record) : null;
}
async findByUserIdAndPublicKey(
userId: bigint,
publicKey: string,
): Promise<BackupShare | null> {
const record = await this.prisma.backupShare.findFirst({
where: {
userId,
publicKey,
},
});
return record ? this.toDomain(record) : null;
}
async findByAccountSequence(
accountSequence: bigint,
): Promise<BackupShare | null> {
const record = await this.prisma.backupShare.findUnique({
where: { accountSequence },
});
return record ? this.toDomain(record) : null;
}
async delete(shareId: bigint): Promise<void> {
await this.prisma.backupShare.delete({
where: { shareId },
});
}
private toDomain(record: PrismaBackupShare): BackupShare {
const props: BackupShareProps = {
shareId: record.shareId,
userId: record.userId,
accountSequence: record.accountSequence,
publicKey: record.publicKey,
partyIndex: record.partyIndex,
threshold: record.threshold,
totalParties: record.totalParties,
encryptedShareData: record.encryptedShareData,
encryptionKeyId: record.encryptionKeyId,
status: record.status as BackupShareStatus,
accessCount: record.accessCount,
lastAccessedAt: record.lastAccessedAt,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
revokedAt: record.revokedAt,
};
return BackupShare.reconstitute(props);
}
}

View File

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// CORS - only allow internal services
app.enableCors({
origin: false, // Disable public CORS - only internal services should access
});
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port') || 3002;
const env = configService.get<string>('app.env') || 'development';
await app.listen(port);
logger.log(`Backup Service is running on port ${port} in ${env} mode`);
logger.log(`Health check: http://localhost:${port}/health`);
}
bootstrap();

View File

@ -0,0 +1,92 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { ApplicationError } from '../../application/errors/application.error';
import { DomainError } from '../../domain/errors/domain.error';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let code = 'INTERNAL_ERROR';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message;
code = this.getCodeFromStatus(status);
} else if (exception instanceof ApplicationError) {
status = this.getStatusFromCode(exception.code);
message = exception.message;
code = exception.code;
} else if (exception instanceof DomainError) {
status = HttpStatus.BAD_REQUEST;
message = exception.message;
code = 'DOMAIN_ERROR';
} else if (exception instanceof Error) {
message = exception.message;
}
this.logger.error(
`${request.method} ${request.url} - ${status} - ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
response.status(status).json({
success: false,
error: message,
code,
timestamp: new Date().toISOString(),
path: request.url,
});
}
private getCodeFromStatus(status: number): string {
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
case HttpStatus.UNAUTHORIZED:
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.TOO_MANY_REQUESTS:
return 'RATE_LIMIT_EXCEEDED';
default:
return 'INTERNAL_ERROR';
}
}
private getStatusFromCode(code: string): number {
switch (code) {
case 'SHARE_NOT_FOUND':
return HttpStatus.NOT_FOUND;
case 'SHARE_ALREADY_EXISTS':
return HttpStatus.CONFLICT;
case 'SHARE_NOT_ACTIVE':
return HttpStatus.BAD_REQUEST;
case 'RATE_LIMIT_EXCEEDED':
return HttpStatus.TOO_MANY_REQUESTS;
case 'UNAUTHORIZED':
return HttpStatus.UNAUTHORIZED;
default:
return HttpStatus.BAD_REQUEST;
}
}
}

View File

@ -0,0 +1,70 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
interface ServiceTokenPayload {
service: string;
iat: number;
exp?: number;
}
@Injectable()
export class ServiceAuthGuard implements CanActivate {
private readonly logger = new Logger(ServiceAuthGuard.name);
private readonly allowedServices: string[];
private readonly secret: string;
constructor(private readonly configService: ConfigService) {
this.secret = this.configService.get<string>('SERVICE_JWT_SECRET') || '';
const allowedServicesStr = this.configService.get<string>('ALLOWED_SERVICES') || 'identity-service,recovery-service';
this.allowedServices = allowedServicesStr.split(',').map(s => s.trim());
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const serviceToken = request.headers['x-service-token'];
if (!serviceToken) {
this.logger.warn('Missing service token in request');
throw new UnauthorizedException('Missing service token');
}
try {
const payload = jwt.verify(serviceToken, this.secret) as ServiceTokenPayload;
if (!this.allowedServices.includes(payload.service)) {
this.logger.warn(`Service not authorized: ${payload.service}`);
throw new UnauthorizedException('Service not authorized');
}
// Attach service info to request for audit logging
request.sourceService = payload.service;
request.sourceIp = this.getClientIp(request);
this.logger.debug(`Authenticated request from service: ${payload.service}`);
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
this.logger.warn(`Invalid service token: ${error.message}`);
throw new UnauthorizedException('Invalid service token');
}
}
private getClientIp(request: any): string {
return (
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
request.headers['x-real-ip'] ||
request.connection?.remoteAddress ||
request.socket?.remoteAddress ||
'unknown'
);
}
}

View File

@ -0,0 +1,63 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditLogInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url, body } = request;
const sourceService = request.sourceService || 'unknown';
const sourceIp = request.sourceIp || 'unknown';
const startTime = Date.now();
// Log sensitive fields redacted
const sanitizedBody = this.sanitizeBody(body);
this.logger.log(
`[${sourceService}] ${method} ${url} - Request from ${sourceIp}`,
);
this.logger.debug(`Request body: ${JSON.stringify(sanitizedBody)}`);
return next.handle().pipe(
tap({
next: (response) => {
const duration = Date.now() - startTime;
this.logger.log(
`[${sourceService}] ${method} ${url} - Response ${duration}ms - Success`,
);
},
error: (error) => {
const duration = Date.now() - startTime;
this.logger.error(
`[${sourceService}] ${method} ${url} - Response ${duration}ms - Error: ${error.message}`,
);
},
}),
);
}
private sanitizeBody(body: any): any {
if (!body) return body;
const sanitized = { ...body };
const sensitiveFields = ['encryptedShareData', 'recoveryToken', 'password', 'secret'];
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
}
return sanitized;
}
}

View File

@ -0,0 +1,132 @@
# Backup Service Tests
## Test Structure
```
test/
├── unit/ # Unit tests (no external dependencies)
│ ├── api/ # Controller tests
│ ├── application/ # Handler tests
│ ├── domain/ # Entity and value object tests
│ ├── infrastructure/ # Service tests
│ └── shared/ # Guard, filter, interceptor tests
├── integration/ # Integration tests (mocked DB)
├── e2e/ # End-to-end tests
│ ├── backup-share-mock.e2e-spec.ts # Mock DB E2E tests
│ └── backup-share.e2e-spec.ts # Real DB E2E tests
├── setup/ # Test setup files
│ ├── global-setup.ts # DB container setup
│ ├── global-teardown.ts # DB container cleanup
│ ├── jest-e2e-setup.ts # Real DB test setup
│ ├── jest-mock-setup.ts # Mock test setup
│ └── test-database.helper.ts # DB helper utilities
└── utils/ # Test utilities
├── mock-prisma.service.ts # Mock Prisma service
└── test-utils.ts # Helper functions
```
## Running Tests
### Unit Tests
```bash
npm run test:unit
```
Runs 37 unit tests. No external dependencies required.
### Mock E2E Tests
```bash
npm run test:e2e:mock
# or
npm run test:e2e
```
Runs 21 E2E tests with mocked database. No Docker or PostgreSQL required.
### All Tests (Unit + Mock E2E)
```bash
npm run test:all
```
Runs all 58 tests that don't require a real database.
### Real Database E2E Tests
#### Option 1: With Docker Desktop
```bash
# Ensure Docker Desktop is running
npm run test:e2e:db
```
This will:
1. Start PostgreSQL container (port 5434)
2. Push Prisma schema
3. Run 32 E2E tests
4. Stop and cleanup container
#### Option 2: With Existing PostgreSQL
```bash
# 1. Create a test database
psql -c "CREATE DATABASE rwa_backup_test;"
# 2. Update DATABASE_URL in .env.test
# DATABASE_URL="postgresql://user:password@localhost:5432/rwa_backup_test"
# 3. Setup database schema
npm run db:test:setup
# 4. Run tests
npm run test:e2e:db:manual
```
## Test Commands
| Command | Description |
|---------|-------------|
| `npm run test` | Run all Jest tests |
| `npm run test:unit` | Run unit tests only |
| `npm run test:e2e` | Run mock E2E tests |
| `npm run test:e2e:mock` | Run mock E2E tests |
| `npm run test:e2e:db` | Run real DB E2E tests (Docker) |
| `npm run test:e2e:db:manual` | Run real DB E2E tests (existing DB) |
| `npm run test:all` | Run unit + mock E2E tests |
| `npm run test:cov` | Run tests with coverage |
| `npm run db:test:up` | Start test DB container |
| `npm run db:test:down` | Stop test DB container |
| `npm run db:test:setup` | Setup existing DB for tests |
## Test Environment Variables
Located in `.env.test`:
```env
DATABASE_URL="postgresql://postgres:testpassword@localhost:5434/rwa_backup_test"
APP_PORT=3003
APP_ENV="test"
SERVICE_JWT_SECRET="test-super-secret-service-jwt-key-for-e2e-testing"
ALLOWED_SERVICES="identity-service,recovery-service"
BACKUP_ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
BACKUP_ENCRYPTION_KEY_ID="test-key-v1"
```
## Test Utilities
### generateServiceToken(service, secret?)
Generates a JWT service token for testing authenticated endpoints.
### createStoreSharePayload(overrides?)
Creates a valid store share request payload.
### createRetrieveSharePayload(overrides?)
Creates a valid retrieve share request payload.
### createRevokeSharePayload(overrides?)
Creates a valid revoke share request payload.
### MockPrismaService
Full mock implementation of PrismaService for testing without a database.
## Coverage Report
Generate coverage report:
```bash
npm run test:cov
```
Coverage output in `./coverage/` directory.

View File

@ -0,0 +1,433 @@
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');
});
});
});

View File

@ -0,0 +1,517 @@
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');
});
});
});

View File

@ -0,0 +1,204 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditLogRepository } from '../../src/infrastructure/persistence/repositories/audit-log.repository';
import { PrismaService } from '../../src/infrastructure/persistence/prisma/prisma.service';
import { MockPrismaService } from '../utils/mock-prisma.service';
describe('AuditLogRepository Integration', () => {
let repository: AuditLogRepository;
let mockPrisma: MockPrismaService;
beforeEach(async () => {
mockPrisma = new MockPrismaService();
const module: TestingModule = await Test.createTestingModule({
providers: [
AuditLogRepository,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
repository = module.get<AuditLogRepository>(AuditLogRepository);
});
afterEach(() => {
mockPrisma.reset();
});
describe('log', () => {
it('should create an audit log entry for STORE action', async () => {
await repository.log({
shareId: BigInt(1),
userId: BigInt(12345),
action: 'STORE',
sourceService: 'identity-service',
sourceIp: '192.168.1.1',
success: true,
});
expect(mockPrisma.shareAccessLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
shareId: BigInt(1),
userId: BigInt(12345),
action: 'STORE',
sourceService: 'identity-service',
sourceIp: '192.168.1.1',
success: true,
}),
});
});
it('should create an audit log entry for RETRIEVE action', async () => {
await repository.log({
shareId: BigInt(2),
userId: BigInt(67890),
action: 'RETRIEVE',
sourceService: 'recovery-service',
sourceIp: '10.0.0.1',
success: true,
});
expect(mockPrisma.shareAccessLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'RETRIEVE',
sourceService: 'recovery-service',
}),
});
});
it('should create an audit log entry for REVOKE action', async () => {
await repository.log({
shareId: BigInt(3),
userId: BigInt(11111),
action: 'REVOKE',
sourceService: 'identity-service',
sourceIp: '172.16.0.1',
success: true,
});
expect(mockPrisma.shareAccessLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'REVOKE',
}),
});
});
it('should log failed operations with error message', async () => {
await repository.log({
shareId: BigInt(0),
userId: BigInt(22222),
action: 'RETRIEVE',
sourceService: 'identity-service',
sourceIp: '192.168.1.100',
success: false,
errorMessage: 'Share not found',
});
expect(mockPrisma.shareAccessLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
success: false,
errorMessage: 'Share not found',
}),
});
});
it('should not throw when database error occurs', async () => {
mockPrisma.shareAccessLog.create.mockRejectedValueOnce(new Error('DB Error'));
await expect(
repository.log({
shareId: BigInt(1),
userId: BigInt(12345),
action: 'STORE',
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
success: true,
}),
).resolves.not.toThrow();
});
});
describe('findByShareId', () => {
it('should find logs by share ID', async () => {
const shareId = BigInt(100);
// Create some logs
await repository.log({
shareId,
userId: BigInt(12345),
action: 'STORE',
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
success: true,
});
await repository.log({
shareId,
userId: BigInt(12345),
action: 'RETRIEVE',
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
success: true,
});
await repository.findByShareId(shareId);
expect(mockPrisma.shareAccessLog.findMany).toHaveBeenCalledWith({
where: { shareId },
orderBy: { createdAt: 'desc' },
take: 100,
});
});
it('should respect limit parameter', async () => {
await repository.findByShareId(BigInt(1), 50);
expect(mockPrisma.shareAccessLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: 50,
}),
);
});
});
describe('findByUserId', () => {
it('should find logs by user ID', async () => {
const userId = BigInt(55555);
await repository.findByUserId(userId);
expect(mockPrisma.shareAccessLog.findMany).toHaveBeenCalledWith({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 100,
});
});
});
describe('countRetrievesByUserToday', () => {
it('should count today\'s retrieves for a user', async () => {
const userId = BigInt(33333);
await repository.countRetrievesByUserToday(userId);
expect(mockPrisma.shareAccessLog.count).toHaveBeenCalledWith({
where: {
userId,
action: 'RETRIEVE',
createdAt: expect.objectContaining({
gte: expect.any(Date),
}),
},
});
});
it('should return correct count', async () => {
const userId = BigInt(44444);
// Simulate 2 retrieves today
mockPrisma.shareAccessLog.count.mockResolvedValueOnce(2);
const count = await repository.countRetrievesByUserToday(userId);
expect(count).toBe(2);
});
});
});

View File

@ -0,0 +1,252 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BackupShareRepositoryImpl } from '../../src/infrastructure/persistence/repositories/backup-share.repository.impl';
import { PrismaService } from '../../src/infrastructure/persistence/prisma/prisma.service';
import { MockPrismaService } from '../utils/mock-prisma.service';
import { BackupShare, BackupShareStatus } from '../../src/domain';
describe('BackupShareRepository Integration', () => {
let repository: BackupShareRepositoryImpl;
let mockPrisma: MockPrismaService;
beforeEach(async () => {
mockPrisma = new MockPrismaService();
const module: TestingModule = await Test.createTestingModule({
providers: [
BackupShareRepositoryImpl,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
repository = module.get<BackupShareRepositoryImpl>(BackupShareRepositoryImpl);
});
afterEach(() => {
mockPrisma.reset();
});
describe('save', () => {
it('should create a new backup share', async () => {
const share = BackupShare.create({
userId: BigInt(12345),
accountSequence: BigInt(1001),
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-data',
encryptionKeyId: 'key-v1',
});
const saved = await repository.save(share);
expect(saved.shareId).toBeDefined();
expect(saved.userId).toBe(BigInt(12345));
expect(saved.publicKey).toBe('02' + 'a'.repeat(64));
expect(mockPrisma.backupShare.create).toHaveBeenCalled();
});
it('should update an existing backup share', async () => {
// First create
const share = BackupShare.create({
userId: BigInt(12345),
accountSequence: BigInt(1001),
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-data',
encryptionKeyId: 'key-v1',
});
const saved = await repository.save(share);
// Modify and update
saved.recordAccess();
const updated = await repository.save(saved);
expect(updated.accessCount).toBe(1);
expect(mockPrisma.backupShare.update).toHaveBeenCalled();
});
it('should preserve all fields when saving', async () => {
const share = BackupShare.create({
userId: BigInt(99999),
accountSequence: BigInt(9999),
publicKey: '02' + 'b'.repeat(64),
encryptedShareData: 'test-encrypted-data',
encryptionKeyId: 'key-v2',
threshold: 3,
totalParties: 5,
});
const saved = await repository.save(share);
expect(saved.threshold).toBe(3);
expect(saved.totalParties).toBe(5);
expect(saved.partyIndex).toBe(2);
expect(saved.status).toBe(BackupShareStatus.ACTIVE);
});
});
describe('findByUserId', () => {
it('should find share by user ID', async () => {
const share = BackupShare.create({
userId: BigInt(55555),
accountSequence: BigInt(5555),
publicKey: '02' + 'c'.repeat(64),
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
await repository.save(share);
const found = await repository.findByUserId(BigInt(55555));
expect(found).toBeDefined();
expect(found?.userId).toBe(BigInt(55555));
});
it('should return null for non-existent user', async () => {
const found = await repository.findByUserId(BigInt(99999999));
expect(found).toBeNull();
});
});
describe('findByPublicKey', () => {
it('should find share by public key', async () => {
const publicKey = '02' + 'd'.repeat(64);
const share = BackupShare.create({
userId: BigInt(66666),
accountSequence: BigInt(6666),
publicKey,
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
await repository.save(share);
const found = await repository.findByPublicKey(publicKey);
expect(found).toBeDefined();
expect(found?.publicKey).toBe(publicKey);
});
});
describe('findByUserIdAndPublicKey', () => {
it('should find share by user ID and public key combination', async () => {
const userId = BigInt(77777);
const publicKey = '02' + 'e'.repeat(64);
const share = BackupShare.create({
userId,
accountSequence: BigInt(7777),
publicKey,
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
await repository.save(share);
const found = await repository.findByUserIdAndPublicKey(userId, publicKey);
expect(found).toBeDefined();
expect(found?.userId).toBe(userId);
expect(found?.publicKey).toBe(publicKey);
});
it('should return null when user ID matches but public key does not', async () => {
const share = BackupShare.create({
userId: BigInt(88888),
accountSequence: BigInt(8888),
publicKey: '02' + 'f'.repeat(64),
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
await repository.save(share);
const found = await repository.findByUserIdAndPublicKey(
BigInt(88888),
'02' + 'g'.repeat(64), // Different public key
);
expect(found).toBeNull();
});
});
describe('findByAccountSequence', () => {
it('should find share by account sequence', async () => {
const accountSequence = BigInt(123456);
const share = BackupShare.create({
userId: BigInt(11111),
accountSequence,
publicKey: '02' + 'h'.repeat(64),
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
await repository.save(share);
const found = await repository.findByAccountSequence(accountSequence);
expect(found).toBeDefined();
expect(found?.accountSequence).toBe(accountSequence);
});
});
describe('delete', () => {
it('should delete a backup share', async () => {
const share = BackupShare.create({
userId: BigInt(22222),
accountSequence: BigInt(2222),
publicKey: '02' + 'i'.repeat(64),
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
const saved = await repository.save(share);
await repository.delete(saved.shareId!);
expect(mockPrisma.backupShare.delete).toHaveBeenCalledWith({
where: { shareId: saved.shareId },
});
});
});
describe('entity state transitions', () => {
it('should persist revoked status', async () => {
const share = BackupShare.create({
userId: BigInt(33333),
accountSequence: BigInt(3333),
publicKey: '02' + 'j'.repeat(64),
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
const saved = await repository.save(share);
saved.revoke('SECURITY_BREACH');
await repository.save(saved);
expect(mockPrisma.backupShare.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'REVOKED',
}),
}),
);
});
it('should persist access count updates', async () => {
const share = BackupShare.create({
userId: BigInt(44444),
accountSequence: BigInt(4444),
publicKey: '02' + 'k'.repeat(64),
encryptedShareData: 'data',
encryptionKeyId: 'key-v1',
});
const saved = await repository.save(share);
saved.recordAccess();
saved.recordAccess();
await repository.save(saved);
expect(mockPrisma.backupShare.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
accessCount: 2,
}),
}),
);
});
});
});

View File

@ -0,0 +1,17 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": "backup-share.e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
},
"setupFilesAfterEnv": ["<rootDir>/setup/jest-e2e-setup.ts"],
"globalSetup": "<rootDir>/setup/global-setup.ts",
"globalTeardown": "<rootDir>/setup/global-teardown.ts",
"testTimeout": 60000,
"verbose": true
}

View File

@ -0,0 +1,15 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": "mock.e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
},
"setupFilesAfterEnv": ["<rootDir>/setup/jest-mock-setup.ts"],
"testTimeout": 30000,
"verbose": true
}

View File

@ -0,0 +1,17 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
},
"setupFilesAfterEnv": ["<rootDir>/setup/jest-e2e-setup.ts"],
"globalSetup": "<rootDir>/setup/global-setup.ts",
"globalTeardown": "<rootDir>/setup/global-teardown.ts",
"testTimeout": 30000,
"verbose": true
}

View File

@ -0,0 +1,97 @@
import { execSync } from 'child_process';
import * as path from 'path';
import * as dotenv from 'dotenv';
// Load test environment
dotenv.config({ path: path.resolve(__dirname, '../../.env.test') });
export default async function globalSetup() {
console.log('\n🚀 Starting E2E test environment setup...');
try {
const useDocker = process.env.USE_DOCKER !== 'false';
if (useDocker) {
// Try to use Docker
const dockerAvailable = checkDockerAvailable();
if (dockerAvailable) {
// Start test database container
console.log('📦 Starting test database container...');
execSync('docker-compose -f docker-compose.test.yml up -d', {
cwd: path.resolve(__dirname, '../..'),
stdio: 'inherit',
});
} else {
console.log('⚠️ Docker not available. Assuming database is already running...');
console.log(' Make sure PostgreSQL is running on port 5434');
console.log(' Or set USE_DOCKER=false and configure DATABASE_URL');
}
} else {
console.log('📌 Docker disabled. Using existing database...');
}
// Wait for database to be ready
console.log('⏳ Waiting for database to be ready...');
await waitForDatabase();
// Push Prisma schema to database
console.log('🔄 Pushing Prisma schema to database...');
execSync('npx prisma db push --force-reset --accept-data-loss', {
cwd: path.resolve(__dirname, '../..'),
stdio: 'inherit',
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
});
// Generate Prisma client
console.log('🔧 Generating Prisma client...');
execSync('npx prisma generate', {
cwd: path.resolve(__dirname, '../..'),
stdio: 'inherit',
});
console.log('✅ E2E test environment setup complete!\n');
} catch (error) {
console.error('❌ Failed to setup E2E test environment:', error);
throw error;
}
}
function checkDockerAvailable(): boolean {
try {
execSync('docker info', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
async function waitForDatabase(maxAttempts = 30, delayMs = 1000): Promise<void> {
const { Client } = await import('pg');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
try {
await client.connect();
await client.query('SELECT 1');
await client.end();
console.log(` Database ready after ${attempt} attempt(s)`);
return;
} catch (error) {
await client.end().catch(() => {});
if (attempt === maxAttempts) {
throw new Error(`Database not ready after ${maxAttempts} attempts. Is PostgreSQL running on port 5434?`);
}
if (attempt % 5 === 0) {
console.log(` Still waiting for database... (attempt ${attempt}/${maxAttempts})`);
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}

View File

@ -0,0 +1,29 @@
import { execSync } from 'child_process';
import * as path from 'path';
export default async function globalTeardown() {
console.log('\n🧹 Cleaning up E2E test environment...');
const useDocker = process.env.USE_DOCKER !== 'false';
if (useDocker) {
try {
// Check if Docker is available
execSync('docker info', { stdio: 'ignore' });
// Stop and remove test database container
console.log('📦 Stopping test database container...');
execSync('docker-compose -f docker-compose.test.yml down -v', {
cwd: path.resolve(__dirname, '../..'),
stdio: 'inherit',
});
console.log('✅ E2E test environment cleanup complete!\n');
} catch (error) {
console.log('⚠️ Docker not available or container already stopped');
}
} else {
console.log('📌 Docker disabled. Skipping container cleanup...');
console.log('✅ E2E test environment cleanup complete!\n');
}
}

View File

@ -0,0 +1,36 @@
import * as path from 'path';
import * as dotenv from 'dotenv';
// Load test environment variables before any tests run
dotenv.config({ path: path.resolve(__dirname, '../../.env.test') });
// Set test environment
process.env.NODE_ENV = 'test';
process.env.APP_ENV = 'test';
// Increase timeout for E2E tests
jest.setTimeout(30000);
// Add custom matchers if needed
expect.extend({
toBeValidUUID(received: string) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pass = uuidRegex.test(received);
return {
pass,
message: () =>
pass
? `expected ${received} not to be a valid UUID`
: `expected ${received} to be a valid UUID`,
};
},
});
// Extend Jest types
declare global {
namespace jest {
interface Matchers<R> {
toBeValidUUID(): R;
}
}
}

View File

@ -0,0 +1,10 @@
// Mock E2E test setup - no database needed
process.env.NODE_ENV = 'test';
process.env.APP_ENV = 'test';
process.env.SERVICE_JWT_SECRET = 'test-super-secret-service-jwt-key-for-e2e-testing';
process.env.BACKUP_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
process.env.BACKUP_ENCRYPTION_KEY_ID = 'test-key-v1';
process.env.ALLOWED_SERVICES = 'identity-service,recovery-service';
// Increase timeout for E2E tests
jest.setTimeout(30000);

View File

@ -0,0 +1,50 @@
import { PrismaClient } from '@prisma/client';
let prisma: PrismaClient | null = null;
export function getPrismaClient(): PrismaClient {
if (!prisma) {
prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
}
return prisma;
}
export async function cleanDatabase(): Promise<void> {
const client = getPrismaClient();
// Delete in correct order due to foreign key constraints
await client.shareAccessLog.deleteMany();
await client.backupShare.deleteMany();
}
export async function disconnectDatabase(): Promise<void> {
if (prisma) {
await prisma.$disconnect();
prisma = null;
}
}
export async function seedTestData(): Promise<void> {
const client = getPrismaClient();
// Seed some test data if needed
await client.backupShare.create({
data: {
userId: BigInt(999999),
accountSequence: BigInt(999999),
publicKey: '02' + 'f'.repeat(64),
partyIndex: 2,
threshold: 2,
totalParties: 3,
encryptedShareData: 'seed-encrypted-data',
encryptionKeyId: 'test-key-v1',
status: 'ACTIVE',
},
});
}

View File

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["./**/*.ts"]
}

View File

@ -0,0 +1,212 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BackupShareController } from '../../../src/api/controllers/backup-share.controller';
import { BackupShareApplicationService } from '../../../src/application/services/backup-share-application.service';
import { ServiceAuthGuard } from '../../../src/shared/guards/service-auth.guard';
import { ConfigService } from '@nestjs/config';
import {
createStoreSharePayload,
createRetrieveSharePayload,
createRevokeSharePayload,
generatePublicKey,
} from '../../utils/test-utils';
describe('BackupShareController', () => {
let controller: BackupShareController;
let mockApplicationService: jest.Mocked<BackupShareApplicationService>;
const mockRequest = {
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
};
beforeEach(async () => {
mockApplicationService = {
storeBackupShare: jest.fn(),
getBackupShare: jest.fn(),
revokeShare: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
controllers: [BackupShareController],
providers: [
{ provide: BackupShareApplicationService, useValue: mockApplicationService },
{
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue('test-secret'),
},
},
],
})
.overrideGuard(ServiceAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<BackupShareController>(BackupShareController);
});
describe('storeShare', () => {
it('should store a backup share successfully', async () => {
const dto = createStoreSharePayload();
mockApplicationService.storeBackupShare.mockResolvedValue({ shareId: '1' });
const result = await controller.storeShare(dto, mockRequest);
expect(result.success).toBe(true);
expect(result.shareId).toBe('1');
expect(result.message).toBe('Backup share stored successfully');
expect(mockApplicationService.storeBackupShare).toHaveBeenCalledWith(
expect.objectContaining({
userId: dto.userId,
accountSequence: dto.accountSequence,
publicKey: dto.publicKey,
encryptedShareData: dto.encryptedShareData,
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
}),
);
});
it('should pass optional threshold and totalParties', async () => {
const dto = createStoreSharePayload({
threshold: 3,
totalParties: 5,
});
mockApplicationService.storeBackupShare.mockResolvedValue({ shareId: '2' });
await controller.storeShare(dto, mockRequest);
expect(mockApplicationService.storeBackupShare).toHaveBeenCalledWith(
expect.objectContaining({
threshold: 3,
totalParties: 5,
}),
);
});
it('should handle different user IDs', async () => {
const dto = createStoreSharePayload({ userId: '999888777' });
mockApplicationService.storeBackupShare.mockResolvedValue({ shareId: '3' });
await controller.storeShare(dto, mockRequest);
expect(mockApplicationService.storeBackupShare).toHaveBeenCalledWith(
expect.objectContaining({
userId: '999888777',
}),
);
});
});
describe('retrieveShare', () => {
it('should retrieve a backup share successfully', async () => {
const dto = createRetrieveSharePayload();
mockApplicationService.getBackupShare.mockResolvedValue({
encryptedShareData: 'decrypted-data',
partyIndex: 2,
publicKey: dto.publicKey,
});
const result = await controller.retrieveShare(dto, mockRequest);
expect(result.success).toBe(true);
expect(result.encryptedShareData).toBe('decrypted-data');
expect(result.partyIndex).toBe(2);
expect(result.publicKey).toBe(dto.publicKey);
});
it('should pass deviceId when provided', async () => {
const dto = createRetrieveSharePayload({ deviceId: 'device-123' });
mockApplicationService.getBackupShare.mockResolvedValue({
encryptedShareData: 'data',
partyIndex: 2,
publicKey: dto.publicKey,
});
await controller.retrieveShare(dto, mockRequest);
expect(mockApplicationService.getBackupShare).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'device-123',
}),
);
});
it('should include recovery token in query', async () => {
const dto = createRetrieveSharePayload({ recoveryToken: 'special-token-123' });
mockApplicationService.getBackupShare.mockResolvedValue({
encryptedShareData: 'data',
partyIndex: 2,
publicKey: dto.publicKey,
});
await controller.retrieveShare(dto, mockRequest);
expect(mockApplicationService.getBackupShare).toHaveBeenCalledWith(
expect.objectContaining({
recoveryToken: 'special-token-123',
}),
);
});
});
describe('revokeShare', () => {
it('should revoke a backup share successfully', async () => {
const dto = createRevokeSharePayload();
mockApplicationService.revokeShare.mockResolvedValue(undefined);
const result = await controller.revokeShare(dto, mockRequest);
expect(result.success).toBe(true);
expect(result.message).toBe('Backup share revoked successfully');
});
it('should pass correct reason to service', async () => {
const dto = createRevokeSharePayload({ reason: 'SECURITY_BREACH' });
mockApplicationService.revokeShare.mockResolvedValue(undefined);
await controller.revokeShare(dto, mockRequest);
expect(mockApplicationService.revokeShare).toHaveBeenCalledWith(
expect.objectContaining({
reason: 'SECURITY_BREACH',
}),
);
});
it('should handle different revoke reasons', async () => {
const reasons = ['ROTATION', 'ACCOUNT_CLOSED', 'SECURITY_BREACH', 'USER_REQUEST'];
for (const reason of reasons) {
const dto = createRevokeSharePayload({
reason,
publicKey: generatePublicKey(reason[0].toLowerCase()),
});
mockApplicationService.revokeShare.mockResolvedValue(undefined);
await controller.revokeShare(dto, mockRequest);
expect(mockApplicationService.revokeShare).toHaveBeenCalledWith(
expect.objectContaining({ reason }),
);
}
});
});
describe('request context', () => {
it('should extract source service from request', async () => {
const dto = createStoreSharePayload();
const customRequest = { sourceService: 'recovery-service', sourceIp: '10.0.0.1' };
mockApplicationService.storeBackupShare.mockResolvedValue({ shareId: '1' });
await controller.storeShare(dto, customRequest);
expect(mockApplicationService.storeBackupShare).toHaveBeenCalledWith(
expect.objectContaining({
sourceService: 'recovery-service',
sourceIp: '10.0.0.1',
}),
);
});
});
});

View File

@ -0,0 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from '../../../src/api/controllers/health.controller';
import { PrismaService } from '../../../src/infrastructure/persistence/prisma/prisma.service';
describe('HealthController', () => {
let controller: HealthController;
let mockPrisma: { $queryRaw: jest.Mock };
beforeEach(async () => {
mockPrisma = {
$queryRaw: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [{ provide: PrismaService, useValue: mockPrisma }],
}).compile();
controller = module.get<HealthController>(HealthController);
});
describe('check', () => {
it('should return ok status', async () => {
const result = await controller.check();
expect(result.status).toBe('ok');
expect(result.service).toBe('backup-service');
expect(result.timestamp).toBeDefined();
});
it('should return valid timestamp', async () => {
const result = await controller.check();
const timestamp = new Date(result.timestamp);
expect(timestamp.getTime()).not.toBeNaN();
expect(timestamp.getTime()).toBeLessThanOrEqual(Date.now());
});
});
describe('readiness', () => {
it('should return ready when database is connected', async () => {
mockPrisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await controller.readiness();
expect(result.status).toBe('ready');
expect(result.database).toBe('connected');
expect(result.timestamp).toBeDefined();
});
it('should return not ready when database is disconnected', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('Connection refused'));
const result = await controller.readiness();
expect(result.status).toBe('not ready');
expect(result.database).toBe('disconnected');
expect(result.error).toBe('Connection refused');
});
it('should handle timeout errors', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('Query timeout'));
const result = await controller.readiness();
expect(result.status).toBe('not ready');
expect(result.error).toBe('Query timeout');
});
});
describe('liveness', () => {
it('should return alive status', async () => {
const result = await controller.liveness();
expect(result.status).toBe('alive');
expect(result.timestamp).toBeDefined();
});
it('should always succeed regardless of database state', async () => {
// Even if database check would fail, liveness should succeed
mockPrisma.$queryRaw.mockRejectedValue(new Error('DB Error'));
const result = await controller.liveness();
expect(result.status).toBe('alive');
});
});
});

View File

@ -0,0 +1,134 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { GetBackupShareHandler } from '../../../src/application/queries/get-backup-share/get-backup-share.handler';
import { GetBackupShareQuery } from '../../../src/application/queries/get-backup-share/get-backup-share.query';
import { BACKUP_SHARE_REPOSITORY, BackupShare, BackupShareStatus } from '../../../src/domain';
import { AesEncryptionService } from '../../../src/infrastructure/crypto/aes-encryption.service';
import { AuditLogRepository } from '../../../src/infrastructure/persistence/repositories/audit-log.repository';
import { ApplicationError } from '../../../src/application/errors/application.error';
describe('GetBackupShareHandler', () => {
let handler: GetBackupShareHandler;
let mockRepository: any;
let mockEncryptionService: any;
let mockAuditLogRepository: any;
const createMockShare = (status: BackupShareStatus = BackupShareStatus.ACTIVE) => {
const share = BackupShare.create({
userId: BigInt(12345),
accountSequence: BigInt(1001),
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'double-encrypted-data',
encryptionKeyId: 'key-v1',
});
share.setShareId(BigInt(1));
if (status === BackupShareStatus.REVOKED) {
share.revoke('TEST');
}
return share;
};
beforeEach(async () => {
mockRepository = {
findByUserIdAndPublicKey: jest.fn(),
save: jest.fn().mockImplementation((share) => Promise.resolve(share)),
};
mockEncryptionService = {
decrypt: jest.fn().mockResolvedValue('original-encrypted-data'),
};
mockAuditLogRepository = {
log: jest.fn().mockResolvedValue(undefined),
countRetrievesByUserToday: jest.fn().mockResolvedValue(0),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
GetBackupShareHandler,
{ provide: BACKUP_SHARE_REPOSITORY, useValue: mockRepository },
{ provide: AesEncryptionService, useValue: mockEncryptionService },
{ provide: AuditLogRepository, useValue: mockAuditLogRepository },
{
provide: ConfigService,
useValue: {
get: (key: string) => {
if (key === 'MAX_RETRIEVE_PER_DAY') return 3;
return undefined;
},
},
},
],
}).compile();
handler = module.get<GetBackupShareHandler>(GetBackupShareHandler);
});
it('should be defined', () => {
expect(handler).toBeDefined();
});
describe('execute', () => {
const validQuery = new GetBackupShareQuery(
'12345',
'02' + 'a'.repeat(64),
'valid-recovery-token',
'identity-service',
'127.0.0.1',
);
it('should retrieve backup share successfully', async () => {
const mockShare = createMockShare();
mockRepository.findByUserIdAndPublicKey.mockResolvedValue(mockShare);
const result = await handler.execute(validQuery);
expect(result.encryptedShareData).toBe('original-encrypted-data');
expect(result.partyIndex).toBe(2);
expect(result.publicKey).toBe(validQuery.publicKey);
expect(mockEncryptionService.decrypt).toHaveBeenCalledWith(
'double-encrypted-data',
'key-v1',
);
expect(mockAuditLogRepository.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'RETRIEVE',
success: true,
}),
);
});
it('should throw error if share not found', async () => {
mockRepository.findByUserIdAndPublicKey.mockResolvedValue(null);
await expect(handler.execute(validQuery)).rejects.toThrow(ApplicationError);
await expect(handler.execute(validQuery)).rejects.toThrow('Backup share not found');
});
it('should throw error if share is not active', async () => {
const revokedShare = createMockShare(BackupShareStatus.REVOKED);
mockRepository.findByUserIdAndPublicKey.mockResolvedValue(revokedShare);
await expect(handler.execute(validQuery)).rejects.toThrow(ApplicationError);
await expect(handler.execute(validQuery)).rejects.toThrow('Backup share is not active');
});
it('should throw error if rate limit exceeded', async () => {
mockAuditLogRepository.countRetrievesByUserToday.mockResolvedValue(3);
await expect(handler.execute(validQuery)).rejects.toThrow(ApplicationError);
await expect(handler.execute(validQuery)).rejects.toThrow('Rate limit exceeded');
});
it('should increment access count', async () => {
const mockShare = createMockShare();
const initialAccessCount = mockShare.accessCount;
mockRepository.findByUserIdAndPublicKey.mockResolvedValue(mockShare);
await handler.execute(validQuery);
expect(mockShare.accessCount).toBe(initialAccessCount + 1);
expect(mockRepository.save).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,127 @@
import { Test, TestingModule } from '@nestjs/testing';
import { StoreBackupShareHandler } from '../../../src/application/commands/store-backup-share/store-backup-share.handler';
import { StoreBackupShareCommand } from '../../../src/application/commands/store-backup-share/store-backup-share.command';
import { BACKUP_SHARE_REPOSITORY } from '../../../src/domain';
import { AesEncryptionService } from '../../../src/infrastructure/crypto/aes-encryption.service';
import { AuditLogRepository } from '../../../src/infrastructure/persistence/repositories/audit-log.repository';
import { ApplicationError } from '../../../src/application/errors/application.error';
describe('StoreBackupShareHandler', () => {
let handler: StoreBackupShareHandler;
let mockRepository: any;
let mockEncryptionService: any;
let mockAuditLogRepository: any;
beforeEach(async () => {
mockRepository = {
findByUserId: jest.fn(),
findByPublicKey: jest.fn(),
save: jest.fn(),
};
mockEncryptionService = {
encrypt: jest.fn().mockResolvedValue({
encrypted: 'double-encrypted-data',
keyId: 'key-v1',
}),
};
mockAuditLogRepository = {
log: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
StoreBackupShareHandler,
{ provide: BACKUP_SHARE_REPOSITORY, useValue: mockRepository },
{ provide: AesEncryptionService, useValue: mockEncryptionService },
{ provide: AuditLogRepository, useValue: mockAuditLogRepository },
],
}).compile();
handler = module.get<StoreBackupShareHandler>(StoreBackupShareHandler);
});
it('should be defined', () => {
expect(handler).toBeDefined();
});
describe('execute', () => {
const validCommand = new StoreBackupShareCommand(
'12345',
1001,
'02' + 'a'.repeat(64),
'encrypted-share-data',
'identity-service',
'127.0.0.1',
);
it('should store backup share successfully', async () => {
mockRepository.findByUserId.mockResolvedValue(null);
mockRepository.findByPublicKey.mockResolvedValue(null);
mockRepository.save.mockImplementation((share: any) => {
share.setShareId(BigInt(1));
return Promise.resolve(share);
});
const result = await handler.execute(validCommand);
expect(result.shareId).toBe('1');
expect(mockRepository.findByUserId).toHaveBeenCalledWith(BigInt(12345));
expect(mockRepository.findByPublicKey).toHaveBeenCalledWith(validCommand.publicKey);
expect(mockEncryptionService.encrypt).toHaveBeenCalledWith(validCommand.encryptedShareData);
expect(mockRepository.save).toHaveBeenCalled();
expect(mockAuditLogRepository.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'STORE',
success: true,
}),
);
});
it('should throw error if share already exists for user', async () => {
mockRepository.findByUserId.mockResolvedValue({ userId: BigInt(12345) });
await expect(handler.execute(validCommand)).rejects.toThrow(ApplicationError);
await expect(handler.execute(validCommand)).rejects.toThrow(
'Backup share already exists for this user',
);
});
it('should throw error if share already exists for public key', async () => {
mockRepository.findByUserId.mockResolvedValue(null);
mockRepository.findByPublicKey.mockResolvedValue({ publicKey: validCommand.publicKey });
await expect(handler.execute(validCommand)).rejects.toThrow(ApplicationError);
await expect(handler.execute(validCommand)).rejects.toThrow(
'Backup share already exists for this public key',
);
});
it('should use custom threshold and totalParties if provided', async () => {
mockRepository.findByUserId.mockResolvedValue(null);
mockRepository.findByPublicKey.mockResolvedValue(null);
mockRepository.save.mockImplementation((share: any) => {
share.setShareId(BigInt(1));
expect(share.threshold).toBe(3);
expect(share.totalParties).toBe(5);
return Promise.resolve(share);
});
const commandWithCustomParams = new StoreBackupShareCommand(
'12345',
1001,
'02' + 'a'.repeat(64),
'encrypted-share-data',
'identity-service',
'127.0.0.1',
3, // threshold
5, // totalParties
);
await handler.execute(commandWithCustomParams);
expect(mockRepository.save).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,170 @@
import {
BackupShare,
BackupShareStatus,
} from '../../../src/domain/entities/backup-share.entity';
import { DomainError } from '../../../src/domain/errors/domain.error';
describe('BackupShare Entity', () => {
const validParams = {
userId: BigInt(12345),
accountSequence: BigInt(1001),
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-data-base64',
encryptionKeyId: 'key-v1',
};
describe('create', () => {
it('should create a backup share with valid parameters', () => {
const share = BackupShare.create(validParams);
expect(share.userId).toBe(validParams.userId);
expect(share.accountSequence).toBe(validParams.accountSequence);
expect(share.publicKey).toBe(validParams.publicKey);
expect(share.encryptedShareData).toBe(validParams.encryptedShareData);
expect(share.encryptionKeyId).toBe(validParams.encryptionKeyId);
expect(share.partyIndex).toBe(2);
expect(share.threshold).toBe(2);
expect(share.totalParties).toBe(3);
expect(share.status).toBe(BackupShareStatus.ACTIVE);
expect(share.accessCount).toBe(0);
expect(share.shareId).toBeNull();
});
it('should allow custom threshold and totalParties', () => {
const share = BackupShare.create({
...validParams,
threshold: 3,
totalParties: 5,
});
expect(share.threshold).toBe(3);
expect(share.totalParties).toBe(5);
});
});
describe('recordAccess', () => {
it('should increment access count for active share', () => {
const share = BackupShare.create(validParams);
share.recordAccess();
expect(share.accessCount).toBe(1);
expect(share.lastAccessedAt).not.toBeNull();
});
it('should throw error when accessing revoked share', () => {
const share = BackupShare.create(validParams);
share.revoke('TEST_REASON');
expect(() => share.recordAccess()).toThrow(DomainError);
expect(() => share.recordAccess()).toThrow(
'Cannot access revoked or rotated share',
);
});
});
describe('revoke', () => {
it('should revoke an active share', () => {
const share = BackupShare.create(validParams);
share.revoke('SECURITY_BREACH');
expect(share.status).toBe(BackupShareStatus.REVOKED);
expect(share.revokedAt).not.toBeNull();
});
it('should throw error when revoking already revoked share', () => {
const share = BackupShare.create(validParams);
share.revoke('FIRST_REVOKE');
expect(() => share.revoke('SECOND_REVOKE')).toThrow(DomainError);
expect(() => share.revoke('SECOND_REVOKE')).toThrow(
'Share already revoked',
);
});
});
describe('rotate', () => {
it('should rotate encryption for active share', () => {
const share = BackupShare.create(validParams);
const newEncryptedData = 'new-encrypted-data';
const newKeyId = 'key-v2';
share.rotate(newEncryptedData, newKeyId);
expect(share.encryptedShareData).toBe(newEncryptedData);
expect(share.encryptionKeyId).toBe(newKeyId);
expect(share.status).toBe(BackupShareStatus.ACTIVE);
});
it('should throw error when rotating revoked share', () => {
const share = BackupShare.create(validParams);
share.revoke('TEST');
expect(() => share.rotate('new-data', 'key-v2')).toThrow(DomainError);
expect(() => share.rotate('new-data', 'key-v2')).toThrow(
'Cannot rotate revoked share',
);
});
});
describe('isActive', () => {
it('should return true for active share', () => {
const share = BackupShare.create(validParams);
expect(share.isActive()).toBe(true);
});
it('should return false for revoked share', () => {
const share = BackupShare.create(validParams);
share.revoke('TEST');
expect(share.isActive()).toBe(false);
});
});
describe('setShareId', () => {
it('should set share ID when not already set', () => {
const share = BackupShare.create(validParams);
share.setShareId(BigInt(1));
expect(share.shareId).toBe(BigInt(1));
});
it('should throw error when share ID already set', () => {
const share = BackupShare.create(validParams);
share.setShareId(BigInt(1));
expect(() => share.setShareId(BigInt(2))).toThrow(DomainError);
expect(() => share.setShareId(BigInt(2))).toThrow('Share ID already set');
});
});
describe('toProps', () => {
it('should return all properties', () => {
const share = BackupShare.create(validParams);
const props = share.toProps();
expect(props.userId).toBe(validParams.userId);
expect(props.accountSequence).toBe(validParams.accountSequence);
expect(props.publicKey).toBe(validParams.publicKey);
expect(props.status).toBe(BackupShareStatus.ACTIVE);
expect(props.partyIndex).toBe(2);
});
});
describe('reconstitute', () => {
it('should reconstitute from props', () => {
const original = BackupShare.create(validParams);
original.setShareId(BigInt(123));
const props = original.toProps();
const reconstituted = BackupShare.reconstitute(props);
expect(reconstituted.shareId).toBe(props.shareId);
expect(reconstituted.userId).toBe(props.userId);
expect(reconstituted.status).toBe(props.status);
});
});
});

View File

@ -0,0 +1,178 @@
import { ShareId } from '../../../src/domain/value-objects/share-id.vo';
import { EncryptedData } from '../../../src/domain/value-objects/encrypted-data.vo';
import { DomainError } from '../../../src/domain/errors/domain.error';
describe('Value Objects', () => {
describe('ShareId', () => {
describe('create', () => {
it('should create ShareId from bigint', () => {
const shareId = ShareId.create(BigInt(123));
expect(shareId.value).toBe(BigInt(123));
});
it('should create ShareId from string', () => {
const shareId = ShareId.create('456');
expect(shareId.value).toBe(BigInt(456));
});
it('should create ShareId from number', () => {
const shareId = ShareId.create(789);
expect(shareId.value).toBe(BigInt(789));
});
it('should throw error for zero', () => {
expect(() => ShareId.create(0)).toThrow(DomainError);
expect(() => ShareId.create(0)).toThrow('ShareId must be a positive number');
});
it('should throw error for negative number', () => {
expect(() => ShareId.create(-1)).toThrow(DomainError);
expect(() => ShareId.create(BigInt(-100))).toThrow(DomainError);
});
});
describe('toString', () => {
it('should return string representation', () => {
const shareId = ShareId.create(12345);
expect(shareId.toString()).toBe('12345');
});
});
describe('equals', () => {
it('should return true for equal values', () => {
const id1 = ShareId.create(100);
const id2 = ShareId.create(100);
expect(id1.equals(id2)).toBe(true);
});
it('should return false for different values', () => {
const id1 = ShareId.create(100);
const id2 = ShareId.create(200);
expect(id1.equals(id2)).toBe(false);
});
});
});
describe('EncryptedData', () => {
describe('create', () => {
it('should create EncryptedData with valid params', () => {
const data = EncryptedData.create({
ciphertext: 'encrypted-content',
iv: 'initialization-vector',
authTag: 'authentication-tag',
keyId: 'key-v1',
});
expect(data.ciphertext).toBe('encrypted-content');
expect(data.iv).toBe('initialization-vector');
expect(data.authTag).toBe('authentication-tag');
expect(data.keyId).toBe('key-v1');
});
it('should throw error for empty ciphertext', () => {
expect(() =>
EncryptedData.create({
ciphertext: '',
iv: 'iv',
authTag: 'tag',
keyId: 'key',
}),
).toThrow(DomainError);
expect(() =>
EncryptedData.create({
ciphertext: '',
iv: 'iv',
authTag: 'tag',
keyId: 'key',
}),
).toThrow('Ciphertext cannot be empty');
});
it('should throw error for empty IV', () => {
expect(() =>
EncryptedData.create({
ciphertext: 'cipher',
iv: '',
authTag: 'tag',
keyId: 'key',
}),
).toThrow('IV cannot be empty');
});
it('should throw error for empty auth tag', () => {
expect(() =>
EncryptedData.create({
ciphertext: 'cipher',
iv: 'iv',
authTag: '',
keyId: 'key',
}),
).toThrow('Auth tag cannot be empty');
});
it('should throw error for empty key ID', () => {
expect(() =>
EncryptedData.create({
ciphertext: 'cipher',
iv: 'iv',
authTag: 'tag',
keyId: '',
}),
).toThrow('Key ID cannot be empty');
});
});
describe('fromSerializedString', () => {
it('should parse valid serialized string', () => {
const serialized = 'ciphertext:iv:authTag';
const data = EncryptedData.fromSerializedString(serialized, 'key-v1');
expect(data.ciphertext).toBe('ciphertext');
expect(data.iv).toBe('iv');
expect(data.authTag).toBe('authTag');
expect(data.keyId).toBe('key-v1');
});
it('should throw error for invalid format (too few parts)', () => {
expect(() => EncryptedData.fromSerializedString('only:two', 'key')).toThrow(
'Invalid encrypted data format',
);
});
it('should throw error for invalid format (too many parts)', () => {
expect(() =>
EncryptedData.fromSerializedString('one:two:three:four', 'key'),
).toThrow('Invalid encrypted data format');
});
});
describe('toSerializedString', () => {
it('should serialize to correct format', () => {
const data = EncryptedData.create({
ciphertext: 'cipher123',
iv: 'iv456',
authTag: 'tag789',
keyId: 'key-v2',
});
expect(data.toSerializedString()).toBe('cipher123:iv456:tag789');
});
it('should be reversible', () => {
const original = EncryptedData.create({
ciphertext: 'test-cipher',
iv: 'test-iv',
authTag: 'test-tag',
keyId: 'key-v1',
});
const serialized = original.toSerializedString();
const restored = EncryptedData.fromSerializedString(serialized, 'key-v1');
expect(restored.ciphertext).toBe(original.ciphertext);
expect(restored.iv).toBe(original.iv);
expect(restored.authTag).toBe(original.authTag);
});
});
});
});

View File

@ -0,0 +1,142 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { AesEncryptionService } from '../../../src/infrastructure/crypto/aes-encryption.service';
describe('AesEncryptionService', () => {
let service: AesEncryptionService;
const testKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const testKeyId = 'test-key-v1';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AesEncryptionService,
{
provide: ConfigService,
useValue: {
get: (key: string) => {
switch (key) {
case 'BACKUP_ENCRYPTION_KEY':
return testKey;
case 'BACKUP_ENCRYPTION_KEY_ID':
return testKeyId;
default:
return undefined;
}
},
},
},
],
}).compile();
service = module.get<AesEncryptionService>(AesEncryptionService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('encrypt', () => {
it('should encrypt plaintext successfully', async () => {
const plaintext = 'Hello, World!';
const result = await service.encrypt(plaintext);
expect(result.encrypted).toBeDefined();
expect(result.keyId).toBe(testKeyId);
expect(result.encrypted).not.toBe(plaintext);
// Should contain 3 parts separated by ':'
expect(result.encrypted.split(':').length).toBe(3);
});
it('should produce different ciphertext for same plaintext (due to random IV)', async () => {
const plaintext = 'Same message';
const result1 = await service.encrypt(plaintext);
const result2 = await service.encrypt(plaintext);
expect(result1.encrypted).not.toBe(result2.encrypted);
});
});
describe('decrypt', () => {
it('should decrypt ciphertext back to original plaintext', async () => {
const plaintext = 'Secret message for testing';
const { encrypted, keyId } = await service.encrypt(plaintext);
const decrypted = await service.decrypt(encrypted, keyId);
expect(decrypted).toBe(plaintext);
});
it('should handle special characters', async () => {
const plaintext = 'Special chars: !@#$%^&*()_+-={}[]|\\:";\'<>?,./';
const { encrypted, keyId } = await service.encrypt(plaintext);
const decrypted = await service.decrypt(encrypted, keyId);
expect(decrypted).toBe(plaintext);
});
it('should handle unicode characters', async () => {
const plaintext = 'Unicode: 你好世界 🌍 مرحبا';
const { encrypted, keyId } = await service.encrypt(plaintext);
const decrypted = await service.decrypt(encrypted, keyId);
expect(decrypted).toBe(plaintext);
});
it('should handle large payloads', async () => {
const plaintext = 'a'.repeat(10000);
const { encrypted, keyId } = await service.encrypt(plaintext);
const decrypted = await service.decrypt(encrypted, keyId);
expect(decrypted).toBe(plaintext);
});
it('should throw error for invalid format', async () => {
await expect(service.decrypt('invalid-format', testKeyId)).rejects.toThrow(
'Invalid encrypted data format',
);
});
it('should throw error for non-existent key', async () => {
const { encrypted } = await service.encrypt('test');
await expect(service.decrypt(encrypted, 'non-existent-key')).rejects.toThrow(
'Decryption key not found',
);
});
});
describe('addKey', () => {
it('should add new key for rotation', async () => {
const newKeyId = 'key-v2';
const newKey = 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210';
service.addKey(newKeyId, newKey);
// Encrypt with original key
const { encrypted: encrypted1 } = await service.encrypt('test');
// Should still be able to decrypt (uses current key)
const decrypted = await service.decrypt(encrypted1, testKeyId);
expect(decrypted).toBe('test');
});
it('should throw error for invalid key length', () => {
expect(() => service.addKey('bad-key', 'too-short')).toThrow(
'Key must be 256 bits',
);
});
});
describe('getCurrentKeyId', () => {
it('should return current key ID', () => {
expect(service.getCurrentKeyId()).toBe(testKeyId);
});
});
});

View File

@ -0,0 +1,192 @@
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of, throwError } from 'rxjs';
import { AuditLogInterceptor } from '../../../src/shared/interceptors/audit-log.interceptor';
describe('AuditLogInterceptor', () => {
let interceptor: AuditLogInterceptor;
const createMockContext = (
method: string = 'POST',
url: string = '/backup-share/store',
body: any = {},
sourceService: string = 'identity-service',
sourceIp: string = '127.0.0.1',
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => ({
method,
url,
body,
sourceService,
sourceIp,
}),
}),
} as ExecutionContext;
};
const createMockCallHandler = (response: any = {}): CallHandler => ({
handle: () => of(response),
});
const createErrorCallHandler = (error: Error): CallHandler => ({
handle: () => throwError(() => error),
});
beforeEach(() => {
interceptor = new AuditLogInterceptor();
});
describe('intercept', () => {
it('should allow request to proceed', (done) => {
const context = createMockContext();
const handler = createMockCallHandler({ success: true });
interceptor.intercept(context, handler).subscribe({
next: (response) => {
expect(response).toEqual({ success: true });
done();
},
});
});
it('should not modify the response', (done) => {
const context = createMockContext();
const originalResponse = { shareId: '123', success: true };
const handler = createMockCallHandler(originalResponse);
interceptor.intercept(context, handler).subscribe({
next: (response) => {
expect(response).toEqual(originalResponse);
done();
},
});
});
it('should propagate errors', (done) => {
const context = createMockContext();
const error = new Error('Test error');
const handler = createErrorCallHandler(error);
interceptor.intercept(context, handler).subscribe({
error: (err) => {
expect(err).toBe(error);
done();
},
});
});
it('should sanitize sensitive fields in body', () => {
const sensitiveBody = {
userId: '12345',
encryptedShareData: 'secret-data',
recoveryToken: 'secret-token',
password: 'secret-password',
publicKey: '02abc...',
};
// Access the private method through prototype
const sanitized = (interceptor as any).sanitizeBody(sensitiveBody);
expect(sanitized.userId).toBe('12345');
expect(sanitized.publicKey).toBe('02abc...');
expect(sanitized.encryptedShareData).toBe('[REDACTED]');
expect(sanitized.recoveryToken).toBe('[REDACTED]');
expect(sanitized.password).toBe('[REDACTED]');
});
it('should handle null body', () => {
const sanitized = (interceptor as any).sanitizeBody(null);
expect(sanitized).toBeNull();
});
it('should handle undefined body', () => {
const sanitized = (interceptor as any).sanitizeBody(undefined);
expect(sanitized).toBeUndefined();
});
it('should handle empty body', () => {
const sanitized = (interceptor as any).sanitizeBody({});
expect(sanitized).toEqual({});
});
it('should not modify original body object', () => {
const originalBody = {
userId: '12345',
encryptedShareData: 'secret-data',
};
const bodyCopy = { ...originalBody };
(interceptor as any).sanitizeBody(originalBody);
expect(originalBody).toEqual(bodyCopy);
});
});
describe('logging behavior', () => {
it('should handle different HTTP methods', (done) => {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
let completed = 0;
methods.forEach((method) => {
const context = createMockContext(method, '/test');
const handler = createMockCallHandler();
interceptor.intercept(context, handler).subscribe({
complete: () => {
completed++;
if (completed === methods.length) {
done();
}
},
});
});
});
it('should handle different URL paths', (done) => {
const paths = [
'/backup-share/store',
'/backup-share/retrieve',
'/backup-share/revoke',
'/health',
];
let completed = 0;
paths.forEach((url) => {
const context = createMockContext('GET', url);
const handler = createMockCallHandler();
interceptor.intercept(context, handler).subscribe({
complete: () => {
completed++;
if (completed === paths.length) {
done();
}
},
});
});
});
it('should handle unknown source service', (done) => {
const context = createMockContext('POST', '/test', {}, undefined, '127.0.0.1');
const handler = createMockCallHandler();
interceptor.intercept(context, handler).subscribe({
complete: () => {
done();
},
});
});
it('should handle unknown source IP', (done) => {
const context = createMockContext('POST', '/test', {}, 'identity-service', undefined);
const handler = createMockCallHandler();
interceptor.intercept(context, handler).subscribe({
complete: () => {
done();
},
});
});
});
});

View File

@ -0,0 +1,215 @@
import { HttpException, HttpStatus, ArgumentsHost } from '@nestjs/common';
import { GlobalExceptionFilter } from '../../../src/shared/filters/global-exception.filter';
import { ApplicationError } from '../../../src/application/errors/application.error';
import { DomainError } from '../../../src/domain/errors/domain.error';
describe('GlobalExceptionFilter', () => {
let filter: GlobalExceptionFilter;
let mockResponse: any;
let mockRequest: any;
let mockHost: ArgumentsHost;
beforeEach(() => {
filter = new GlobalExceptionFilter();
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
mockRequest = {
method: 'POST',
url: '/backup-share/store',
};
mockHost = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => mockRequest,
}),
} as ArgumentsHost;
});
describe('catch', () => {
it('should handle HttpException', () => {
const exception = new HttpException('Bad Request', HttpStatus.BAD_REQUEST);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Bad Request',
code: 'BAD_REQUEST',
}),
);
});
it('should handle HttpException with object response', () => {
const exception = new HttpException(
{ message: 'Validation failed', errors: ['field is required'] },
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Validation failed',
}),
);
});
it('should handle ApplicationError with SHARE_NOT_FOUND', () => {
const exception = new ApplicationError('Backup share not found', 'SHARE_NOT_FOUND');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Backup share not found',
code: 'SHARE_NOT_FOUND',
}),
);
});
it('should handle ApplicationError with SHARE_ALREADY_EXISTS', () => {
const exception = new ApplicationError('Share already exists', 'SHARE_ALREADY_EXISTS');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'SHARE_ALREADY_EXISTS',
}),
);
});
it('should handle ApplicationError with RATE_LIMIT_EXCEEDED', () => {
const exception = new ApplicationError('Rate limit exceeded', 'RATE_LIMIT_EXCEEDED');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.TOO_MANY_REQUESTS);
});
it('should handle ApplicationError with SHARE_NOT_ACTIVE', () => {
const exception = new ApplicationError('Share is not active', 'SHARE_NOT_ACTIVE');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
});
it('should handle DomainError', () => {
const exception = new DomainError('Cannot access revoked share');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Cannot access revoked share',
code: 'DOMAIN_ERROR',
}),
);
});
it('should handle generic Error', () => {
const exception = new Error('Something went wrong');
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Something went wrong',
code: 'INTERNAL_ERROR',
}),
);
});
it('should handle unknown exception', () => {
const exception = 'Unknown error';
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'INTERNAL_ERROR',
}),
);
});
it('should include timestamp in response', () => {
const exception = new Error('Test');
filter.catch(exception, mockHost);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
timestamp: expect.any(String),
}),
);
});
it('should include path in response', () => {
const exception = new Error('Test');
filter.catch(exception, mockHost);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
path: '/backup-share/store',
}),
);
});
it('should handle UNAUTHORIZED status', () => {
const exception = new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'UNAUTHORIZED',
}),
);
});
it('should handle FORBIDDEN status', () => {
const exception = new HttpException('Forbidden', HttpStatus.FORBIDDEN);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'FORBIDDEN',
}),
);
});
it('should handle NOT_FOUND status', () => {
const exception = new HttpException('Not Found', HttpStatus.NOT_FOUND);
filter.catch(exception, mockHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'NOT_FOUND',
}),
);
});
});
});

View File

@ -0,0 +1,200 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ServiceAuthGuard } from '../../../src/shared/guards/service-auth.guard';
import {
generateServiceToken,
generateExpiredServiceToken,
TEST_SERVICE_JWT_SECRET,
} from '../../utils/test-utils';
describe('ServiceAuthGuard', () => {
let guard: ServiceAuthGuard;
const createMockContext = (headers: Record<string, string> = {}, connection: any = {}): ExecutionContext => {
const mockRequest = {
headers,
connection,
socket: connection,
};
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ServiceAuthGuard,
{
provide: ConfigService,
useValue: {
get: (key: string) => {
switch (key) {
case 'SERVICE_JWT_SECRET':
return TEST_SERVICE_JWT_SECRET;
case 'ALLOWED_SERVICES':
return 'identity-service,recovery-service';
default:
return undefined;
}
},
},
},
],
}).compile();
guard = module.get<ServiceAuthGuard>(ServiceAuthGuard);
});
describe('canActivate', () => {
it('should allow request with valid identity-service token', () => {
const token = generateServiceToken('identity-service');
const context = createMockContext({ 'x-service-token': token });
expect(guard.canActivate(context)).toBe(true);
});
it('should allow request with valid recovery-service token', () => {
const token = generateServiceToken('recovery-service');
const context = createMockContext({ 'x-service-token': token });
expect(guard.canActivate(context)).toBe(true);
});
it('should reject request without service token', () => {
const context = createMockContext({});
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
expect(() => guard.canActivate(context)).toThrow('Missing service token');
});
it('should reject request with invalid token', () => {
const context = createMockContext({ 'x-service-token': 'invalid-token' });
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
expect(() => guard.canActivate(context)).toThrow('Invalid service token');
});
it('should reject request with expired token', () => {
const token = generateExpiredServiceToken('identity-service');
const context = createMockContext({ 'x-service-token': token });
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});
it('should reject request from unauthorized service', () => {
const token = generateServiceToken('unknown-service');
const context = createMockContext({ 'x-service-token': token });
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
expect(() => guard.canActivate(context)).toThrow('Service not authorized');
});
it('should reject token signed with wrong secret', () => {
const token = generateServiceToken('identity-service', 'wrong-secret');
const context = createMockContext({ 'x-service-token': token });
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});
it('should attach sourceService to request', () => {
const token = generateServiceToken('identity-service');
const mockRequest: any = {
headers: { 'x-service-token': token },
connection: {},
socket: {},
};
const context = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
guard.canActivate(context);
expect(mockRequest.sourceService).toBe('identity-service');
});
it('should extract client IP from x-forwarded-for header', () => {
const token = generateServiceToken('identity-service');
const mockRequest: any = {
headers: {
'x-service-token': token,
'x-forwarded-for': '203.0.113.195, 70.41.3.18, 150.172.238.178',
},
connection: {},
socket: {},
};
const context = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
guard.canActivate(context);
expect(mockRequest.sourceIp).toBe('203.0.113.195');
});
it('should extract client IP from x-real-ip header', () => {
const token = generateServiceToken('identity-service');
const mockRequest: any = {
headers: {
'x-service-token': token,
'x-real-ip': '192.168.1.100',
},
connection: {},
socket: {},
};
const context = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
guard.canActivate(context);
expect(mockRequest.sourceIp).toBe('192.168.1.100');
});
it('should extract client IP from connection.remoteAddress', () => {
const token = generateServiceToken('identity-service');
const mockRequest: any = {
headers: { 'x-service-token': token },
connection: { remoteAddress: '10.0.0.1' },
socket: {},
};
const context = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
guard.canActivate(context);
expect(mockRequest.sourceIp).toBe('10.0.0.1');
});
it('should return "unknown" when no IP can be determined', () => {
const token = generateServiceToken('identity-service');
const mockRequest: any = {
headers: { 'x-service-token': token },
connection: {},
socket: {},
};
const context = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
guard.canActivate(context);
expect(mockRequest.sourceIp).toBe('unknown');
});
});
});

View File

@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
interface MockBackupShare {
shareId: bigint;
userId: bigint;
accountSequence: bigint;
publicKey: string;
partyIndex: number;
threshold: number;
totalParties: number;
encryptedShareData: string;
encryptionKeyId: string;
status: string;
accessCount: number;
lastAccessedAt: Date | null;
createdAt: Date;
updatedAt: Date;
revokedAt: Date | null;
}
interface MockShareAccessLog {
logId: bigint;
shareId: bigint;
userId: bigint;
action: string;
sourceService: string;
sourceIp: string;
success: boolean;
errorMessage: string | null;
createdAt: Date;
}
@Injectable()
export class MockPrismaService {
private backupShares: MockBackupShare[] = [];
private shareAccessLogs: MockShareAccessLog[] = [];
private shareIdCounter = 1n;
private logIdCounter = 1n;
backupShare = {
create: jest.fn().mockImplementation(({ data }) => {
const share: MockBackupShare = {
shareId: this.shareIdCounter++,
userId: data.userId,
accountSequence: data.accountSequence,
publicKey: data.publicKey,
partyIndex: data.partyIndex ?? 2,
threshold: data.threshold ?? 2,
totalParties: data.totalParties ?? 3,
encryptedShareData: data.encryptedShareData,
encryptionKeyId: data.encryptionKeyId,
status: data.status ?? 'ACTIVE',
accessCount: data.accessCount ?? 0,
lastAccessedAt: data.lastAccessedAt ?? null,
createdAt: new Date(),
updatedAt: new Date(),
revokedAt: data.revokedAt ?? null,
};
this.backupShares.push(share);
return Promise.resolve(share);
}),
update: jest.fn().mockImplementation(({ where, data }) => {
const index = this.backupShares.findIndex(s => s.shareId === where.shareId);
if (index === -1) return Promise.resolve(null);
this.backupShares[index] = {
...this.backupShares[index],
...data,
updatedAt: new Date(),
};
return Promise.resolve(this.backupShares[index]);
}),
findUnique: jest.fn().mockImplementation(({ where }) => {
let share: MockBackupShare | undefined;
if (where.shareId) {
share = this.backupShares.find(s => s.shareId === where.shareId);
} else if (where.userId) {
share = this.backupShares.find(s => s.userId === where.userId);
} else if (where.publicKey) {
share = this.backupShares.find(s => s.publicKey === where.publicKey);
} else if (where.accountSequence) {
share = this.backupShares.find(s => s.accountSequence === where.accountSequence);
}
return Promise.resolve(share ?? null);
}),
findFirst: jest.fn().mockImplementation(({ where }) => {
const share = this.backupShares.find(s =>
s.userId === where.userId && s.publicKey === where.publicKey
);
return Promise.resolve(share ?? null);
}),
delete: jest.fn().mockImplementation(({ where }) => {
const index = this.backupShares.findIndex(s => s.shareId === where.shareId);
if (index === -1) return Promise.resolve(null);
const deleted = this.backupShares.splice(index, 1)[0];
return Promise.resolve(deleted);
}),
deleteMany: jest.fn().mockImplementation(() => {
const count = this.backupShares.length;
this.backupShares = [];
return Promise.resolve({ count });
}),
};
shareAccessLog = {
create: jest.fn().mockImplementation(({ data }) => {
const log: MockShareAccessLog = {
logId: this.logIdCounter++,
shareId: data.shareId,
userId: data.userId,
action: data.action,
sourceService: data.sourceService,
sourceIp: data.sourceIp,
success: data.success ?? true,
errorMessage: data.errorMessage ?? null,
createdAt: new Date(),
};
this.shareAccessLogs.push(log);
return Promise.resolve(log);
}),
findMany: jest.fn().mockImplementation(({ where, orderBy, take }) => {
let logs = [...this.shareAccessLogs];
if (where?.shareId) {
logs = logs.filter(l => l.shareId === where.shareId);
}
if (where?.userId) {
logs = logs.filter(l => l.userId === where.userId);
}
if (orderBy?.createdAt === 'desc') {
logs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
if (take) {
logs = logs.slice(0, take);
}
return Promise.resolve(logs);
}),
count: jest.fn().mockImplementation(({ where }) => {
let logs = [...this.shareAccessLogs];
if (where?.userId) {
logs = logs.filter(l => l.userId === where.userId);
}
if (where?.action) {
logs = logs.filter(l => l.action === where.action);
}
if (where?.createdAt?.gte) {
logs = logs.filter(l => l.createdAt >= where.createdAt.gte);
}
return Promise.resolve(logs.length);
}),
deleteMany: jest.fn().mockImplementation(() => {
const count = this.shareAccessLogs.length;
this.shareAccessLogs = [];
return Promise.resolve({ count });
}),
};
$connect = jest.fn().mockResolvedValue(undefined);
$disconnect = jest.fn().mockResolvedValue(undefined);
$queryRaw = jest.fn().mockResolvedValue([{ '?column?': 1 }]);
// Helper methods for testing
reset(): void {
this.backupShares = [];
this.shareAccessLogs = [];
this.shareIdCounter = 1n;
this.logIdCounter = 1n;
jest.clearAllMocks();
}
getBackupShares(): MockBackupShare[] {
return [...this.backupShares];
}
getShareAccessLogs(): MockShareAccessLog[] {
return [...this.shareAccessLogs];
}
}

View File

@ -0,0 +1,90 @@
import * as jwt from 'jsonwebtoken';
export const TEST_SERVICE_JWT_SECRET = 'test-super-secret-service-jwt-key-for-e2e-testing';
export const TEST_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
export const TEST_ENCRYPTION_KEY_ID = 'test-key-v1';
export function generateServiceToken(
service: string = 'identity-service',
secret: string = TEST_SERVICE_JWT_SECRET,
expiresIn: string = '1h',
): string {
return jwt.sign(
{ service, iat: Math.floor(Date.now() / 1000) },
secret,
{ expiresIn },
);
}
export function generateExpiredServiceToken(
service: string = 'identity-service',
secret: string = TEST_SERVICE_JWT_SECRET,
): string {
return jwt.sign(
{ service, iat: Math.floor(Date.now() / 1000) - 7200, exp: Math.floor(Date.now() / 1000) - 3600 },
secret,
);
}
export function generatePublicKey(prefix: string = 'a'): string {
return '02' + prefix.repeat(64);
}
export function createStoreSharePayload(overrides: Partial<{
userId: string;
accountSequence: number;
publicKey: string;
encryptedShareData: string;
threshold: number;
totalParties: number;
}> = {}) {
return {
userId: overrides.userId ?? '12345',
accountSequence: overrides.accountSequence ?? 1001,
publicKey: overrides.publicKey ?? generatePublicKey('a'),
encryptedShareData: overrides.encryptedShareData ?? 'encrypted-share-data-base64',
threshold: overrides.threshold,
totalParties: overrides.totalParties,
};
}
export function createRetrieveSharePayload(overrides: Partial<{
userId: string;
publicKey: string;
recoveryToken: string;
deviceId: string;
}> = {}) {
return {
userId: overrides.userId ?? '12345',
publicKey: overrides.publicKey ?? generatePublicKey('a'),
recoveryToken: overrides.recoveryToken ?? 'valid-recovery-token',
deviceId: overrides.deviceId,
};
}
export function createRevokeSharePayload(overrides: Partial<{
userId: string;
publicKey: string;
reason: string;
}> = {}) {
return {
userId: overrides.userId ?? '12345',
publicKey: overrides.publicKey ?? generatePublicKey('a'),
reason: overrides.reason ?? 'ROTATION',
};
}
export const testEnvConfig = {
APP_ENV: 'test',
SERVICE_JWT_SECRET: TEST_SERVICE_JWT_SECRET,
BACKUP_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY,
BACKUP_ENCRYPTION_KEY_ID: TEST_ENCRYPTION_KEY_ID,
MAX_RETRIEVE_PER_DAY: '3',
ALLOWED_SERVICES: 'identity-service,recovery-service',
};
export function setupTestEnv(): void {
Object.entries(testEnvConfig).forEach(([key, value]) => {
process.env[key] = value;
});
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}