import { describe, it, expect, vi, beforeEach } from 'vitest'; import Fastify from 'fastify'; import { BackupService } from '../src/services/backup/backup-service.js'; import { RestoreService } from '../src/services/backup/restore-service.js'; import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js'; import { registerBackupRoutes } from '../src/routes/backup.js'; import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js'; import type { IProjectRepository } from '../src/repositories/project.repository.js'; import type { IUserRepository } from '../src/repositories/user.repository.js'; import type { IGroupRepository } from '../src/repositories/group.repository.js'; import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; // Mock data const mockServers = [ { id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github', dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null, env: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }, { id: 's2', name: 'slack', description: 'Slack MCP', packageName: null, dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null, env: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }, ]; const mockSecrets = [ { id: 'sec1', name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_secret123' }, version: 1, createdAt: new Date(), updatedAt: new Date(), }, ]; const mockProjects = [ { id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null, ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(), servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }], }, ]; const mockUsers = [ { id: 'u1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, { id: 'u2', email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, ]; const mockGroups = [ { id: 'g1', name: 'dev-team', description: 'Developers', version: 1, createdAt: new Date(), updatedAt: new Date(), members: [ { id: 'gm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } }, { id: 'gm2', user: { id: 'u2', email: 'bob@test.com', name: null } }, ], }, ]; const mockRbacDefinitions = [ { id: 'rbac1', name: 'admins', version: 1, createdAt: new Date(), updatedAt: new Date(), subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }], }, ]; function mockServerRepo(): IMcpServerRepository { return { findAll: vi.fn(async () => [...mockServers]), findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null), findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null), create: vi.fn(async (data) => ({ id: 'new-s', ...data, env: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])), update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })), delete: vi.fn(async () => {}), }; } function mockSecretRepo(): ISecretRepository { return { findAll: vi.fn(async () => [...mockSecrets]), findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null), findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null), create: vi.fn(async (data) => ({ id: 'new-sec', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockSecrets[0])), update: vi.fn(async (id, data) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })), delete: vi.fn(async () => {}), }; } function mockProjectRepo(): IProjectRepository { return { findAll: vi.fn(async () => [...mockProjects]), findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null), findByName: vi.fn(async () => null), create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])), update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })), delete: vi.fn(async () => {}), setServers: vi.fn(async () => {}), addServer: vi.fn(async () => {}), removeServer: vi.fn(async () => {}), }; } function mockUserRepo(): IUserRepository { return { findAll: vi.fn(async () => [...mockUsers]), findById: vi.fn(async (id: string) => mockUsers.find((u) => u.id === id) ?? null), findByEmail: vi.fn(async (email: string) => mockUsers.find((u) => u.email === email) ?? null), create: vi.fn(async (data) => ({ id: 'new-u', ...data, provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockUsers[0])), delete: vi.fn(async () => {}), count: vi.fn(async () => mockUsers.length), }; } function mockGroupRepo(): IGroupRepository { return { findAll: vi.fn(async () => [...mockGroups]), findById: vi.fn(async (id: string) => mockGroups.find((g) => g.id === id) ?? null), findByName: vi.fn(async (name: string) => mockGroups.find((g) => g.name === name) ?? null), create: vi.fn(async (data) => ({ id: 'new-g', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockGroups[0])), update: vi.fn(async (id, data) => ({ ...mockGroups.find((g) => g.id === id)!, ...data })), delete: vi.fn(async () => {}), setMembers: vi.fn(async () => {}), findGroupsForUser: vi.fn(async () => []), }; } function mockRbacRepo(): IRbacDefinitionRepository { return { findAll: vi.fn(async () => [...mockRbacDefinitions]), findById: vi.fn(async (id: string) => mockRbacDefinitions.find((r) => r.id === id) ?? null), findByName: vi.fn(async (name: string) => mockRbacDefinitions.find((r) => r.name === name) ?? null), create: vi.fn(async (data) => ({ id: 'new-rbac', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockRbacDefinitions[0])), update: vi.fn(async (id, data) => ({ ...mockRbacDefinitions.find((r) => r.id === id)!, ...data })), delete: vi.fn(async () => {}), }; } describe('Crypto', () => { it('encrypts and decrypts successfully', () => { const data = 'hello secret world'; const password = 'my-password-123'; const encrypted = encrypt(data, password); expect(encrypted.algorithm).toBe('aes-256-gcm'); expect(encrypted.ciphertext).not.toBe(data); const decrypted = decrypt(encrypted, password); expect(decrypted).toBe(data); }); it('fails with wrong password', () => { const encrypted = encrypt('secret', 'correct-password'); expect(() => decrypt(encrypted, 'wrong-password')).toThrow(); }); it('handles large data', () => { const data = 'x'.repeat(10000); const encrypted = encrypt(data, 'pass'); expect(decrypt(encrypted, 'pass')).toBe(data); }); it('detects sensitive keys', () => { expect(isSensitiveKey('GITHUB_TOKEN')).toBe(true); expect(isSensitiveKey('API_KEY')).toBe(true); expect(isSensitiveKey('DATABASE_PASSWORD')).toBe(true); expect(isSensitiveKey('AWS_SECRET')).toBe(true); expect(isSensitiveKey('MY_SECRET_KEY')).toBe(true); expect(isSensitiveKey('CREDENTIALS')).toBe(true); expect(isSensitiveKey('PORT')).toBe(false); expect(isSensitiveKey('DATABASE_URL')).toBe(false); expect(isSensitiveKey('NODE_ENV')).toBe(false); }); }); describe('BackupService', () => { let backupService: BackupService; beforeEach(() => { backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo(), mockUserRepo(), mockGroupRepo(), mockRbacRepo()); }); it('creates backup with all resources', async () => { const bundle = await backupService.createBackup(); expect(bundle.version).toBe('1'); expect(bundle.encrypted).toBe(false); expect(bundle.servers).toHaveLength(2); expect(bundle.secrets).toHaveLength(1); expect(bundle.projects).toHaveLength(1); expect(bundle.servers[0]!.name).toBe('github'); expect(bundle.secrets[0]!.name).toBe('github-secrets'); expect(bundle.projects[0]!.name).toBe('my-project'); }); it('includes users in backup', async () => { const bundle = await backupService.createBackup(); expect(bundle.users).toHaveLength(2); expect(bundle.users![0]!.email).toBe('alice@test.com'); expect(bundle.users![0]!.role).toBe('ADMIN'); expect(bundle.users![1]!.email).toBe('bob@test.com'); expect(bundle.users![1]!.provider).toBe('oidc'); }); it('includes groups in backup with member emails', async () => { const bundle = await backupService.createBackup(); expect(bundle.groups).toHaveLength(1); expect(bundle.groups![0]!.name).toBe('dev-team'); expect(bundle.groups![0]!.memberEmails).toEqual(['alice@test.com', 'bob@test.com']); }); it('includes rbac bindings in backup', async () => { const bundle = await backupService.createBackup(); expect(bundle.rbacBindings).toHaveLength(1); expect(bundle.rbacBindings![0]!.name).toBe('admins'); expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]); }); it('includes enriched projects with server names', async () => { const bundle = await backupService.createBackup(); const proj = bundle.projects[0]!; expect(proj.proxyMode).toBe('direct'); expect(proj.serverNames).toEqual(['github']); }); it('filters resources', async () => { const bundle = await backupService.createBackup({ resources: ['servers'] }); expect(bundle.servers).toHaveLength(2); expect(bundle.secrets).toHaveLength(0); expect(bundle.projects).toHaveLength(0); expect(bundle.users).toHaveLength(0); expect(bundle.groups).toHaveLength(0); expect(bundle.rbacBindings).toHaveLength(0); }); it('filters to only users', async () => { const bundle = await backupService.createBackup({ resources: ['users'] }); expect(bundle.servers).toHaveLength(0); expect(bundle.users).toHaveLength(2); }); it('encrypts sensitive secret values when password provided', async () => { const bundle = await backupService.createBackup({ password: 'test-pass' }); expect(bundle.encrypted).toBe(true); expect(bundle.encryptedSecrets).toBeDefined(); // The GITHUB_TOKEN should be replaced with placeholder const data = bundle.secrets[0]!.data; expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:'); }); it('handles empty repositories', async () => { const emptyServerRepo = mockServerRepo(); (emptyServerRepo.findAll as ReturnType).mockResolvedValue([]); const emptySecretRepo = mockSecretRepo(); (emptySecretRepo.findAll as ReturnType).mockResolvedValue([]); const emptyProjectRepo = mockProjectRepo(); (emptyProjectRepo.findAll as ReturnType).mockResolvedValue([]); const emptyUserRepo = mockUserRepo(); (emptyUserRepo.findAll as ReturnType).mockResolvedValue([]); const emptyGroupRepo = mockGroupRepo(); (emptyGroupRepo.findAll as ReturnType).mockResolvedValue([]); const emptyRbacRepo = mockRbacRepo(); (emptyRbacRepo.findAll as ReturnType).mockResolvedValue([]); const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo, emptyUserRepo, emptyGroupRepo, emptyRbacRepo); const bundle = await service.createBackup(); expect(bundle.servers).toHaveLength(0); expect(bundle.secrets).toHaveLength(0); expect(bundle.projects).toHaveLength(0); expect(bundle.users).toHaveLength(0); expect(bundle.groups).toHaveLength(0); expect(bundle.rbacBindings).toHaveLength(0); }); }); describe('RestoreService', () => { let restoreService: RestoreService; let serverRepo: IMcpServerRepository; let secretRepo: ISecretRepository; let projectRepo: IProjectRepository; let userRepo: IUserRepository; let groupRepo: IGroupRepository; let rbacRepo: IRbacDefinitionRepository; beforeEach(() => { serverRepo = mockServerRepo(); secretRepo = mockSecretRepo(); projectRepo = mockProjectRepo(); userRepo = mockUserRepo(); groupRepo = mockGroupRepo(); rbacRepo = mockRbacRepo(); // Default: nothing exists yet (serverRepo.findByName as ReturnType).mockResolvedValue(null); (secretRepo.findByName as ReturnType).mockResolvedValue(null); (projectRepo.findByName as ReturnType).mockResolvedValue(null); (userRepo.findByEmail as ReturnType).mockResolvedValue(null); (groupRepo.findByName as ReturnType).mockResolvedValue(null); (rbacRepo.findByName as ReturnType).mockResolvedValue(null); restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo); }); const validBundle = { version: '1', mcpctlVersion: '0.1.0', createdAt: new Date().toISOString(), encrypted: false, servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }], secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }], projects: [{ name: 'test-proj', description: 'Test' }], }; const fullBundle = { ...validBundle, users: [ { email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null }, { email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc' }, ], groups: [ { name: 'dev-team', description: 'Developers', memberEmails: ['alice@test.com', 'bob@test.com'] }, ], rbacBindings: [ { name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] }, ], projects: [ { name: 'test-proj', description: 'Test', proxyMode: 'filtered', llmProvider: 'openai', llmModel: 'gpt-4', serverNames: ['github'], members: ['alice@test.com'] }, ], }; it('validates valid bundle', () => { expect(restoreService.validateBundle(validBundle)).toBe(true); }); it('rejects invalid bundle', () => { expect(restoreService.validateBundle(null)).toBe(false); expect(restoreService.validateBundle({})).toBe(false); expect(restoreService.validateBundle({ version: '1' })).toBe(false); }); it('validates old bundles without new fields (backwards compatibility)', () => { expect(restoreService.validateBundle(validBundle)).toBe(true); // Old bundle has no users/groups/rbacBindings — should still validate }); it('restores all resources', async () => { const result = await restoreService.restore(validBundle); expect(result.serversCreated).toBe(1); expect(result.secretsCreated).toBe(1); expect(result.projectsCreated).toBe(1); expect(result.errors).toHaveLength(0); expect(serverRepo.create).toHaveBeenCalled(); expect(secretRepo.create).toHaveBeenCalled(); expect(projectRepo.create).toHaveBeenCalled(); }); it('restores users', async () => { const result = await restoreService.restore(fullBundle); expect(result.usersCreated).toBe(2); expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', passwordHash: '__RESTORED_MUST_RESET__', })); expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'bob@test.com', role: 'USER', })); }); it('restores groups with member resolution', async () => { // After users are created, simulate they can be found by email let callCount = 0; (userRepo.findByEmail as ReturnType).mockImplementation(async (email: string) => { // First calls during user restore return null (user doesn't exist yet) // Later calls during group member resolution return the created user callCount++; if (callCount > 2) { // After user creation phase, simulate finding created users if (email === 'alice@test.com') return { id: 'new-u-alice', email }; if (email === 'bob@test.com') return { id: 'new-u-bob', email }; } return null; }); const result = await restoreService.restore(fullBundle); expect(result.groupsCreated).toBe(1); expect(groupRepo.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'dev-team', description: 'Developers', })); expect(groupRepo.setMembers).toHaveBeenCalled(); }); it('restores rbac bindings', async () => { const result = await restoreService.restore(fullBundle); expect(result.rbacCreated).toBe(1); expect(rbacRepo.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }], })); }); it('restores enriched projects with server linking', async () => { // Simulate servers exist (restored in prior step) (serverRepo.findByName as ReturnType).mockResolvedValue(null); // After server restore, we can find them let serverCallCount = 0; (serverRepo.findByName as ReturnType).mockImplementation(async (name: string) => { serverCallCount++; // During server restore phase, first call returns null (server doesn't exist) // During project restore phase, server should be found if (serverCallCount > 1 && name === 'github') return { id: 'restored-s1', name: 'github' }; return null; }); const result = await restoreService.restore(fullBundle); expect(result.projectsCreated).toBe(1); expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-proj', proxyMode: 'filtered', llmProvider: 'openai', llmModel: 'gpt-4', })); expect(projectRepo.setServers).toHaveBeenCalled(); }); it('restores old bundle without users/groups/rbac', async () => { const result = await restoreService.restore(validBundle); expect(result.serversCreated).toBe(1); expect(result.secretsCreated).toBe(1); expect(result.projectsCreated).toBe(1); expect(result.usersCreated).toBe(0); expect(result.groupsCreated).toBe(0); expect(result.rbacCreated).toBe(0); expect(result.errors).toHaveLength(0); }); it('skips existing resources with skip strategy', async () => { (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); const result = await restoreService.restore(validBundle, { conflictStrategy: 'skip' }); expect(result.serversSkipped).toBe(1); expect(result.serversCreated).toBe(0); expect(serverRepo.create).not.toHaveBeenCalled(); }); it('skips existing users', async () => { (userRepo.findByEmail as ReturnType).mockResolvedValue(mockUsers[0]); const bundle = { ...validBundle, users: [{ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null }] }; const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' }); expect(result.usersSkipped).toBe(1); expect(result.usersCreated).toBe(0); }); it('skips existing groups', async () => { (groupRepo.findByName as ReturnType).mockResolvedValue(mockGroups[0]); const bundle = { ...validBundle, groups: [{ name: 'dev-team', description: 'Devs', memberEmails: [] }] }; const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' }); expect(result.groupsSkipped).toBe(1); expect(result.groupsCreated).toBe(0); }); it('skips existing rbac bindings', async () => { (rbacRepo.findByName as ReturnType).mockResolvedValue(mockRbacDefinitions[0]); const bundle = { ...validBundle, rbacBindings: [{ name: 'admins', subjects: [], roleBindings: [] }] }; const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' }); expect(result.rbacSkipped).toBe(1); expect(result.rbacCreated).toBe(0); }); it('aborts on conflict with fail strategy', async () => { (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); const result = await restoreService.restore(validBundle, { conflictStrategy: 'fail' }); expect(result.errors).toContain('Server "github" already exists'); }); it('overwrites existing with overwrite strategy', async () => { (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); const result = await restoreService.restore(validBundle, { conflictStrategy: 'overwrite' }); expect(result.serversCreated).toBe(1); expect(serverRepo.update).toHaveBeenCalled(); }); it('overwrites existing rbac bindings', async () => { (rbacRepo.findByName as ReturnType).mockResolvedValue(mockRbacDefinitions[0]); const bundle = { ...validBundle, rbacBindings: [{ name: 'admins', subjects: [{ kind: 'User', name: 'new@test.com' }], roleBindings: [{ role: 'view', resource: 'servers' }] }], }; const result = await restoreService.restore(bundle, { conflictStrategy: 'overwrite' }); expect(result.rbacCreated).toBe(1); expect(rbacRepo.update).toHaveBeenCalled(); }); it('fails restore with encrypted bundle and no password', async () => { const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt('{}', 'pw') }; const result = await restoreService.restore(encBundle); expect(result.errors).toContain('Backup is encrypted but no password provided'); }); it('restores encrypted bundle with correct password', async () => { const encryptedData = { 'secret:github-secrets:GITHUB_TOKEN': 'ghp_decrypted' }; const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'), secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }], }; const result = await restoreService.restore(encBundle, { password: 'test-pw' }); expect(result.errors).toHaveLength(0); expect(result.secretsCreated).toBe(1); }); it('fails with wrong decryption password', async () => { const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt('{"key":"val"}', 'correct'), }; const result = await restoreService.restore(encBundle, { password: 'wrong' }); expect(result.errors[0]).toContain('Failed to decrypt'); }); it('restores in correct order: secrets → servers → users → groups → projects → rbac', async () => { const callOrder: string[] = []; (secretRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('secret'); return { id: 'sec' }; }); (serverRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; }); (userRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; }); (groupRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; }); (projectRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [] }; }); (rbacRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; }); await restoreService.restore(fullBundle); expect(callOrder[0]).toBe('secret'); expect(callOrder[1]).toBe('server'); expect(callOrder[2]).toBe('user'); expect(callOrder[3]).toBe('user'); // second user expect(callOrder[4]).toBe('group'); expect(callOrder[5]).toBe('project'); expect(callOrder[6]).toBe('rbac'); }); }); describe('Backup Routes', () => { let backupService: BackupService; let restoreService: RestoreService; beforeEach(() => { const sRepo = mockServerRepo(); const secRepo = mockSecretRepo(); const prRepo = mockProjectRepo(); backupService = new BackupService(sRepo, prRepo, secRepo, mockUserRepo(), mockGroupRepo(), mockRbacRepo()); const rSRepo = mockServerRepo(); (rSRepo.findByName as ReturnType).mockResolvedValue(null); const rSecRepo = mockSecretRepo(); (rSecRepo.findByName as ReturnType).mockResolvedValue(null); const rPrRepo = mockProjectRepo(); (rPrRepo.findByName as ReturnType).mockResolvedValue(null); const rUserRepo = mockUserRepo(); (rUserRepo.findByEmail as ReturnType).mockResolvedValue(null); const rGroupRepo = mockGroupRepo(); (rGroupRepo.findByName as ReturnType).mockResolvedValue(null); const rRbacRepo = mockRbacRepo(); (rRbacRepo.findByName as ReturnType).mockResolvedValue(null); restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, rUserRepo, rGroupRepo, rRbacRepo); }); async function buildApp() { const app = Fastify(); registerBackupRoutes(app, { backupService, restoreService }); return app; } it('POST /api/v1/backup returns bundle with new resource types', async () => { const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/backup', payload: {}, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.version).toBe('1'); expect(body.servers).toBeDefined(); expect(body.secrets).toBeDefined(); expect(body.projects).toBeDefined(); expect(body.users).toBeDefined(); expect(body.groups).toBeDefined(); expect(body.rbacBindings).toBeDefined(); }); it('POST /api/v1/restore imports bundle', async () => { const app = await buildApp(); const bundle = await backupService.createBackup(); const res = await app.inject({ method: 'POST', url: '/api/v1/restore', payload: { bundle, conflictStrategy: 'skip' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.serversCreated).toBeDefined(); expect(body.usersCreated).toBeDefined(); expect(body.groupsCreated).toBeDefined(); expect(body.rbacCreated).toBeDefined(); }); it('POST /api/v1/restore rejects invalid bundle', async () => { const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/v1/restore', payload: { bundle: { invalid: true } }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toContain('Invalid'); }); });