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
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
56 lines
2.1 KiB
TypeScript
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')]);
|
|
});
|
|
});
|