import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { BackupEnv } from '@dorfteich/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildRsyncArgs, mirrorSets, parseTransferredFiles, resolveMirrorConfig, } from './mirror.js'; const silentLog = { info: () => {}, warn: () => {}, error: () => {} }; const noMailEnv = { BACKUP_MAIL_TO: undefined } as unknown as BackupEnv; function hasRsync(): boolean { try { execFileSync('rsync', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } } describe('resolveMirrorConfig', () => { it('requires both target and key; port defaults to 22', () => { const base = { BACKUP_MIRROR_SSH_PORT: 22 } as unknown as BackupEnv; expect(resolveMirrorConfig(base)).toBeNull(); expect( resolveMirrorConfig({ ...base, BACKUP_MIRROR_TARGET: 'u@h:/x/' } as BackupEnv), ).toBeNull(); expect( resolveMirrorConfig({ ...base, BACKUP_MIRROR_TARGET: 'u@h:/x/', BACKUP_MIRROR_SSH_KEY: '/data/secrets/key', } as BackupEnv), ).toEqual({ target: 'u@h:/x/', sshKeyFile: '/data/secrets/key', sshPort: 22 }); }); }); describe('buildRsyncArgs', () => { it('transfers only set files, deletes within the filter, uses the pinned ssh', () => { const args = buildRsyncArgs( { target: 'u@h:/backups/', sshKeyFile: '/k', sshPort: 2222 }, '/backups', ); expect(args).toContain('--delete'); expect(args).toContain('--include=db-*.dump'); expect(args).toContain('--include=files-*.tar.gz'); expect(args).toContain('--exclude=*'); const ssh = args[args.indexOf('-e') + 1]!; expect(ssh).toContain('-i /k'); expect(ssh).toContain('-p 2222'); expect(ssh).toContain('BatchMode=yes'); expect(args.at(-2)).toBe('/backups/'); expect(args.at(-1)).toBe('u@h:/backups/'); }); }); describe('parseTransferredFiles', () => { it('reads the rsync stats line, tolerating thousands separators', () => { expect(parseTransferredFiles('Number of regular files transferred: 4\n')).toBe(4); expect(parseTransferredFiles('Number of regular files transferred: 1,234\n')).toBe(1234); expect(parseTransferredFiles('no stats here')).toBeUndefined(); }); }); describe.skipIf(!hasRsync())('mirrorSets (real rsync, local target)', () => { let source: string; let target: string; let keyFile: string; beforeEach(async () => { source = await mkdtemp(join(tmpdir(), 'dorfteich-mirror-src-')); target = await mkdtemp(join(tmpdir(), 'dorfteich-mirror-dst-')); // rsync to a local path ignores -e ssh; a dummy key satisfies the check. keyFile = join(source, '.dummy-key'); await writeFile(keyFile, 'dummy'); await writeFile(join(source, 'db-20260712-030000.dump'), 'dump-1'); await writeFile(join(source, 'files-20260712-030000.tar.gz'), 'files-1'); await writeFile(join(source, 'db-20260711-030000.dump'), 'dump-0'); await writeFile(join(source, 'files-20260711-030000.tar.gz'), 'files-0'); await writeFile(join(source, 'status.json'), '{}'); }); afterEach(async () => { await rm(source, { recursive: true, force: true }); await rm(target, { recursive: true, force: true }); }); const run = () => mirrorSets({ env: noMailEnv, config: { target: `${target}/`, sshKeyFile: keyFile, sshPort: 22 }, backupsDir: source, previous: undefined, now: () => new Date('2026-07-12T03:05:00Z'), log: silentLog, }); it('transfers set files only, is idempotent, and aligns retention', async () => { const first = await run(); expect(first.lastRun.outcome).toBe('succeeded'); expect(first.lastRun.transferredFiles).toBe(4); expect(first.lastSuccessAt).not.toBeNull(); expect((await readdir(target)).sort()).toEqual([ 'db-20260711-030000.dump', 'db-20260712-030000.dump', 'files-20260711-030000.tar.gz', 'files-20260712-030000.tar.gz', ]); // Idempotent re-run: nothing travels. const second = await run(); expect(second.lastRun.outcome).toBe('succeeded'); expect(second.lastRun.transferredFiles).toBe(0); // A locally pruned set disappears remotely too (retention alignment). await rm(join(source, 'db-20260711-030000.dump')); await rm(join(source, 'files-20260711-030000.tar.gz')); await run(); expect((await readdir(target)).sort()).toEqual([ 'db-20260712-030000.dump', 'files-20260712-030000.tar.gz', ]); }); it('reports a failure without throwing and carries the last success', async () => { const good = await run(); const failed = await mirrorSets({ env: noMailEnv, config: { target: `${target}/`, sshKeyFile: '/nonexistent-key', sshPort: 22 }, backupsDir: source, previous: good, now: () => new Date('2026-07-12T03:10:00Z'), log: silentLog, }); expect(failed.lastRun.outcome).toBe('failed'); expect(failed.lastRun.error).toContain('not found'); expect(failed.lastSuccessAt).toBe(good.lastSuccessAt); expect(existsSync(join(target, 'db-20260712-030000.dump'))).toBe(true); }); });