diff --git a/backend/.env.example b/backend/.env.example index 16dfdb4c..7858ab1b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -53,3 +53,19 @@ AUDIT_RETENTION_DAYS=365 AUDIT_RETENTION_SWEEP_HOURS=24 # Set to "false" to disable the background retention worker AUDIT_RETENTION_ENABLED=true + +# Backup system +# Enable/disable automatic backups +BACKUP_AUTO_ENABLED=true +# Health check interval in hours +BACKUP_HEALTH_CHECK_INTERVAL_HOURS=6 +# Enable/disable health monitoring +BACKUP_HEALTH_CHECK_ENABLED=true +# Rotation check interval in days +BACKUP_ROTATION_CHECK_INTERVAL_DAYS=7 +# Enable/disable rotation +BACKUP_ROTATION_ENABLED=true +# Restoration test interval in days +BACKUP_RESTORATION_TEST_INTERVAL_DAYS=30 +# Enable/disable restoration tests +BACKUP_RESTORATION_TEST_ENABLED=true diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f6529214..cc92d58d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { SorobanModule } from './soroban/soroban.module'; import { IndexerModule } from './indexer/indexer.module'; import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; +import { BackupModule } from './backup/backup.module'; @Module({ imports: [ @@ -70,6 +71,7 @@ import { AuditModule } from './audit/audit.module'; IndexerModule, HealthModule, AuditModule, + BackupModule, ], providers: [ { diff --git a/backend/src/backup/backup.controller.spec.ts b/backend/src/backup/backup.controller.spec.ts new file mode 100644 index 00000000..8bfab7fa --- /dev/null +++ b/backend/src/backup/backup.controller.spec.ts @@ -0,0 +1,143 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BackupController } from './backup.controller'; +import { BackupService } from './backup.service'; + +describe('BackupController', () => { + let controller: BackupController; + let mockBackupService: any; + + beforeEach(async () => { + mockBackupService = { + createBackup: jest.fn(), + getBackups: jest.fn(), + restoreBackup: jest.fn(), + runHealthCheck: jest.fn(), + runRotation: jest.fn(), + runRestorationTests: jest.fn(), + getAlerts: jest.fn(), + acknowledgeAlert: jest.fn(), + getConfig: jest.fn(), + updateConfig: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [BackupController], + providers: [ + { provide: BackupService, useValue: mockBackupService }, + ], + }).compile(); + + controller = module.get(BackupController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('createBackup', () => { + it('should create a backup', async () => { + const dto = { walletAddress: 'test-wallet', backupType: 'full' }; + mockBackupService.createBackup.mockResolvedValue({ id: 'test-id' }); + + const result = await controller.createBackup(dto as any); + + expect(result).toEqual({ id: 'test-id' }); + expect(mockBackupService.createBackup).toHaveBeenCalledWith(dto); + }); + }); + + describe('getBackups', () => { + it('should get backups for a wallet', async () => { + mockBackupService.getBackups.mockResolvedValue([{ id: '1' }]); + + const result = await controller.getBackups('test-wallet'); + + expect(result).toEqual([{ id: '1' }]); + expect(mockBackupService.getBackups).toHaveBeenCalledWith('test-wallet'); + }); + }); + + describe('restoreBackup', () => { + it('should restore a backup', async () => { + const dto = { backupId: 'test-id', walletAddress: 'test-wallet' }; + mockBackupService.restoreBackup.mockResolvedValue({ success: true, restored: {}, dryRun: false }); + + const result = await controller.restoreBackup(dto as any); + + expect(result.success).toBe(true); + expect(mockBackupService.restoreBackup).toHaveBeenCalledWith(dto); + }); + }); + + describe('getHealthStatus', () => { + it('should get health status', async () => { + mockBackupService.runHealthCheck.mockResolvedValue({ checked: 5, healthy: 4 }); + + const result = await controller.getHealthStatus(); + + expect(result).toEqual({ checked: 5, healthy: 4 }); + }); + }); + + describe('triggerRotation', () => { + it('should trigger rotation', async () => { + mockBackupService.runRotation.mockResolvedValue({ totalChecked: 10, deleted: 2, kept: 8 }); + + const result = await controller.triggerRotation(); + + expect(result).toEqual({ totalChecked: 10, deleted: 2, kept: 8 }); + }); + }); + + describe('triggerRestorationTest', () => { + it('should trigger restoration test', async () => { + mockBackupService.runRestorationTests.mockResolvedValue({ tested: 3, passed: 2, failed: 1 }); + + const result = await controller.triggerRestorationTest(); + + expect(result).toEqual({ tested: 3, passed: 2, failed: 1 }); + }); + }); + + describe('getAlerts', () => { + it('should get alerts', async () => { + mockBackupService.getAlerts.mockResolvedValue([{ id: '1', message: 'test' }]); + + const result = await controller.getAlerts('test-wallet', 'true'); + + expect(result).toEqual([{ id: '1', message: 'test' }]); + expect(mockBackupService.getAlerts).toHaveBeenCalledWith('test-wallet', true); + }); + }); + + describe('acknowledgeAlert', () => { + it('should acknowledge an alert', async () => { + mockBackupService.acknowledgeAlert.mockResolvedValue({ id: '1', acknowledged: true }); + + const result = await controller.acknowledgeAlert('1'); + + expect(result.acknowledged).toBe(true); + }); + }); + + describe('getConfig', () => { + it('should get config', async () => { + mockBackupService.getConfig.mockResolvedValue({ walletAddress: 'test-wallet' }); + + const result = await controller.getConfig('test-wallet'); + + expect(result.walletAddress).toBe('test-wallet'); + }); + }); + + describe('updateConfig', () => { + it('should update config', async () => { + const dto = { backupIntervalHours: 12 }; + mockBackupService.updateConfig.mockResolvedValue({ walletAddress: 'test-wallet', backupIntervalHours: 12 }); + + const result = await controller.updateConfig('test-wallet', dto as any); + + expect(result.backupIntervalHours).toBe(12); + }); + }); +}); diff --git a/backend/src/backup/backup.controller.ts b/backend/src/backup/backup.controller.ts new file mode 100644 index 00000000..bd7cef57 --- /dev/null +++ b/backend/src/backup/backup.controller.ts @@ -0,0 +1,85 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { BackupService } from './backup.service'; +import { CreateBackupDto } from './dto/create-backup.dto'; +import { UpdateBackupConfigDto } from './dto/update-backup-config.dto'; +import { RestoreBackupDto } from './dto/restore-backup.dto'; + +@Controller('backup') +@UseGuards(JwtAuthGuard) +export class BackupController { + constructor(private readonly backupService: BackupService) {} + + @Post() + async createBackup(@Body() dto: CreateBackupDto) { + return this.backupService.createBackup(dto); + } + + @Get('wallet/:walletAddress') + async getBackups(@Param('walletAddress') walletAddress: string) { + return this.backupService.getBackups(walletAddress); + } + + @Post('restore') + @HttpCode(HttpStatus.OK) + async restoreBackup(@Body() dto: RestoreBackupDto) { + return this.backupService.restoreBackup(dto); + } + + @Get('health') + async getHealthStatus() { + return this.backupService.runHealthCheck(); + } + + @Post('rotation') + @HttpCode(HttpStatus.OK) + async triggerRotation() { + return this.backupService.runRotation(); + } + + @Post('restoration-test') + @HttpCode(HttpStatus.OK) + async triggerRestorationTest() { + return this.backupService.runRestorationTests(); + } + + @Get('alerts/:walletAddress') + async getAlerts( + @Param('walletAddress') walletAddress: string, + @Query('unacknowledged') unacknowledged?: string, + ) { + return this.backupService.getAlerts( + walletAddress, + unacknowledged === 'true', + ); + } + + @Put('alerts/:alertId/acknowledge') + async acknowledgeAlert(@Param('alertId') alertId: string) { + return this.backupService.acknowledgeAlert(alertId); + } + + @Get('config/:walletAddress') + async getConfig(@Param('walletAddress') walletAddress: string) { + return this.backupService.getConfig(walletAddress); + } + + @Put('config/:walletAddress') + async updateConfig( + @Param('walletAddress') walletAddress: string, + @Body() dto: UpdateBackupConfigDto, + ) { + return this.backupService.updateConfig(walletAddress, dto); + } +} diff --git a/backend/src/backup/backup.module.ts b/backend/src/backup/backup.module.ts new file mode 100644 index 00000000..7173e249 --- /dev/null +++ b/backend/src/backup/backup.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { BackupService } from './backup.service'; +import { BackupController } from './backup.controller'; +import { BackupRecord } from './entities/backup-record.entity'; +import { BackupAlert } from './entities/backup-alert.entity'; +import { BackupConfig } from './entities/backup-config.entity'; +import { IpfsModule } from '../ipfs/ipfs.module'; +import { IdentityModule } from '../identity/identity.module'; +import { VerificationModule } from '../verification/verification.module'; +import { DataSharingModule } from '../data-sharing/data-sharing.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BackupRecord, BackupAlert, BackupConfig]), + ScheduleModule.forRoot(), + IpfsModule, + IdentityModule, + VerificationModule, + DataSharingModule, + ], + controllers: [BackupController], + providers: [BackupService], + exports: [BackupService], +}) +export class BackupModule {} diff --git a/backend/src/backup/backup.service.spec.ts b/backend/src/backup/backup.service.spec.ts new file mode 100644 index 00000000..37091f80 --- /dev/null +++ b/backend/src/backup/backup.service.spec.ts @@ -0,0 +1,220 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { BackupService } from './backup.service'; +import { BackupRecord, BackupStatus } from './entities/backup-record.entity'; +import { BackupAlert, AlertType, AlertSeverity } from './entities/backup-alert.entity'; +import { BackupConfig } from './entities/backup-config.entity'; +import { IpfsService } from '../ipfs/ipfs.service'; +import { IdentityService } from '../identity/identity.service'; +import { VerificationService } from '../verification/verification.service'; +import { DataSharingService } from '../data-sharing/data-sharing.service'; + +describe('BackupService', () => { + let service: BackupService; + let mockBackupRecordRepo: any; + let mockBackupAlertRepo: any; + let mockBackupConfigRepo: any; + let mockIpfsService: any; + let mockIdentityService: any; + let mockVerificationService: any; + let mockDataSharingService: any; + let mockConfigService: any; + + beforeEach(async () => { + mockBackupRecordRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + + mockBackupAlertRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }; + + mockBackupConfigRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }; + + mockIpfsService = { + uploadJson: jest.fn(), + fetchJson: jest.fn(), + }; + + mockIdentityService = { + findAllByWallet: jest.fn(), + restore: jest.fn(), + }; + + mockVerificationService = { + findAllByWallet: jest.fn(), + restore: jest.fn(), + }; + + mockDataSharingService = { + findAllByOwner: jest.fn(), + restore: jest.fn(), + }; + + mockConfigService = { + get: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BackupService, + { provide: getRepositoryToken(BackupRecord), useValue: mockBackupRecordRepo }, + { provide: getRepositoryToken(BackupAlert), useValue: mockBackupAlertRepo }, + { provide: getRepositoryToken(BackupConfig), useValue: mockBackupConfigRepo }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: IpfsService, useValue: mockIpfsService }, + { provide: IdentityService, useValue: mockIdentityService }, + { provide: VerificationService, useValue: mockVerificationService }, + { provide: DataSharingService, useValue: mockDataSharingService }, + ], + }).compile(); + + service = module.get(BackupService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createBackup', () => { + it('should create a backup successfully', async () => { + const dto = { + walletAddress: 'test-wallet', + backupType: 'full' as any, + }; + + const mockRecord = { + id: 'test-id', + walletAddress: 'test-wallet', + status: BackupStatus.IN_PROGRESS, + }; + + mockBackupRecordRepo.create.mockReturnValue(mockRecord); + mockBackupRecordRepo.save.mockResolvedValue({ ...mockRecord, status: BackupStatus.COMPLETED }); + mockIdentityService.findAllByWallet.mockResolvedValue([]); + mockVerificationService.findAllByWallet.mockResolvedValue([]); + mockDataSharingService.findAllByOwner.mockResolvedValue([]); + mockIpfsService.uploadJson.mockResolvedValue('QmTestHash'); + + const result = await service.createBackup(dto); + + expect(result.status).toBe(BackupStatus.COMPLETED); + expect(result.ipfsCid).toBe('QmTestHash'); + expect(mockIpfsService.uploadJson).toHaveBeenCalled(); + }); + + it('should handle backup failure', async () => { + const dto = { + walletAddress: 'test-wallet', + backupType: 'full' as any, + }; + + mockBackupRecordRepo.create.mockReturnValue({ + id: 'test-id', + walletAddress: 'test-wallet', + status: BackupStatus.IN_PROGRESS, + }); + mockBackupRecordRepo.save.mockResolvedValue({}); + mockIdentityService.findAllByWallet.mockRejectedValue(new Error('Test error')); + mockBackupAlertRepo.create.mockReturnValue({}); + mockBackupAlertRepo.save.mockResolvedValue({}); + + await expect(service.createBackup(dto)).rejects.toThrow('Test error'); + }); + }); + + describe('runHealthCheck', () => { + it('should run health check and return results', async () => { + const mockBackups = [ + { id: '1', walletAddress: 'wallet1', status: BackupStatus.COMPLETED, ipfsCid: 'Qm1', checksum: 'abc' }, + { id: '2', walletAddress: 'wallet1', status: BackupStatus.COMPLETED, ipfsCid: 'Qm2', checksum: 'def' }, + ]; + + mockBackupRecordRepo.find.mockResolvedValue(mockBackups); + mockIpfsService.fetchJson.mockResolvedValue({ data: 'test' }); + + const result = await service.runHealthCheck(); + + expect(result.checked).toBe(2); + expect(result.healthy).toBeGreaterThanOrEqual(0); + }); + }); + + describe('runRotation', () => { + it('should rotate old backups', async () => { + const mockConfigs = [ + { + walletAddress: 'wallet1', + rotationEnabled: true, + retentionDays: 30, + maxBackupsToKeep: 5, + }, + ]; + + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 60); + + const mockBackups = [ + { id: '1', walletAddress: 'wallet1', createdAt: oldDate }, + { id: '2', walletAddress: 'wallet1', createdAt: new Date() }, + ]; + + mockBackupConfigRepo.find.mockResolvedValue(mockConfigs); + mockBackupRecordRepo.find.mockResolvedValue(mockBackups); + mockBackupRecordRepo.remove.mockResolvedValue({}); + mockBackupAlertRepo.create.mockReturnValue({}); + mockBackupAlertRepo.save.mockResolvedValue({}); + + const result = await service.runRotation(); + + expect(result.totalChecked).toBe(2); + expect(result.deleted).toBeGreaterThanOrEqual(0); + }); + }); + + describe('getConfig', () => { + it('should return existing config', async () => { + const mockConfig = { + walletAddress: 'wallet1', + autoBackupEnabled: true, + backupIntervalHours: 24, + }; + + mockBackupConfigRepo.findOne.mockResolvedValue(mockConfig); + + const result = await service.getConfig('wallet1'); + + expect(result.walletAddress).toBe('wallet1'); + }); + + it('should create default config if none exists', async () => { + mockBackupConfigRepo.findOne.mockResolvedValue(null); + mockBackupConfigRepo.create.mockReturnValue({ + walletAddress: 'wallet1', + autoBackupEnabled: true, + }); + mockBackupConfigRepo.save.mockResolvedValue({ + walletAddress: 'wallet1', + autoBackupEnabled: true, + }); + + const result = await service.getConfig('wallet1'); + + expect(result.walletAddress).toBe('wallet1'); + expect(mockBackupConfigRepo.create).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/backup/backup.service.ts b/backend/src/backup/backup.service.ts new file mode 100644 index 00000000..255409ac --- /dev/null +++ b/backend/src/backup/backup.service.ts @@ -0,0 +1,597 @@ +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import * as crypto from 'crypto'; +import { + BackupRecord, + BackupStatus, + BackupType, +} from './entities/backup-record.entity'; +import { + BackupAlert, + AlertType, + AlertSeverity, +} from './entities/backup-alert.entity'; +import { BackupConfig } from './entities/backup-config.entity'; +import { CreateBackupDto } from './dto/create-backup.dto'; +import { RestoreBackupDto } from './dto/restore-backup.dto'; +import { IpfsService } from '../ipfs/ipfs.service'; +import { IdentityService } from '../identity/identity.service'; +import { VerificationService } from '../verification/verification.service'; +import { DataSharingService } from '../data-sharing/data-sharing.service'; + +@Injectable() +export class BackupService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(BackupService.name); + private healthCheckTimer: NodeJS.Timeout | null = null; + private rotationTimer: NodeJS.Timeout | null = null; + private restorationTestTimer: NodeJS.Timeout | null = null; + + constructor( + @InjectRepository(BackupRecord) + private readonly backupRecordRepo: Repository, + @InjectRepository(BackupAlert) + private readonly backupAlertRepo: Repository, + @InjectRepository(BackupConfig) + private readonly backupConfigRepo: Repository, + private readonly configService: ConfigService, + private readonly ipfsService: IpfsService, + private readonly identityService: IdentityService, + private readonly verificationService: VerificationService, + private readonly dataSharingService: DataSharingService, + ) {} + + onModuleInit(): void { + this.startScheduledTasks(); + } + + onModuleDestroy(): void { + this.stopScheduledTasks(); + } + + private startScheduledTasks(): void { + const healthCheckEnabled = + this.configService.get('BACKUP_HEALTH_CHECK_ENABLED') !== 'false'; + const healthCheckHours = + this.configService.get('BACKUP_HEALTH_CHECK_INTERVAL_HOURS') ?? 6; + const rotationEnabled = + this.configService.get('BACKUP_ROTATION_ENABLED') !== 'false'; + const rotationDays = + this.configService.get('BACKUP_ROTATION_CHECK_INTERVAL_DAYS') ?? 7; + const restorationTestEnabled = + this.configService.get('BACKUP_RESTORATION_TEST_ENABLED') !== + 'false'; + const restorationTestDays = + this.configService.get( + 'BACKUP_RESTORATION_TEST_INTERVAL_DAYS', + ) ?? 30; + + if (healthCheckEnabled) { + this.healthCheckTimer = setInterval( + () => void this.runHealthCheck(), + healthCheckHours * 60 * 60 * 1000, + ); + this.healthCheckTimer.unref?.(); + this.logger.log( + `Backup health check started (every ${healthCheckHours}h)`, + ); + } + + if (rotationEnabled) { + this.rotationTimer = setInterval( + () => void this.runRotation(), + rotationDays * 24 * 60 * 60 * 1000, + ); + this.rotationTimer.unref?.(); + this.logger.log(`Backup rotation started (every ${rotationDays}d)`); + } + + if (restorationTestEnabled) { + this.restorationTestTimer = setInterval( + () => void this.runRestorationTests(), + restorationTestDays * 24 * 60 * 60 * 1000, + ); + this.restorationTestTimer.unref?.(); + this.logger.log( + `Backup restoration tests started (every ${restorationTestDays}d)`, + ); + } + } + + private stopScheduledTasks(): void { + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + if (this.rotationTimer) { + clearInterval(this.rotationTimer); + this.rotationTimer = null; + } + if (this.restorationTestTimer) { + clearInterval(this.restorationTestTimer); + this.restorationTestTimer = null; + } + } + + @Cron(CronExpression.EVERY_HOUR) + async scheduledBackup(): Promise { + const enabled = + this.configService.get('BACKUP_AUTO_ENABLED') !== 'false'; + if (!enabled) return; + + const configs = await this.backupConfigRepo.find({ + where: { autoBackupEnabled: true }, + }); + + for (const config of configs) { + const lastBackup = await this.backupRecordRepo.findOne({ + where: { walletAddress: config.walletAddress }, + order: { createdAt: 'DESC' }, + }); + + const intervalMs = config.backupIntervalHours * 60 * 60 * 1000; + const shouldBackup = + !lastBackup || + Date.now() - lastBackup.createdAt.getTime() >= intervalMs; + + if (shouldBackup) { + await this.createBackup({ + walletAddress: config.walletAddress, + backupType: BackupType.FULL, + }); + } + } + } + + async createBackup(dto: CreateBackupDto): Promise { + const record = this.backupRecordRepo.create({ + walletAddress: dto.walletAddress, + backupType: dto.backupType || BackupType.FULL, + status: BackupStatus.IN_PROGRESS, + }); + + try { + const identities = await this.identityService.findAllByWallet( + dto.walletAddress, + ); + const verifications = await this.verificationService.findAllByWallet( + dto.walletAddress, + ); + const sharedData = await this.dataSharingService.findAllByOwner( + dto.walletAddress, + ); + + const backupData = { + walletAddress: dto.walletAddress, + backupType: dto.backupType, + timestamp: new Date().toISOString(), + identities, + verifications, + sharedData, + }; + + const jsonContent = JSON.stringify(backupData, null, 2); + const checksum = crypto + .createHash('sha256') + .update(jsonContent) + .digest('hex'); + + const ipfsCid = await this.ipfsService.uploadJson(backupData); + + record.ipfsCid = ipfsCid; + record.sizeBytes = Buffer.byteLength(jsonContent); + record.checksum = checksum; + record.manifest = { + identitiesCount: identities.length, + verificationsCount: verifications.length, + sharedDataCount: sharedData.length, + }; + record.status = BackupStatus.COMPLETED; + + await this.backupRecordRepo.save(record); + + this.logger.log( + `Backup created for ${dto.walletAddress}: ${ipfsCid}`, + ); + + if (dto.triggerHealthCheck) { + await this.checkBackupHealth(record); + } + + return record; + } catch (error) { + record.status = BackupStatus.FAILED; + record.errorMessage = + error instanceof Error ? error.message : String(error); + await this.backupRecordRepo.save(record); + + await this.createAlert({ + walletAddress: dto.walletAddress, + alertType: AlertType.BACKUP_FAILED, + severity: AlertSeverity.HIGH, + message: `Backup failed: ${record.errorMessage}`, + metadata: { backupId: record.id }, + }); + + throw error; + } + } + + async restoreBackup(dto: RestoreBackupDto): Promise<{ + success: boolean; + restored: Record; + dryRun: boolean; + }> { + const backup = await this.backupRecordRepo.findOne({ + where: { id: dto.backupId }, + }); + + if (!backup) { + throw new Error(`Backup ${dto.backupId} not found`); + } + + if (backup.walletAddress !== dto.walletAddress) { + throw new Error('Wallet address mismatch'); + } + + const backupData = await this.ipfsService.fetchJson(backup.ipfsCid); + + if (backup.checksum) { + const jsonContent = JSON.stringify(backupData, null, 2); + const currentChecksum = crypto + .createHash('sha256') + .update(jsonContent) + .digest('hex'); + + if (currentChecksum !== backup.checksum) { + await this.createAlert({ + walletAddress: dto.walletAddress, + alertType: AlertType.RESTORATION_TEST_FAILED, + severity: AlertSeverity.CRITICAL, + message: `Backup integrity check failed for ${backup.id}`, + metadata: { + backupId: backup.id, + expectedChecksum: backup.checksum, + actualChecksum: currentChecksum, + }, + }); + throw new Error('Backup integrity check failed'); + } + } + + const restored = { + identities: 0, + verifications: 0, + sharedData: 0, + }; + + if (!dto.dryRun) { + if (backupData.identities?.length) { + for (const identity of backupData.identities) { + await this.identityService.restore(identity); + restored.identities++; + } + } + + if (backupData.verifications?.length) { + for (const verification of backupData.verifications) { + await this.verificationService.restore(verification); + restored.verifications++; + } + } + + if (backupData.sharedData?.length) { + for (const shared of backupData.sharedData) { + await this.dataSharingService.restore(shared); + restored.sharedData++; + } + } + + backup.status = BackupStatus.RESTORED; + backup.restoreAttempts++; + backup.lastRestoreTest = new Date(); + await this.backupRecordRepo.save(backup); + } + + return { + success: true, + restored, + dryRun: dto.dryRun || false, + }; + } + + async checkBackupHealth(backup: BackupRecord): Promise { + try { + const backupData = await this.ipfsService.fetchJson(backup.ipfsCid); + + const jsonContent = JSON.stringify(backupData, null, 2); + const currentChecksum = crypto + .createHash('sha256') + .update(jsonContent) + .digest('hex'); + + if (backup.checksum && currentChecksum !== backup.checksum) { + await this.createAlert({ + walletAddress: backup.walletAddress, + alertType: AlertType.BACKUP_HEALTH_WARNING, + severity: AlertSeverity.HIGH, + message: `Backup integrity check failed for ${backup.id}`, + metadata: { + backupId: backup.id, + expectedChecksum: backup.checksum, + actualChecksum: currentChecksum, + }, + }); + return false; + } + + return true; + } catch (error) { + await this.createAlert({ + walletAddress: backup.walletAddress, + alertType: AlertType.BACKUP_HEALTH_WARNING, + severity: AlertSeverity.MEDIUM, + message: `Backup health check failed: ${ + error instanceof Error ? error.message : String(error) + }`, + metadata: { backupId: backup.id }, + }); + return false; + } + } + + async runHealthCheck(): Promise<{ checked: number; healthy: number }> { + const backups = await this.backupRecordRepo.find({ + where: { status: BackupStatus.COMPLETED }, + order: { createdAt: 'DESC' }, + }); + + let healthy = 0; + for (const backup of backups) { + const isHealthy = await this.checkBackupHealth(backup); + if (isHealthy) healthy++; + } + + return { checked: backups.length, healthy }; + } + + async runRotation(): Promise<{ + totalChecked: number; + deleted: number; + kept: number; + }> { + const configs = await this.backupConfigRepo.find({ + where: { rotationEnabled: true }, + }); + + let totalChecked = 0; + let deleted = 0; + let kept = 0; + + for (const config of configs) { + const backups = await this.backupRecordRepo.find({ + where: { walletAddress: config.walletAddress }, + order: { createdAt: 'DESC' }, + }); + + totalChecked += backups.length; + + const retentionDate = new Date(); + retentionDate.setDate(retentionDate.getDate() - config.retentionDays); + + const backupsToDelete: BackupRecord[] = []; + const backupsToKeep: BackupRecord[] = []; + + for (const backup of backups) { + if ( + backup.createdAt < retentionDate && + backupsToKeep.length >= config.maxBackupsToKeep + ) { + backupsToDelete.push(backup); + } else { + backupsToKeep.push(backup); + } + } + + for (const backup of backupsToDelete) { + await this.backupRecordRepo.remove(backup); + deleted++; + } + + kept += backupsToKeep.length; + + if (backupsToDelete.length > 0) { + await this.createAlert({ + walletAddress: config.walletAddress, + alertType: AlertType.BACKUP_ROTATION_EXECUTED, + severity: AlertSeverity.LOW, + message: `Rotation executed: deleted ${backupsToDelete.length} old backups`, + metadata: { + deletedCount: backupsToDelete.length, + keptCount: backupsToKeep.length, + }, + }); + } + } + + this.logger.log( + `Rotation completed: checked ${totalChecked}, deleted ${deleted}, kept ${kept}`, + ); + + return { totalChecked, deleted, kept }; + } + + async runRestorationTests(): Promise<{ + tested: number; + passed: number; + failed: number; + }> { + const configs = await this.backupConfigRepo.find({ + where: { restorationTestEnabled: true }, + }); + + let tested = 0; + let passed = 0; + let failed = 0; + + for (const config of configs) { + const latestBackup = await this.backupRecordRepo.findOne({ + where: { + walletAddress: config.walletAddress, + status: BackupStatus.COMPLETED, + }, + order: { createdAt: 'DESC' }, + }); + + if (latestBackup) { + tested++; + try { + const result = await this.restoreBackup({ + backupId: latestBackup.id, + walletAddress: config.walletAddress, + dryRun: true, + }); + + if (result.success) { + passed++; + } else { + failed++; + } + } catch (error) { + failed++; + await this.createAlert({ + walletAddress: config.walletAddress, + alertType: AlertType.RESTORATION_TEST_FAILED, + severity: AlertSeverity.HIGH, + message: `Restoration test failed: ${ + error instanceof Error ? error.message : String(error) + }`, + metadata: { backupId: latestBackup.id }, + }); + } + } + } + + this.logger.log( + `Restoration tests: tested ${tested}, passed ${passed}, failed ${failed}`, + ); + + return { tested, passed, failed }; + } + + async createAlert(data: { + walletAddress: string; + alertType: AlertType; + severity: AlertSeverity; + message: string; + metadata?: Record; + }): Promise { + const alert = this.backupAlertRepo.create(data); + const saved = await this.backupAlertRepo.save(alert); + + const config = await this.backupConfigRepo.findOne({ + where: { walletAddress: data.walletAddress }, + }); + + if (config?.alertWebhookUrl) { + await this.sendWebhook(config.alertWebhookUrl, saved); + } + + return saved; + } + + private async sendWebhook(url: string, alert: BackupAlert): Promise { + try { + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + alertType: alert.alertType, + severity: alert.severity, + message: alert.message, + walletAddress: alert.walletAddress, + metadata: alert.metadata, + timestamp: alert.createdAt, + }), + }); + } catch (error) { + this.logger.error( + `Webhook failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async getBackups(walletAddress: string): Promise { + return this.backupRecordRepo.find({ + where: { walletAddress }, + order: { createdAt: 'DESC' }, + }); + } + + async getAlerts( + walletAddress: string, + unacknowledgedOnly = false, + ): Promise { + const where: any = { walletAddress }; + if (unacknowledgedOnly) { + where.acknowledged = false; + } + return this.backupAlertRepo.find({ + where, + order: { createdAt: 'DESC' }, + }); + } + + async acknowledgeAlert(alertId: string): Promise { + const alert = await this.backupAlertRepo.findOne({ + where: { id: alertId }, + }); + if (!alert) { + throw new Error(`Alert ${alertId} not found`); + } + alert.acknowledged = true; + return this.backupAlertRepo.save(alert); + } + + async getConfig(walletAddress: string): Promise { + let config = await this.backupConfigRepo.findOne({ + where: { walletAddress }, + }); + + if (!config) { + config = this.backupConfigRepo.create({ + walletAddress, + autoBackupEnabled: true, + backupIntervalHours: 24, + retentionDays: 30, + maxBackupsToKeep: 10, + healthMonitoringEnabled: true, + healthCheckIntervalHours: 6, + alertsEnabled: true, + rotationEnabled: true, + rotationCheckIntervalDays: 7, + restorationTestEnabled: true, + restorationTestIntervalDays: 30, + }); + await this.backupConfigRepo.save(config); + } + + return config; + } + + async updateConfig( + walletAddress: string, + updates: Partial, + ): Promise { + const config = await this.getConfig(walletAddress); + Object.assign(config, updates); + return this.backupConfigRepo.save(config); + } +} diff --git a/backend/src/backup/dto/create-backup.dto.ts b/backend/src/backup/dto/create-backup.dto.ts new file mode 100644 index 00000000..df540bdd --- /dev/null +++ b/backend/src/backup/dto/create-backup.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber } from 'class-validator'; +import { BackupType } from '../entities/backup-record.entity'; + +export class CreateBackupDto { + @IsString() + walletAddress: string; + + @IsEnum(BackupType) + @IsOptional() + backupType?: BackupType = BackupType.FULL; + + @IsBoolean() + @IsOptional() + triggerHealthCheck?: boolean = false; +} diff --git a/backend/src/backup/dto/index.ts b/backend/src/backup/dto/index.ts new file mode 100644 index 00000000..28a2fdc2 --- /dev/null +++ b/backend/src/backup/dto/index.ts @@ -0,0 +1,3 @@ +export { CreateBackupDto } from './create-backup.dto'; +export { UpdateBackupConfigDto } from './update-backup-config.dto'; +export { RestoreBackupDto } from './restore-backup.dto'; diff --git a/backend/src/backup/dto/restore-backup.dto.ts b/backend/src/backup/dto/restore-backup.dto.ts new file mode 100644 index 00000000..28e83ac1 --- /dev/null +++ b/backend/src/backup/dto/restore-backup.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsUUID, IsOptional, IsBoolean } from 'class-validator'; + +export class RestoreBackupDto { + @IsUUID() + backupId: string; + + @IsString() + walletAddress: string; + + @IsBoolean() + @IsOptional() + dryRun?: boolean = false; +} diff --git a/backend/src/backup/dto/update-backup-config.dto.ts b/backend/src/backup/dto/update-backup-config.dto.ts new file mode 100644 index 00000000..64cff5fe --- /dev/null +++ b/backend/src/backup/dto/update-backup-config.dto.ts @@ -0,0 +1,70 @@ +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Min, + Max, +} from 'class-validator'; + +export class UpdateBackupConfigDto { + @IsBoolean() + @IsOptional() + autoBackupEnabled?: boolean; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(168) + backupIntervalHours?: number; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(365) + retentionDays?: number; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(100) + maxBackupsToKeep?: number; + + @IsBoolean() + @IsOptional() + healthMonitoringEnabled?: boolean; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(168) + healthCheckIntervalHours?: number; + + @IsBoolean() + @IsOptional() + alertsEnabled?: boolean; + + @IsString() + @IsOptional() + alertWebhookUrl?: string; + + @IsBoolean() + @IsOptional() + rotationEnabled?: boolean; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(365) + rotationCheckIntervalDays?: number; + + @IsBoolean() + @IsOptional() + restorationTestEnabled?: boolean; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(365) + restorationTestIntervalDays?: number; +} diff --git a/backend/src/backup/entities/backup-alert.entity.ts b/backend/src/backup/entities/backup-alert.entity.ts new file mode 100644 index 00000000..6fe6849c --- /dev/null +++ b/backend/src/backup/entities/backup-alert.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum AlertType { + BACKUP_FAILED = 'backup_failed', + BACKUP_HEALTH_WARNING = 'backup_health_warning', + BACKUP_ROTATION_EXECUTED = 'backup_rotation_executed', + RESTORATION_TEST_FAILED = 'restoration_test_failed', + STORAGE_QUOTA_WARNING = 'storage_quota_warning', +} + +export enum AlertSeverity { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +@Entity('backup_alerts') +export class BackupAlert { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + walletAddress: string; + + @Column({ type: 'enum', enum: AlertType }) + alertType: AlertType; + + @Column({ type: 'enum', enum: AlertSeverity, default: AlertSeverity.LOW }) + severity: AlertSeverity; + + @Column('text') + message: string; + + @Column({ type: 'json', nullable: true }) + metadata: Record; + + @Column({ default: false }) + acknowledged: boolean; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/backup/entities/backup-config.entity.ts b/backend/src/backup/entities/backup-config.entity.ts new file mode 100644 index 00000000..6765706c --- /dev/null +++ b/backend/src/backup/entities/backup-config.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('backup_configs') +export class BackupConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ unique: true }) + walletAddress: string; + + @Column({ default: true }) + autoBackupEnabled: boolean; + + @Column({ type: 'int', default: 24 }) + backupIntervalHours: number; + + @Column({ type: 'int', default: 30 }) + retentionDays: number; + + @Column({ type: 'int', default: 10 }) + maxBackupsToKeep: number; + + @Column({ default: true }) + healthMonitoringEnabled: boolean; + + @Column({ type: 'int', default: 6 }) + healthCheckIntervalHours: number; + + @Column({ default: true }) + alertsEnabled: boolean; + + @Column({ type: 'json', nullable: true }) + alertWebhookUrl: string; + + @Column({ default: true }) + rotationEnabled: boolean; + + @Column({ type: 'int', default: 7 }) + rotationCheckIntervalDays: number; + + @Column({ default: true }) + restorationTestEnabled: boolean; + + @Column({ type: 'int', default: 30 }) + restorationTestIntervalDays: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/backup/entities/backup-record.entity.ts b/backend/src/backup/entities/backup-record.entity.ts new file mode 100644 index 00000000..ed6d784d --- /dev/null +++ b/backend/src/backup/entities/backup-record.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum BackupStatus { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + FAILED = 'failed', + RESTORED = 'restored', +} + +export enum BackupType { + FULL = 'full', + INCREMENTAL = 'incremental', + SELECTIVE = 'selective', +} + +@Entity('backup_records') +export class BackupRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + walletAddress: string; + + @Column({ type: 'enum', enum: BackupType, default: BackupType.FULL }) + backupType: BackupType; + + @Column({ type: 'enum', enum: BackupStatus, default: BackupStatus.PENDING }) + status: BackupStatus; + + @Column() + ipfsCid: string; + + @Column({ type: 'bigint' }) + sizeBytes: number; + + @Column({ type: 'json', nullable: true }) + manifest: Record; + + @Column({ nullable: true }) + checksum: string; + + @Column({ nullable: true }) + errorMessage: string; + + @Column({ type: 'int', default: 0 }) + restoreAttempts: number; + + @Column({ type: 'timestamptz', nullable: true }) + lastRestoreTest: Date; + + @Column({ type: 'boolean', default: true }) + isRotatable: boolean; + + @Column({ type: 'int', default: 0 }) + retentionDays: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/backup/entities/index.ts b/backend/src/backup/entities/index.ts new file mode 100644 index 00000000..7fb6b4d4 --- /dev/null +++ b/backend/src/backup/entities/index.ts @@ -0,0 +1,3 @@ +export { BackupRecord, BackupStatus, BackupType } from './backup-record.entity'; +export { BackupAlert, AlertType, AlertSeverity } from './backup-alert.entity'; +export { BackupConfig } from './backup-config.entity'; diff --git a/backend/src/data-sharing/data-sharing.service.ts b/backend/src/data-sharing/data-sharing.service.ts index 0ceaba51..73ebb3a3 100644 --- a/backend/src/data-sharing/data-sharing.service.ts +++ b/backend/src/data-sharing/data-sharing.service.ts @@ -69,4 +69,25 @@ export class DataSharingService { sharedData.accessExpiry += additionalSeconds; return await this.sharedDataRepository.save(sharedData); } + + async findAllByOwner(ownerAddress: string): Promise { + return await this.sharedDataRepository.find({ + where: { ownerAddress }, + order: { sharedAt: 'DESC' }, + }); + } + + async restore(data: Partial): Promise { + const existing = await this.sharedDataRepository.findOne({ + where: { documentHash: data.documentHash, ownerAddress: data.ownerAddress }, + }); + + if (existing) { + Object.assign(existing, data); + return await this.sharedDataRepository.save(existing); + } + + const sharedData = this.sharedDataRepository.create(data); + return await this.sharedDataRepository.save(sharedData); + } } diff --git a/backend/src/identity/identity.service.ts b/backend/src/identity/identity.service.ts index 5f8c9e4d..1df94824 100644 --- a/backend/src/identity/identity.service.ts +++ b/backend/src/identity/identity.service.ts @@ -75,4 +75,25 @@ export class IdentityService { async findByDocumentHash(documentHash: string): Promise { return await this.identityRepository.findOne({ where: { documentHash } }); } + + async findAllByWallet(walletAddress: string): Promise { + return await this.identityRepository.find({ + where: { walletAddress }, + order: { createdAt: 'DESC' }, + }); + } + + async restore(data: Partial): Promise { + const existing = await this.identityRepository.findOne({ + where: { documentHash: data.documentHash }, + }); + + if (existing) { + Object.assign(existing, data); + return await this.identityRepository.save(existing); + } + + const identity = this.identityRepository.create(data); + return await this.identityRepository.save(identity); + } } diff --git a/backend/src/ipfs/ipfs.service.ts b/backend/src/ipfs/ipfs.service.ts index b994a9b1..6be35990 100644 --- a/backend/src/ipfs/ipfs.service.ts +++ b/backend/src/ipfs/ipfs.service.ts @@ -67,4 +67,25 @@ export class IpfsService { const result = await this.pinata.testAuthentication(); return result.authenticated; } + + async uploadJson(data: unknown): Promise { + this.ensureClient(); + try { + const result = await this.pinata.pinJSONToIPFS(data, { + pinataMetadata: { name: `backup-${Date.now()}` }, + }); + return result.IpfsHash; + } catch (error) { + throw new InternalServerErrorException(`Failed to upload JSON to IPFS: ${(error as Error).message}`); + } + } + + async fetchJson(cid: string): Promise { + const url = this.getGatewayUrl(cid); + const res = await fetch(url); + if (!res.ok) { + throw new InternalServerErrorException(`Failed to fetch JSON from IPFS: ${res.statusText}`); + } + return res.json(); + } } diff --git a/backend/src/verification/verification.service.ts b/backend/src/verification/verification.service.ts index b69c14a2..24360e32 100644 --- a/backend/src/verification/verification.service.ts +++ b/backend/src/verification/verification.service.ts @@ -71,4 +71,25 @@ export class VerificationService { if (!verification.expiresAt) return false; return verification.expiresAt < new Date(); } + + async findAllByWallet(walletAddress: string): Promise { + return await this.verificationRepository.find({ + where: { walletAddress }, + order: { createdAt: 'DESC' }, + }); + } + + async restore(data: Partial): Promise { + const existing = await this.verificationRepository.findOne({ + where: { proofHash: data.proofHash }, + }); + + if (existing) { + Object.assign(existing, data); + return await this.verificationRepository.save(existing); + } + + const verification = this.verificationRepository.create(data); + return await this.verificationRepository.save(verification); + } }