dorfteich/apps/backup/src/mirror.test.ts
Claude Fable 5 394d1c811d
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
#192: deploy-level backup target allowlist
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:17:14 +02:00

168 lines
5.8 KiB
TypeScript

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', () => {
const base = {
BACKUP_MIRROR_SSH_PORT: 22,
BACKUP_ALLOWED_TARGETS: ['h'],
} as unknown as BackupEnv;
it('requires both target and key; port defaults to 22', () => {
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 });
});
it('is null when the target host is outside the deploy allowlist (issue #192)', () => {
const configured = {
...base,
BACKUP_MIRROR_TARGET: 'u@h:/x/',
BACKUP_MIRROR_SSH_KEY: '/data/secrets/key',
} as BackupEnv;
expect(
resolveMirrorConfig({ ...configured, BACKUP_ALLOWED_TARGETS: ['other.host'] } as BackupEnv),
).toBeNull();
// The empty allowlist disables the mirror outright.
expect(
resolveMirrorConfig({ ...configured, BACKUP_ALLOWED_TARGETS: [] } as BackupEnv),
).toBeNull();
});
});
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);
});
});