All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / lint (pull_request) Successful in 1m47s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Successful in 4m34s
CI/CD / build (pull_request) Successful in 3m50s
CI/CD / publish (pull_request) Has been skipped
Why: API keys live in Postgres as plaintext JSON. A DB read exposes every credential in the system. Before centralising more secrets (LLM keys, etc.) we want to be able to point at an external KV store and drop DB access to sensitive rows. New model: - `SecretBackend` resource (CRUD + isDefault invariant) owns how a secret is stored. `Secret` gains `backendId` FK and `externalRef`. Reads/writes dispatch through a driver. - `plaintext` driver (near-noop, uses existing Secret.data column) is seeded as the `default` row at startup. Acts as trust root / bootstrap. - `openbao` driver (also HashiCorp Vault KV v2 compatible) talks plain HTTP, no SDK dependency. Auth via static token pulled from a plaintext-backed `Secret` through the injected SecretRefResolver. Caches resolved token. - `SecretMigrateService` moves secrets one-at-a-time: read → write dest → flip row → best-effort source delete. Interrupted runs are idempotent (skips secrets already on destination). CLI surface: - `mcpctl create|get|describe|delete secretbackend` + `--default` on create. - `mcpctl migrate secrets --from X --to Y [--names a,b] [--keep-source] [--dry-run]` - `apply -f` round-trips secretbackends (yaml/json multi-doc + grouped). - RBAC: `secretbackends` resource + `run:migrate-secrets` operation. - Fish + bash completions regenerated. docs/secret-backends.md covers the OpenBao policy, chicken-and-egg auth flow, and the migration semantics. Broke the circular dep (OpenBao needs SecretService to resolve its own token, SecretService needs SecretBackendService) with a deferred-resolver bridge in mcpd startup. 11 new driver unit tests; existing env-resolver/secret-route/ backup tests updated for the new service signatures. Full suite: 1792/1792. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
656 lines
27 KiB
TypeScript
656 lines
27 KiB
TypeScript
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';
|
|
import type { SecretService } from '../src/services/secret.service.js';
|
|
|
|
/**
|
|
* Minimal SecretService shim over a mock repo — just the `.create()` / `.update()`
|
|
* methods that RestoreService calls. We don't need the backend-dispatch path
|
|
* here since the restore happy-path tests don't exercise remote backends.
|
|
*/
|
|
function mockSecretService(repo: ISecretRepository): SecretService {
|
|
return {
|
|
create: vi.fn(async (input: unknown) => {
|
|
const data = input as { name: string; data: Record<string, string> };
|
|
return repo.create({ name: data.name, backendId: 'backend-plaintext', data: data.data, externalRef: '' });
|
|
}),
|
|
update: vi.fn(async (id: string, input: unknown) => {
|
|
const data = input as { data: Record<string, string> };
|
|
return repo.update(id, { data: data.data });
|
|
}),
|
|
} as unknown as SecretService;
|
|
}
|
|
|
|
// 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', proxyModel: '', 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.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<typeof vi.fn>).mockResolvedValue([]);
|
|
const emptySecretRepo = mockSecretRepo();
|
|
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
const emptyProjectRepo = mockProjectRepo();
|
|
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
const emptyUserRepo = mockUserRepo();
|
|
(emptyUserRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
const emptyGroupRepo = mockGroupRepo();
|
|
(emptyGroupRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
const emptyRbacRepo = mockRbacRepo();
|
|
(emptyRbacRepo.findAll as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(null);
|
|
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, mockSecretService(secretRepo), userRepo, groupRepo, rbacRepo);
|
|
});
|
|
|
|
const validBundle = {
|
|
version: '1',
|
|
mcpctlVersion: '0.0.1',
|
|
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', 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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(null);
|
|
// After server restore, we can find them
|
|
let serverCallCount = 0;
|
|
(serverRepo.findByName as ReturnType<typeof vi.fn>).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',
|
|
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<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('skips existing users', async () => {
|
|
(userRepo.findByEmail as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<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('overwrites existing rbac bindings', async () => {
|
|
(rbacRepo.findByName as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockImplementation(async () => { callOrder.push('secret'); return { id: 'sec' }; });
|
|
(serverRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; });
|
|
(userRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; });
|
|
(groupRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; });
|
|
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [] }; });
|
|
(rbacRepo.create as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(null);
|
|
const rSecRepo = mockSecretRepo();
|
|
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
const rPrRepo = mockProjectRepo();
|
|
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
const rUserRepo = mockUserRepo();
|
|
(rUserRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
const rGroupRepo = mockGroupRepo();
|
|
(rGroupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
const rRbacRepo = mockRbacRepo();
|
|
(rRbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, mockSecretService(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');
|
|
});
|
|
});
|