feat: add backup and restore with encrypted secrets
BackupService exports servers/profiles/projects to JSON bundle. RestoreService imports with skip/overwrite/fail conflict strategies. AES-256-GCM encryption for sensitive env vars via scrypt-derived keys. REST endpoints and CLI commands for backup/restore operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
336
src/mcpd/tests/backup.test.ts
Normal file
336
src/mcpd/tests/backup.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
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, IMcpProfileRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
|
||||
// Mock data
|
||||
const mockServers = [
|
||||
{
|
||||
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
|
||||
dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null,
|
||||
envTemplate: [], 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,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 'p1', name: 'default', serverId: 's1', permissions: ['read'],
|
||||
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' },
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: 'proj1', name: 'my-project', description: 'Test project',
|
||||
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
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, envTemplate: [], 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 mockProfileRepo(): IMcpProfileRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [...mockProfiles]),
|
||||
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null),
|
||||
findByServerAndName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.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, 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 () => {}),
|
||||
setProfiles: vi.fn(async () => {}),
|
||||
getProfileIds: vi.fn(async () => ['p1']),
|
||||
};
|
||||
}
|
||||
|
||||
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(), mockProfileRepo(), mockProjectRepo());
|
||||
});
|
||||
|
||||
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.profiles).toHaveLength(1);
|
||||
expect(bundle.projects).toHaveLength(1);
|
||||
expect(bundle.servers[0]!.name).toBe('github');
|
||||
expect(bundle.profiles[0]!.serverName).toBe('github');
|
||||
expect(bundle.projects[0]!.name).toBe('my-project');
|
||||
});
|
||||
|
||||
it('filters resources', async () => {
|
||||
const bundle = await backupService.createBackup({ resources: ['servers'] });
|
||||
expect(bundle.servers).toHaveLength(2);
|
||||
expect(bundle.profiles).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('encrypts sensitive env 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 overrides = bundle.profiles[0]!.envOverrides as Record<string, string>;
|
||||
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
|
||||
});
|
||||
|
||||
it('handles empty repositories', async () => {
|
||||
const emptyServerRepo = mockServerRepo();
|
||||
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProfileRepo = mockProfileRepo();
|
||||
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProjectRepo = mockProjectRepo();
|
||||
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo);
|
||||
const bundle = await service.createBackup();
|
||||
|
||||
expect(bundle.servers).toHaveLength(0);
|
||||
expect(bundle.profiles).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RestoreService', () => {
|
||||
let restoreService: RestoreService;
|
||||
let serverRepo: IMcpServerRepository;
|
||||
let profileRepo: IMcpProfileRepository;
|
||||
let projectRepo: IProjectRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
serverRepo = mockServerRepo();
|
||||
profileRepo = mockProfileRepo();
|
||||
projectRepo = mockProjectRepo();
|
||||
// Default: nothing exists yet
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(profileRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
});
|
||||
|
||||
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, envTemplate: [] }],
|
||||
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }],
|
||||
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }],
|
||||
};
|
||||
|
||||
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('restores all resources', async () => {
|
||||
const result = await restoreService.restore(validBundle);
|
||||
|
||||
expect(result.serversCreated).toBe(1);
|
||||
expect(result.profilesCreated).toBe(1);
|
||||
expect(result.projectsCreated).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(serverRepo.create).toHaveBeenCalled();
|
||||
expect(profileRepo.create).toHaveBeenCalled();
|
||||
expect(projectRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips existing resources with skip strategy', async () => {
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).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('aborts on conflict with fail strategy', async () => {
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(mockServers[0]);
|
||||
const result = await restoreService.restore(validBundle, { conflictStrategy: 'overwrite' });
|
||||
|
||||
expect(result.serversCreated).toBe(1);
|
||||
expect(serverRepo.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 secrets = { 'profile:default:API_KEY': 'secret-val' };
|
||||
const encBundle = {
|
||||
...validBundle,
|
||||
encrypted: true,
|
||||
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'),
|
||||
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }],
|
||||
};
|
||||
|
||||
const result = await restoreService.restore(encBundle, { password: 'test-pw' });
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.profilesCreated).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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup Routes', () => {
|
||||
let backupService: BackupService;
|
||||
let restoreService: RestoreService;
|
||||
|
||||
beforeEach(() => {
|
||||
const sRepo = mockServerRepo();
|
||||
const pRepo = mockProfileRepo();
|
||||
const prRepo = mockProjectRepo();
|
||||
backupService = new BackupService(sRepo, pRepo, prRepo);
|
||||
|
||||
const rSRepo = mockServerRepo();
|
||||
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPRepo = mockProfileRepo();
|
||||
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPrRepo = mockProjectRepo();
|
||||
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo);
|
||||
});
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify();
|
||||
registerBackupRoutes(app, { backupService, restoreService });
|
||||
return app;
|
||||
}
|
||||
|
||||
it('POST /api/v1/backup returns bundle', 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.profiles).toBeDefined();
|
||||
expect(body.projects).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();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user