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