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:
80
src/cli/src/commands/backup.ts
Normal file
80
src/cli/src/commands/backup.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
|
||||||
|
export interface BackupDeps {
|
||||||
|
client: ApiClient;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupCommand(deps: BackupDeps): Command {
|
||||||
|
const cmd = new Command('backup')
|
||||||
|
.description('Backup mcpctl configuration to a JSON file')
|
||||||
|
.option('-o, --output <path>', 'output file path', 'mcpctl-backup.json')
|
||||||
|
.option('-p, --password <password>', 'encrypt sensitive values with password')
|
||||||
|
.option('-r, --resources <types>', 'resource types to backup (comma-separated: servers,profiles,projects)')
|
||||||
|
.action(async (options: { output: string; password?: string; resources?: string }) => {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (options.password) {
|
||||||
|
body.password = options.password;
|
||||||
|
}
|
||||||
|
if (options.resources) {
|
||||||
|
body.resources = options.resources.split(',').map((s) => s.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = await deps.client.post('/api/v1/backup', body);
|
||||||
|
fs.writeFileSync(options.output, JSON.stringify(bundle, null, 2), 'utf-8');
|
||||||
|
deps.log(`Backup saved to ${options.output}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRestoreCommand(deps: BackupDeps): Command {
|
||||||
|
const cmd = new Command('restore')
|
||||||
|
.description('Restore mcpctl configuration from a backup file')
|
||||||
|
.option('-i, --input <path>', 'backup file path', 'mcpctl-backup.json')
|
||||||
|
.option('-p, --password <password>', 'decryption password for encrypted backups')
|
||||||
|
.option('-c, --conflict <strategy>', 'conflict resolution: skip, overwrite, fail', 'skip')
|
||||||
|
.action(async (options: { input: string; password?: string; conflict: string }) => {
|
||||||
|
if (!fs.existsSync(options.input)) {
|
||||||
|
deps.log(`Error: File not found: ${options.input}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(options.input, 'utf-8');
|
||||||
|
const bundle = JSON.parse(raw) as unknown;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
bundle,
|
||||||
|
conflictStrategy: options.conflict,
|
||||||
|
};
|
||||||
|
if (options.password) {
|
||||||
|
body.password = options.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deps.client.post<{
|
||||||
|
serversCreated: number;
|
||||||
|
serversSkipped: number;
|
||||||
|
profilesCreated: number;
|
||||||
|
profilesSkipped: number;
|
||||||
|
projectsCreated: number;
|
||||||
|
projectsSkipped: number;
|
||||||
|
errors: string[];
|
||||||
|
}>('/api/v1/restore', body);
|
||||||
|
|
||||||
|
deps.log('Restore complete:');
|
||||||
|
deps.log(` Servers: ${result.serversCreated} created, ${result.serversSkipped} skipped`);
|
||||||
|
deps.log(` Profiles: ${result.profilesCreated} created, ${result.profilesSkipped} skipped`);
|
||||||
|
deps.log(` Projects: ${result.projectsCreated} created, ${result.projectsSkipped} skipped`);
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
deps.log(` Errors:`);
|
||||||
|
for (const err of result.errors) {
|
||||||
|
deps.log(` - ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { createApplyCommand } from './commands/apply.js';
|
|||||||
import { createSetupCommand } from './commands/setup.js';
|
import { createSetupCommand } from './commands/setup.js';
|
||||||
import { createClaudeCommand } from './commands/claude.js';
|
import { createClaudeCommand } from './commands/claude.js';
|
||||||
import { createProjectCommand } from './commands/project.js';
|
import { createProjectCommand } from './commands/project.js';
|
||||||
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
import { ApiClient } from './api-client.js';
|
import { ApiClient } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
|
|
||||||
@@ -98,6 +99,16 @@ export function createProgram(): Command {
|
|||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
program.addCommand(createBackupCommand({
|
||||||
|
client,
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
program.addCommand(createRestoreCommand({
|
||||||
|
client,
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
src/cli/tests/commands/backup.test.ts
Normal file
120
src/cli/tests/commands/backup.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createBackupCommand, createRestoreCommand } from '../../src/commands/backup.js';
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = vi.fn();
|
||||||
|
|
||||||
|
describe('backup command', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up any created files
|
||||||
|
try { fs.unlinkSync('test-backup.json'); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates backup command', () => {
|
||||||
|
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||||
|
expect(cmd.name()).toBe('backup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls API and writes file', async () => {
|
||||||
|
const bundle = { version: '1', servers: [], profiles: [], projects: [] };
|
||||||
|
mockClient.post.mockResolvedValue(bundle);
|
||||||
|
|
||||||
|
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||||
|
await cmd.parseAsync(['-o', 'test-backup.json'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {});
|
||||||
|
expect(fs.existsSync('test-backup.json')).toBe(true);
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('test-backup.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes password when provided', async () => {
|
||||||
|
mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] });
|
||||||
|
|
||||||
|
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||||
|
await cmd.parseAsync(['-o', 'test-backup.json', '-p', 'secret'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', { password: 'secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes resource filter', async () => {
|
||||||
|
mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] });
|
||||||
|
|
||||||
|
const cmd = createBackupCommand({ client: mockClient as never, log });
|
||||||
|
await cmd.parseAsync(['-o', 'test-backup.json', '-r', 'servers,profiles'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {
|
||||||
|
resources: ['servers', 'profiles'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restore command', () => {
|
||||||
|
const testFile = 'test-restore-input.json';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
fs.writeFileSync(testFile, JSON.stringify({
|
||||||
|
version: '1', servers: [], profiles: [], projects: [],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { fs.unlinkSync(testFile); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates restore command', () => {
|
||||||
|
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||||
|
expect(cmd.name()).toBe('restore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads file and calls API', async () => {
|
||||||
|
mockClient.post.mockResolvedValue({
|
||||||
|
serversCreated: 1, serversSkipped: 0,
|
||||||
|
profilesCreated: 0, profilesSkipped: 0,
|
||||||
|
projectsCreated: 0, projectsSkipped: 0,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||||
|
await cmd.parseAsync(['-i', testFile], { from: 'user' });
|
||||||
|
|
||||||
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/restore', expect.objectContaining({
|
||||||
|
bundle: expect.objectContaining({ version: '1' }),
|
||||||
|
conflictStrategy: 'skip',
|
||||||
|
}));
|
||||||
|
expect(log).toHaveBeenCalledWith('Restore complete:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports errors from restore', async () => {
|
||||||
|
mockClient.post.mockResolvedValue({
|
||||||
|
serversCreated: 0, serversSkipped: 0,
|
||||||
|
profilesCreated: 0, profilesSkipped: 0,
|
||||||
|
projectsCreated: 0, projectsSkipped: 0,
|
||||||
|
errors: ['Server "x" already exists'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||||
|
await cmd.parseAsync(['-i', testFile], { from: 'user' });
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('Errors'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs error for missing file', async () => {
|
||||||
|
const cmd = createRestoreCommand({ client: mockClient as never, log });
|
||||||
|
await cmd.parseAsync(['-i', 'nonexistent.json'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||||
|
expect(mockClient.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/mcpd/src/routes/backup.ts
Normal file
60
src/mcpd/src/routes/backup.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { BackupService } from '../services/backup/backup-service.js';
|
||||||
|
import type { RestoreService } from '../services/backup/restore-service.js';
|
||||||
|
import type { BackupBundle, BackupOptions } from '../services/backup/backup-service.js';
|
||||||
|
import type { ConflictStrategy, RestoreOptions } from '../services/backup/restore-service.js';
|
||||||
|
|
||||||
|
export interface BackupDeps {
|
||||||
|
backupService: BackupService;
|
||||||
|
restoreService: RestoreService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): void {
|
||||||
|
app.post<{
|
||||||
|
Body: {
|
||||||
|
password?: string;
|
||||||
|
resources?: Array<'servers' | 'profiles' | 'projects'>;
|
||||||
|
};
|
||||||
|
}>('/api/v1/backup', async (request) => {
|
||||||
|
const opts: BackupOptions = {};
|
||||||
|
if (request.body?.password) {
|
||||||
|
opts.password = request.body.password;
|
||||||
|
}
|
||||||
|
if (request.body?.resources) {
|
||||||
|
opts.resources = request.body.resources;
|
||||||
|
}
|
||||||
|
const bundle = await deps.backupService.createBackup(opts);
|
||||||
|
return bundle;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Body: {
|
||||||
|
bundle: BackupBundle;
|
||||||
|
password?: string;
|
||||||
|
conflictStrategy?: ConflictStrategy;
|
||||||
|
};
|
||||||
|
}>('/api/v1/restore', async (request, reply) => {
|
||||||
|
const { bundle, password, conflictStrategy } = request.body;
|
||||||
|
|
||||||
|
if (!deps.restoreService.validateBundle(bundle)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'Invalid backup bundle format', statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreOpts: RestoreOptions = {};
|
||||||
|
if (password) {
|
||||||
|
restoreOpts.password = password;
|
||||||
|
}
|
||||||
|
if (conflictStrategy) {
|
||||||
|
restoreOpts.conflictStrategy = conflictStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deps.restoreService.restore(bundle, restoreOpts);
|
||||||
|
|
||||||
|
if (result.errors.length > 0 && result.serversCreated === 0 && result.profilesCreated === 0 && result.projectsCreated === 0) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ export { registerInstanceRoutes } from './instances.js';
|
|||||||
export { registerAuditLogRoutes } from './audit-logs.js';
|
export { registerAuditLogRoutes } from './audit-logs.js';
|
||||||
export { registerHealthMonitoringRoutes } from './health-monitoring.js';
|
export { registerHealthMonitoringRoutes } from './health-monitoring.js';
|
||||||
export type { HealthMonitoringDeps } from './health-monitoring.js';
|
export type { HealthMonitoringDeps } from './health-monitoring.js';
|
||||||
|
export { registerBackupRoutes } from './backup.js';
|
||||||
|
export type { BackupDeps } from './backup.js';
|
||||||
|
|||||||
143
src/mcpd/src/services/backup/backup-service.ts
Normal file
143
src/mcpd/src/services/backup/backup-service.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
|
||||||
|
import type { IProjectRepository } from '../../repositories/project.repository.js';
|
||||||
|
import { encrypt, isSensitiveKey } from './crypto.js';
|
||||||
|
import type { EncryptedPayload } from './crypto.js';
|
||||||
|
import { APP_VERSION } from '@mcpctl/shared';
|
||||||
|
|
||||||
|
export interface BackupBundle {
|
||||||
|
version: string;
|
||||||
|
mcpctlVersion: string;
|
||||||
|
createdAt: string;
|
||||||
|
encrypted: boolean;
|
||||||
|
servers: BackupServer[];
|
||||||
|
profiles: BackupProfile[];
|
||||||
|
projects: BackupProject[];
|
||||||
|
encryptedSecrets?: EncryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupServer {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
packageName: string | null;
|
||||||
|
dockerImage: string | null;
|
||||||
|
transport: string;
|
||||||
|
repositoryUrl: string | null;
|
||||||
|
envTemplate: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupProfile {
|
||||||
|
name: string;
|
||||||
|
serverName: string;
|
||||||
|
permissions: unknown;
|
||||||
|
envOverrides: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupProject {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
profileNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupOptions {
|
||||||
|
password?: string;
|
||||||
|
resources?: Array<'servers' | 'profiles' | 'projects'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupService {
|
||||||
|
constructor(
|
||||||
|
private serverRepo: IMcpServerRepository,
|
||||||
|
private profileRepo: IMcpProfileRepository,
|
||||||
|
private projectRepo: IProjectRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
|
||||||
|
const resources = options?.resources ?? ['servers', 'profiles', 'projects'];
|
||||||
|
|
||||||
|
let servers: BackupServer[] = [];
|
||||||
|
let profiles: BackupProfile[] = [];
|
||||||
|
let projects: BackupProject[] = [];
|
||||||
|
|
||||||
|
if (resources.includes('servers')) {
|
||||||
|
const allServers = await this.serverRepo.findAll();
|
||||||
|
servers = allServers.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
packageName: s.packageName,
|
||||||
|
dockerImage: s.dockerImage,
|
||||||
|
transport: s.transport,
|
||||||
|
repositoryUrl: s.repositoryUrl,
|
||||||
|
envTemplate: s.envTemplate,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.includes('profiles')) {
|
||||||
|
const allProfiles = await this.profileRepo.findAll();
|
||||||
|
const serverMap = new Map<string, string>();
|
||||||
|
const allServers = await this.serverRepo.findAll();
|
||||||
|
for (const s of allServers) {
|
||||||
|
serverMap.set(s.id, s.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles = allProfiles.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
serverName: serverMap.get(p.serverId) ?? p.serverId,
|
||||||
|
permissions: p.permissions,
|
||||||
|
envOverrides: p.envOverrides,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.includes('projects')) {
|
||||||
|
const allProjects = await this.projectRepo.findAll();
|
||||||
|
const allProfiles = await this.profileRepo.findAll();
|
||||||
|
const profileMap = new Map<string, string>();
|
||||||
|
for (const p of allProfiles) {
|
||||||
|
profileMap.set(p.id, p.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
projects = await Promise.all(
|
||||||
|
allProjects.map(async (proj) => {
|
||||||
|
const profileIds = await this.projectRepo.getProfileIds(proj.id);
|
||||||
|
return {
|
||||||
|
name: proj.name,
|
||||||
|
description: proj.description,
|
||||||
|
profileNames: profileIds.map((id) => profileMap.get(id) ?? id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle: BackupBundle = {
|
||||||
|
version: '1',
|
||||||
|
mcpctlVersion: APP_VERSION,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
encrypted: false,
|
||||||
|
servers,
|
||||||
|
profiles,
|
||||||
|
projects,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.password) {
|
||||||
|
// Collect sensitive values and encrypt them
|
||||||
|
const secrets: Record<string, string> = {};
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const overrides = profile.envOverrides as Record<string, string> | null;
|
||||||
|
if (overrides) {
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (isSensitiveKey(key)) {
|
||||||
|
const secretKey = `profile:${profile.name}:${key}`;
|
||||||
|
secrets[secretKey] = value;
|
||||||
|
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(secrets).length > 0) {
|
||||||
|
bundle.encrypted = true;
|
||||||
|
bundle.encryptedSecrets = encrypt(JSON.stringify(secrets), options.password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/mcpd/src/services/backup/crypto.ts
Normal file
68
src/mcpd/src/services/backup/crypto.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
const SALT_LENGTH = 32;
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
const SCRYPT_COST = 16384;
|
||||||
|
|
||||||
|
export interface EncryptedPayload {
|
||||||
|
algorithm: string;
|
||||||
|
salt: string;
|
||||||
|
iv: string;
|
||||||
|
authTag: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveKey(password: string, salt: Buffer): Buffer {
|
||||||
|
return crypto.scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_COST });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encrypt(data: string, password: string): EncryptedPayload {
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
|
const key = deriveKey(password, salt);
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||||
|
const encrypted = Buffer.concat([cipher.update(data, 'utf-8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return {
|
||||||
|
algorithm: ALGORITHM,
|
||||||
|
salt: salt.toString('base64'),
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
authTag: authTag.toString('base64'),
|
||||||
|
ciphertext: encrypted.toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(payload: EncryptedPayload, password: string): string {
|
||||||
|
const salt = Buffer.from(payload.salt, 'base64');
|
||||||
|
const iv = Buffer.from(payload.iv, 'base64');
|
||||||
|
const authTag = Buffer.from(payload.authTag, 'base64');
|
||||||
|
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
|
||||||
|
|
||||||
|
const key = deriveKey(password, salt);
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
return decrypted.toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_PATTERNS = [
|
||||||
|
/_KEY$/i,
|
||||||
|
/_SECRET$/i,
|
||||||
|
/_TOKEN$/i,
|
||||||
|
/PASSWORD/i,
|
||||||
|
/^API_KEY$/i,
|
||||||
|
/^SECRET$/i,
|
||||||
|
/^TOKEN$/i,
|
||||||
|
/^CREDENTIALS$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSensitiveKey(key: string): boolean {
|
||||||
|
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||||
|
}
|
||||||
6
src/mcpd/src/services/backup/index.ts
Normal file
6
src/mcpd/src/services/backup/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { BackupService } from './backup-service.js';
|
||||||
|
export type { BackupBundle, BackupServer, BackupProfile, BackupProject, BackupOptions } from './backup-service.js';
|
||||||
|
export { RestoreService } from './restore-service.js';
|
||||||
|
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js';
|
||||||
|
export { encrypt, decrypt, isSensitiveKey } from './crypto.js';
|
||||||
|
export type { EncryptedPayload } from './crypto.js';
|
||||||
224
src/mcpd/src/services/backup/restore-service.ts
Normal file
224
src/mcpd/src/services/backup/restore-service.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
|
||||||
|
import type { IProjectRepository } from '../../repositories/project.repository.js';
|
||||||
|
import { decrypt } from './crypto.js';
|
||||||
|
import type { BackupBundle } from './backup-service.js';
|
||||||
|
|
||||||
|
export type ConflictStrategy = 'skip' | 'overwrite' | 'fail';
|
||||||
|
|
||||||
|
export interface RestoreOptions {
|
||||||
|
password?: string;
|
||||||
|
conflictStrategy?: ConflictStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreResult {
|
||||||
|
serversCreated: number;
|
||||||
|
serversSkipped: number;
|
||||||
|
profilesCreated: number;
|
||||||
|
profilesSkipped: number;
|
||||||
|
projectsCreated: number;
|
||||||
|
projectsSkipped: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RestoreService {
|
||||||
|
constructor(
|
||||||
|
private serverRepo: IMcpServerRepository,
|
||||||
|
private profileRepo: IMcpProfileRepository,
|
||||||
|
private projectRepo: IProjectRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
validateBundle(bundle: unknown): bundle is BackupBundle {
|
||||||
|
if (typeof bundle !== 'object' || bundle === null) return false;
|
||||||
|
const b = bundle as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof b['version'] === 'string' &&
|
||||||
|
Array.isArray(b['servers']) &&
|
||||||
|
Array.isArray(b['profiles']) &&
|
||||||
|
Array.isArray(b['projects'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> {
|
||||||
|
const strategy = options?.conflictStrategy ?? 'skip';
|
||||||
|
const result: RestoreResult = {
|
||||||
|
serversCreated: 0,
|
||||||
|
serversSkipped: 0,
|
||||||
|
profilesCreated: 0,
|
||||||
|
profilesSkipped: 0,
|
||||||
|
projectsCreated: 0,
|
||||||
|
projectsSkipped: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt secrets if encrypted
|
||||||
|
let secrets: Record<string, string> = {};
|
||||||
|
if (bundle.encrypted && bundle.encryptedSecrets) {
|
||||||
|
if (!options?.password) {
|
||||||
|
result.errors.push('Backup is encrypted but no password provided');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
secrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
|
||||||
|
} catch {
|
||||||
|
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore secrets into profile envOverrides
|
||||||
|
for (const profile of bundle.profiles) {
|
||||||
|
const overrides = profile.envOverrides as Record<string, string> | null;
|
||||||
|
if (overrides) {
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
|
||||||
|
const secretKey = value.slice(12, -2);
|
||||||
|
const decrypted = secrets[secretKey];
|
||||||
|
if (decrypted !== undefined) {
|
||||||
|
overrides[key] = decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore servers
|
||||||
|
const serverNameToId = new Map<string, string>();
|
||||||
|
for (const server of bundle.servers) {
|
||||||
|
try {
|
||||||
|
const existing = await this.serverRepo.findByName(server.name);
|
||||||
|
if (existing) {
|
||||||
|
if (strategy === 'fail') {
|
||||||
|
result.errors.push(`Server "${server.name}" already exists`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (strategy === 'skip') {
|
||||||
|
result.serversSkipped++;
|
||||||
|
serverNameToId.set(server.name, existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// overwrite
|
||||||
|
const updateData: Parameters<IMcpServerRepository['update']>[1] = {
|
||||||
|
description: server.description,
|
||||||
|
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||||
|
};
|
||||||
|
if (server.packageName) updateData.packageName = server.packageName;
|
||||||
|
if (server.dockerImage) updateData.dockerImage = server.dockerImage;
|
||||||
|
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
|
||||||
|
await this.serverRepo.update(existing.id, updateData);
|
||||||
|
serverNameToId.set(server.name, existing.id);
|
||||||
|
result.serversCreated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData: Parameters<IMcpServerRepository['create']>[0] = {
|
||||||
|
name: server.name,
|
||||||
|
description: server.description,
|
||||||
|
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||||
|
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>,
|
||||||
|
};
|
||||||
|
if (server.packageName) createData.packageName = server.packageName;
|
||||||
|
if (server.dockerImage) createData.dockerImage = server.dockerImage;
|
||||||
|
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
|
||||||
|
const created = await this.serverRepo.create(createData);
|
||||||
|
serverNameToId.set(server.name, created.id);
|
||||||
|
result.serversCreated++;
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore profiles
|
||||||
|
const profileNameToId = new Map<string, string>();
|
||||||
|
for (const profile of bundle.profiles) {
|
||||||
|
try {
|
||||||
|
const serverId = serverNameToId.get(profile.serverName);
|
||||||
|
if (!serverId) {
|
||||||
|
// Try to find server by name in DB
|
||||||
|
const server = await this.serverRepo.findByName(profile.serverName);
|
||||||
|
if (!server) {
|
||||||
|
result.errors.push(`Profile "${profile.name}" references unknown server "${profile.serverName}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serverNameToId.set(profile.serverName, server.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = serverNameToId.get(profile.serverName)!;
|
||||||
|
const existing = await this.profileRepo.findByServerAndName(sid, profile.name);
|
||||||
|
if (existing) {
|
||||||
|
if (strategy === 'fail') {
|
||||||
|
result.errors.push(`Profile "${profile.name}" already exists for server "${profile.serverName}"`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (strategy === 'skip') {
|
||||||
|
result.profilesSkipped++;
|
||||||
|
profileNameToId.set(profile.name, existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// overwrite
|
||||||
|
await this.profileRepo.update(existing.id, {
|
||||||
|
permissions: profile.permissions as string[],
|
||||||
|
envOverrides: profile.envOverrides as Record<string, string>,
|
||||||
|
});
|
||||||
|
profileNameToId.set(profile.name, existing.id);
|
||||||
|
result.profilesCreated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.profileRepo.create({
|
||||||
|
name: profile.name,
|
||||||
|
serverId: sid,
|
||||||
|
permissions: profile.permissions as string[],
|
||||||
|
envOverrides: profile.envOverrides as Record<string, string>,
|
||||||
|
});
|
||||||
|
profileNameToId.set(profile.name, created.id);
|
||||||
|
result.profilesCreated++;
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Failed to restore profile "${profile.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore projects
|
||||||
|
for (const project of bundle.projects) {
|
||||||
|
try {
|
||||||
|
const existing = await this.projectRepo.findByName(project.name);
|
||||||
|
if (existing) {
|
||||||
|
if (strategy === 'fail') {
|
||||||
|
result.errors.push(`Project "${project.name}" already exists`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (strategy === 'skip') {
|
||||||
|
result.projectsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// overwrite - update and set profiles
|
||||||
|
await this.projectRepo.update(existing.id, { description: project.description });
|
||||||
|
const profileIds = project.profileNames
|
||||||
|
.map((name) => profileNameToId.get(name))
|
||||||
|
.filter((id): id is string => id !== undefined);
|
||||||
|
if (profileIds.length > 0) {
|
||||||
|
await this.projectRepo.setProfiles(existing.id, profileIds);
|
||||||
|
}
|
||||||
|
result.projectsCreated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.projectRepo.create({
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
ownerId: 'system',
|
||||||
|
});
|
||||||
|
const profileIds = project.profileNames
|
||||||
|
.map((name) => profileNameToId.get(name))
|
||||||
|
.filter((id): id is string => id !== undefined);
|
||||||
|
if (profileIds.length > 0) {
|
||||||
|
await this.projectRepo.setProfiles(created.id, profileIds);
|
||||||
|
}
|
||||||
|
result.projectsCreated++;
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,3 +15,7 @@ export { MetricsCollector } from './metrics-collector.js';
|
|||||||
export type { InstanceMetrics } from './metrics-collector.js';
|
export type { InstanceMetrics } from './metrics-collector.js';
|
||||||
export { HealthAggregator } from './health-aggregator.js';
|
export { HealthAggregator } from './health-aggregator.js';
|
||||||
export type { SystemHealth, InstanceHealth } from './health-aggregator.js';
|
export type { SystemHealth, InstanceHealth } from './health-aggregator.js';
|
||||||
|
export { BackupService } from './backup/index.js';
|
||||||
|
export type { BackupBundle, BackupOptions } from './backup/index.js';
|
||||||
|
export { RestoreService } from './backup/index.js';
|
||||||
|
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './backup/index.js';
|
||||||
|
|||||||
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