Move backup SSH keys and repo URL from MCPD_BACKUP_REPO env var to a "backup-ssh" secret in the database. Keys are auto-generated on first init and stored back into the secret. Also fix ERR_HTTP_HEADERS_SENT crash caused by reply.send() without return in routes when onSend hook is registered. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { createBackupCommand } from '../../src/commands/backup.js';
|
|
|
|
const mockClient = {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
put: vi.fn(),
|
|
delete: vi.fn(),
|
|
};
|
|
|
|
const log = vi.fn();
|
|
|
|
function makeCmd() {
|
|
return createBackupCommand({ client: mockClient as never, log });
|
|
}
|
|
|
|
describe('backup command', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('creates backup command', () => {
|
|
expect(makeCmd().name()).toBe('backup');
|
|
});
|
|
|
|
it('shows status when enabled', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
enabled: true,
|
|
repoUrl: 'ssh://git@10.0.0.194:2222/michal/mcp-backup.git',
|
|
gitReachable: true,
|
|
lastSyncAt: new Date().toISOString(),
|
|
lastPushAt: null,
|
|
lastError: null,
|
|
pendingCount: 0,
|
|
});
|
|
|
|
await makeCmd().parseAsync([], { from: 'user' });
|
|
|
|
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/status');
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh://git@10.0.0.194:2222/michal/mcp-backup.git'));
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('synced'));
|
|
});
|
|
|
|
it('shows disabled when not configured', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
enabled: false,
|
|
repoUrl: null,
|
|
gitReachable: false,
|
|
lastSyncAt: null,
|
|
lastPushAt: null,
|
|
lastError: null,
|
|
pendingCount: 0,
|
|
});
|
|
|
|
await makeCmd().parseAsync([], { from: 'user' });
|
|
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('disabled'));
|
|
});
|
|
|
|
it('shows pending count', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
enabled: true,
|
|
repoUrl: 'ssh://git@host/repo.git',
|
|
gitReachable: true,
|
|
lastSyncAt: null,
|
|
lastPushAt: null,
|
|
lastError: null,
|
|
pendingCount: 5,
|
|
});
|
|
|
|
await makeCmd().parseAsync([], { from: 'user' });
|
|
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
|
|
});
|
|
|
|
it('shows SSH public key in status when enabled', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
enabled: true,
|
|
repoUrl: 'ssh://git@host/repo.git',
|
|
publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local',
|
|
gitReachable: true,
|
|
lastSyncAt: null,
|
|
lastPushAt: null,
|
|
lastError: null,
|
|
pendingCount: 0,
|
|
});
|
|
|
|
await makeCmd().parseAsync([], { from: 'user' });
|
|
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh-ed25519 AAAA... mcpd@mcpctl.local'));
|
|
});
|
|
|
|
it('shows setup instructions when disabled', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
enabled: false,
|
|
repoUrl: null,
|
|
publicKey: null,
|
|
gitReachable: false,
|
|
lastSyncAt: null,
|
|
lastPushAt: null,
|
|
lastError: null,
|
|
pendingCount: 0,
|
|
});
|
|
|
|
await makeCmd().parseAsync([], { from: 'user' });
|
|
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('mcpctl create secret backup-ssh'));
|
|
});
|
|
|
|
it('shows commit log', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
entries: [
|
|
{ hash: 'abc1234567890', date: '2026-03-08T10:00:00Z', author: 'mcpd <mcpd@mcpctl.local>', message: 'Update server grafana', manual: false },
|
|
{ hash: 'def4567890123', date: '2026-03-07T09:00:00Z', author: 'Michal <michal@test.com>', message: 'Manual fix', manual: true },
|
|
],
|
|
});
|
|
|
|
await makeCmd().parseAsync(['log'], { from: 'user' });
|
|
|
|
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=20');
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('COMMIT'));
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('[manual]'));
|
|
});
|
|
});
|
|
|
|
describe('backup restore subcommands', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('lists restore points', async () => {
|
|
mockClient.get.mockResolvedValue({
|
|
entries: [
|
|
{ hash: 'abc1234567890', date: '2026-03-08T10:00:00Z', author: 'mcpd <mcpd@mcpctl.local>', message: 'Sync' },
|
|
],
|
|
});
|
|
|
|
await makeCmd().parseAsync(['restore', 'list'], { from: 'user' });
|
|
|
|
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=30');
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
|
|
});
|
|
|
|
it('shows restore diff preview', async () => {
|
|
mockClient.post.mockResolvedValue({
|
|
targetCommit: 'abc1234567890',
|
|
targetDate: '2026-03-08T10:00:00Z',
|
|
targetMessage: 'Snapshot',
|
|
added: ['servers/new.yaml'],
|
|
removed: ['servers/old.yaml'],
|
|
modified: ['projects/default.yaml'],
|
|
});
|
|
|
|
await makeCmd().parseAsync(['restore', 'diff', 'abc1234'], { from: 'user' });
|
|
|
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('+ servers/new.yaml'));
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('- servers/old.yaml'));
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('~ projects/default.yaml'));
|
|
});
|
|
|
|
it('requires --force for restore', async () => {
|
|
mockClient.post.mockResolvedValue({
|
|
targetCommit: 'abc1234567890',
|
|
targetDate: '2026-03-08T10:00:00Z',
|
|
targetMessage: 'Snapshot',
|
|
added: ['servers/new.yaml'],
|
|
removed: [],
|
|
modified: [],
|
|
});
|
|
|
|
await makeCmd().parseAsync(['restore', 'to', 'abc1234'], { from: 'user' });
|
|
|
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
|
|
expect(mockClient.post).not.toHaveBeenCalledWith('/api/v1/backup/restore', expect.anything());
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('--force'));
|
|
});
|
|
|
|
it('executes restore with --force', async () => {
|
|
mockClient.post
|
|
.mockResolvedValueOnce({
|
|
targetCommit: 'abc1234567890',
|
|
targetDate: '2026-03-08T10:00:00Z',
|
|
targetMessage: 'Snapshot',
|
|
added: ['servers/new.yaml'],
|
|
removed: [],
|
|
modified: [],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
branchName: 'timeline/20260308-100000',
|
|
applied: 1,
|
|
deleted: 0,
|
|
errors: [],
|
|
});
|
|
|
|
await makeCmd().parseAsync(['restore', 'to', 'abc1234', '--force'], { from: 'user' });
|
|
|
|
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore', { commit: 'abc1234' });
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('1 applied'));
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('timeline/20260308-100000'));
|
|
});
|
|
|
|
it('reports restore errors', async () => {
|
|
mockClient.post
|
|
.mockResolvedValueOnce({
|
|
targetCommit: 'abc1234567890',
|
|
targetDate: '2026-03-08T10:00:00Z',
|
|
targetMessage: 'Snapshot',
|
|
added: [],
|
|
removed: [],
|
|
modified: ['servers/broken.yaml'],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
branchName: 'timeline/20260308-100000',
|
|
applied: 0,
|
|
deleted: 0,
|
|
errors: ['Failed to apply servers/broken.yaml: invalid YAML'],
|
|
});
|
|
|
|
await makeCmd().parseAsync(['restore', 'to', 'abc1234', '--force'], { from: 'user' });
|
|
|
|
expect(log).toHaveBeenCalledWith('Errors:');
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid YAML'));
|
|
});
|
|
});
|