dorfteich/apps/backup/src/archive.test.ts
Claude Fable 5 8dbff86537
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 17:29:15 +02:00

56 lines
2.1 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { archiveBase, createArchive, extractArchive } from './archive.js';
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dorfteich-archive-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe('archiveBase', () => {
it('requires a shared parent directory', () => {
expect(() => archiveBase(['/data/uploads', '/other/plugins'])).toThrow(/share one parent/);
expect(archiveBase(['/data/uploads', '/data/plugins'])).toEqual({
base: '/data',
names: ['uploads', 'plugins'],
});
});
});
describe('create/extract round-trip (real tar)', () => {
it('restores the archived files byte-identically', async () => {
const source = join(dir, 'source');
await mkdir(join(source, 'uploads', 'ab'), { recursive: true });
await mkdir(join(source, 'plugins'), { recursive: true });
await writeFile(join(source, 'uploads', 'ab', 'file.png'), 'png-bytes');
await writeFile(join(source, 'plugins', 'manifest.json'), '{"id":"toc"}');
const archive = join(dir, 'files.tar.gz');
await createArchive(archive, [join(source, 'uploads'), join(source, 'plugins')]);
const target = join(dir, 'target');
await mkdir(target, { recursive: true });
await extractArchive(archive, [join(target, 'uploads'), join(target, 'plugins')]);
expect(await readFile(join(target, 'uploads', 'ab', 'file.png'), 'utf8')).toBe('png-bytes');
expect(await readFile(join(target, 'plugins', 'manifest.json'), 'utf8')).toBe('{"id":"toc"}');
});
it('produces a valid empty archive when no data directory exists yet', async () => {
const archive = join(dir, 'empty.tar.gz');
await createArchive(archive, [join(dir, 'none', 'uploads'), join(dir, 'none', 'plugins')]);
const target = join(dir, 'extract');
await mkdir(target, { recursive: true });
await extractArchive(archive, [join(target, 'uploads'), join(target, 'plugins')]);
});
});