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:
parent
083db83c96
commit
66199cc93e
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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*
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 集成测试通过
|
||||
- [ ] 监控指标已配置
|
||||
- [ ] 告警规则已配置
|
||||
- [ ] 生产环境部署在异地机房
|
||||
|
|
@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
},
|
||||
});
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [],
|
||||
exports: [],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export class DomainError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'DomainError';
|
||||
Object.setPrototypeOf(this, DomainError.prototype);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue