import { describe, it, expect } from 'vitest'; import { validateLlamaConfig, computeDiff, backupFilename, applyRemoteConfig, healthWait, type SshExec, type ExecResult, } from '../ssh-config.js'; // A minimal subset of the llama-swap config schema sufficient for these tests: // top-level object with a required non-empty `models` object. const SCHEMA = { type: 'object', required: ['models'], properties: { models: { type: 'object', minProperties: 1, additionalProperties: { type: 'object', properties: { cmd: { type: 'string' } }, }, }, }, } as const; const VALID_YAML = `models:\n m1:\n cmd: "llama-server -m m1.gguf"\n`; describe('validateLlamaConfig', () => { it('accepts a valid config', () => { const r = validateLlamaConfig(VALID_YAML, SCHEMA); expect(r.valid).toBe(true); expect(r.errors).toEqual([]); }); it('rejects broken YAML with a parse error', () => { const r = validateLlamaConfig('models:\n m1:\n cmd: "x\n : :', SCHEMA); expect(r.valid).toBe(false); expect(r.errors[0]).toMatch(/YAML parse error/); }); it('rejects a config missing required models', () => { const r = validateLlamaConfig('healthCheckTimeout: 30\n', SCHEMA); expect(r.valid).toBe(false); expect(r.errors.join(' ')).toMatch(/models/); }); it('rejects a non-mapping document', () => { const r = validateLlamaConfig('- just\n- a\n- list\n', SCHEMA); expect(r.valid).toBe(false); }); }); describe('computeDiff', () => { it('returns empty for identical text', () => { expect(computeDiff('a\nb\n', 'a\nb\n')).toBe(''); }); it('marks changed lines with -/+', () => { const d = computeDiff('a\nb\nc\n', 'a\nX\nc\n'); expect(d).toContain('- b'); expect(d).toContain('+ X'); }); }); describe('backupFilename', () => { it('produces a timestamped path', () => { const name = backupFilename('/etc/llama/config.yaml', new Date('2026-06-12T03:04:05.678Z')); expect(name).toBe('/etc/llama/config.yaml.bak-20260612T030405Z'); }); }); // ─── apply pipeline failure paths ──────────────────────────────────────────── function makeExec(handlers: Record): { exec: SshExec; calls: string[] } { const calls: string[] = []; const exec: SshExec = async (_t, command) => { calls.push(command); for (const [pattern, result] of Object.entries(handlers)) { if (command.includes(pattern)) return result; } return { code: 0, stdout: '', stderr: '' }; }; return { exec, calls }; } const target = { host: 'h', user: 'u', keyPath: '/k' }; const okFetcher = (async () => new Response('{}', { status: 200 })) as unknown as typeof fetch; describe('applyRemoteConfig', () => { it('aborts at validate for an invalid config and never touches the host', async () => { const { exec, calls } = makeExec({}); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: 'not: valid: yaml: here:::', schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, }); expect(r.ok).toBe(false); expect(r.step).toBe('validate'); expect(calls).toHaveLength(0); }); it('aborts at validate when the host config is unreadable', async () => { const { exec } = makeExec({ "cat '": { code: 1, stdout: '', stderr: 'no such file' } }); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, }); expect(r.ok).toBe(false); expect(r.step).toBe('validate'); expect(r.error).toMatch(/read current failed/); }); it('backs up BEFORE write and aborts on write failure (backup retained)', async () => { const { exec, calls } = makeExec({ "cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' }, // read current 'cp ': { code: 0, stdout: '', stderr: '' }, // backup 'cat >': { code: 1, stdout: '', stderr: 'disk full' }, // write fails }); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, now: new Date('2026-06-12T00:00:00Z'), }); expect(r.ok).toBe(false); expect(r.step).toBe('write'); expect(r.backupPath).toBe('/c.yaml.bak-20260612T000000Z'); // backup (cp) must precede write (cat >) const cpIdx = calls.findIndex((c) => c.startsWith('cp ')); const writeIdx = calls.findIndex((c) => c.startsWith('cat >')); expect(cpIdx).toBeGreaterThanOrEqual(0); expect(writeIdx).toBeGreaterThan(cpIdx); }); it('aborts at restart on restart failure', async () => { const { exec } = makeExec({ "cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' }, 'cp ': { code: 0, stdout: '', stderr: '' }, 'cat >': { code: 0, stdout: '', stderr: '' }, restart: { code: 1, stdout: '', stderr: 'service not found' }, }); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, }); expect(r.ok).toBe(false); expect(r.step).toBe('restart'); }); it('aborts at health when the service never comes back', async () => { const { exec } = makeExec({ "cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' }, 'cp ': { code: 0, stdout: '', stderr: '' }, 'cat >': { code: 0, stdout: '', stderr: '' }, 'restart-svc': { code: 0, stdout: '', stderr: '' }, }); const downFetcher = (async () => { throw new Error('refused'); }) as unknown as typeof fetch; const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: downFetcher, healthAttempts: 2, healthDelayMs: 1, }); expect(r.ok).toBe(false); expect(r.step).toBe('health'); }); it('succeeds through the full pipeline', async () => { const { exec } = makeExec({ "cat '": { code: 0, stdout: 'models:\n old: {}\n', stderr: '' }, 'cp ': { code: 0, stdout: '', stderr: '' }, 'cat >': { code: 0, stdout: '', stderr: '' }, 'restart-svc': { code: 0, stdout: '', stderr: '' }, }); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, healthAttempts: 1, healthDelayMs: 1, }); expect(r.ok).toBe(true); expect(r.step).toBe('done'); expect(r.backupPath).toBeDefined(); }); }); describe('healthWait', () => { it('returns true on first OK', async () => { const ok = await healthWait('http://h', okFetcher, 3, 1); expect(ok).toBe(true); }); it('returns false after exhausting attempts', async () => { const downFetcher = (async () => new Response('', { status: 503 })) as unknown as typeof fetch; const ok = await healthWait('http://h', downFetcher, 2, 1); expect(ok).toBe(false); }); }); // ─── wrapper mode (forced-command verbs) ───────────────────────────────────── describe('applyRemoteConfig wrapper mode', () => { it('sends verbs (not raw shell) and reads the backup path from the backup verb', async () => { const { exec, calls } = makeExec({ read: { code: 0, stdout: 'models:\n old: {}\n', stderr: '' }, backup: { code: 0, stdout: '/c.yaml.bak-WRAP\n', stderr: '' }, write: { code: 0, stdout: '', stderr: '' }, restart: { code: 0, stdout: '', stderr: '' }, }); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'ignored-in-wrapper', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, mode: 'wrapper', healthAttempts: 1, healthDelayMs: 1, }); expect(r.ok).toBe(true); // backup path comes from the wrapper's stdout, not a client-computed name expect(r.backupPath).toBe('/c.yaml.bak-WRAP'); // verbs only — no cat/cp/cat > shell commands expect(calls).toEqual(['read', 'backup', 'write', 'restart']); expect(calls.some((c) => c.includes('cat') || c.includes('cp '))).toBe(false); }); it('aborts at write when the wrapper write verb fails (backup retained)', async () => { const { exec } = makeExec({ read: { code: 0, stdout: 'old\n', stderr: '' }, backup: { code: 0, stdout: '/c.yaml.bak-WRAP\n', stderr: '' }, write: { code: 1, stdout: '', stderr: 'denied' }, }); const r = await applyRemoteConfig({ target, configPath: '/c.yaml', restartCmd: 'x', newConfig: VALID_YAML, schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, mode: 'wrapper', }); expect(r.ok).toBe(false); expect(r.step).toBe('write'); expect(r.backupPath).toBe('/c.yaml.bak-WRAP'); }); });