import { createServer, type Server } from 'node:http'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; 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 { extractArchiveTo } from './archive.js'; import { buildBundle, fetchRemoteSet, pruneRemote, uploadDue, uploadSet } from './remote.js'; import { archiveFileName, dumpFileName } from './backup-set.js'; /** * In-memory WebDAV server covering the subset the sidecar uses — the tests * exercise the real HTTP path including streamed PUT bodies, not a mock of * our own client. */ function createDavServer(): { server: Server; files: Map; collections: Set; start(): Promise; stop(): Promise; failWith?: number; setFailWith(status: number | undefined): void; } { const files = new Map(); const collections = new Set(['/remote.php/dav/files/tester']); let failWith: number | undefined; const server = createServer((req, res) => { if (failWith) { res.statusCode = failWith; return res.end(); } const path = decodeURIComponent(new URL(req.url!, 'http://x').pathname).replace(/\/+$/, ''); const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { switch (req.method) { case 'PROPFIND': { if (!collections.has(path) && !files.has(path)) { res.statusCode = 404; return res.end(); } const children = req.headers.depth === '1' ? [...files.keys()].filter((name) => name.startsWith(`${path}/`)) : []; res.statusCode = 207; res.setHeader('Content-Type', 'application/xml'); return res.end( ` ${path}/ ${children .map( (name) => `${encodeURI(name)} ${files.get(name)!.length} `, ) .join('')} `, ); } case 'MKCOL': collections.add(path); res.statusCode = 201; return res.end(); case 'PUT': files.set(path, Buffer.concat(chunks)); res.statusCode = 201; return res.end(); case 'GET': { const body = files.get(path); if (!body) { res.statusCode = 404; return res.end(); } res.statusCode = 200; return res.end(body); } case 'DELETE': { const existed = files.delete(path); res.statusCode = existed ? 204 : 404; return res.end(); } default: res.statusCode = 405; return res.end(); } }); }); return { server, files, collections, setFailWith: (status) => { failWith = status; }, start: () => new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const address = server.address() as { port: number }; resolve(`http://127.0.0.1:${address.port}`); }); }), stop: () => new Promise((resolve) => { server.close(() => resolve()); }), }; } const silentLog = { info: () => {}, warn: () => {}, error: () => {} }; const noMailEnv = { BACKUP_MAIL_TO: undefined } as unknown as BackupEnv; describe('uploadDue', () => { const now = new Date('2026-07-12T03:00:00Z'); it('never uploads on schedule "off"', () => { expect(uploadDue('off', null, now)).toBe(false); expect(uploadDue('off', '2026-01-01T00:00:00Z', now)).toBe(false); }); it('uploads after every set on "daily"', () => { expect(uploadDue('daily', now.toISOString(), now)).toBe(true); }); it('uploads weekly once the last copy is ~a week old', () => { expect(uploadDue('weekly', null, now)).toBe(true); expect(uploadDue('weekly', '2026-07-10T03:00:00Z', now)).toBe(false); expect(uploadDue('weekly', '2026-07-05T02:00:00Z', now)).toBe(true); }); }); describe('remote bundle roundtrip', () => { let dir: string; let restoreDir: string; const dav = createDavServer(); let baseUrl: string; const backupId = '20260712-030000'; const target = (): { baseUrl: string; username: string; password: string; folder: string } => ({ baseUrl, username: 'tester', password: 'secret', folder: 'dorfteich-backups', }); beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dorfteich-remote-')); restoreDir = await mkdtemp(join(tmpdir(), 'dorfteich-remote-restore-')); baseUrl = await dav.start(); dav.setFailWith(undefined); dav.files.clear(); await writeFile(join(dir, dumpFileName(backupId)), 'dump-bytes'); await writeFile(join(dir, archiveFileName(backupId)), 'archive-bytes'); }); afterEach(async () => { await dav.stop(); await rm(dir, { recursive: true, force: true }); await rm(restoreDir, { recursive: true, force: true }); }); it('builds a self-contained bundle with a manifest', async () => { const bundle = await buildBundle(dir, backupId, new Date('2026-07-12T03:00:05Z')); expect(bundle.sizeBytes).toBeGreaterThan(0); await extractArchiveTo(bundle.path, restoreDir); const manifest = JSON.parse(await readFile(join(restoreDir, 'manifest.json'), 'utf8')); expect(manifest).toMatchObject({ schemaVersion: 1, backupId }); expect(await readFile(join(restoreDir, dumpFileName(backupId)), 'utf8')).toBe('dump-bytes'); expect(await readFile(join(restoreDir, archiveFileName(backupId)), 'utf8')).toBe( 'archive-bytes', ); }); it('uploads the bundle, creates the folder, and cleans up locally', async () => { const result = await uploadSet({ env: noMailEnv, backupsDir: dir, target: target(), remoteRetentionDays: 30, backupId, previous: undefined, now: () => new Date('2026-07-12T03:00:10Z'), log: silentLog, }); expect(result.lastUpload.outcome).toBe('succeeded'); expect(result.lastSuccessfulUpload?.backupId).toBe(backupId); const uploaded = [...dav.files.keys()]; expect(uploaded).toEqual([ `/remote.php/dav/files/tester/dorfteich-backups/dorfteich-backup-${backupId}.tar.gz`, ]); // Bundle and manifest are derived data — gone after the upload. expect(existsSync(join(dir, `dorfteich-backup-${backupId}.tar.gz`))).toBe(false); expect(existsSync(join(dir, 'manifest.json'))).toBe(false); }); it('reports a failed upload without throwing and keeps the previous success', async () => { dav.setFailWith(401); const previous = { lastUpload: { backupId: '20260711-030000', finishedAt: '2026-07-11T03:01:00Z', outcome: 'succeeded' as const, sizeBytes: 10, }, lastSuccessfulUpload: { backupId: '20260711-030000', finishedAt: '2026-07-11T03:01:00Z', sizeBytes: 10, }, }; const result = await uploadSet({ env: noMailEnv, backupsDir: dir, target: target(), remoteRetentionDays: 30, backupId, previous, now: () => new Date('2026-07-12T03:00:10Z'), log: silentLog, }); expect(result.lastUpload.outcome).toBe('failed'); expect(result.lastUpload.error).toContain('authentication failed'); expect(result.lastSuccessfulUpload).toEqual(previous.lastSuccessfulUpload); }); it('prunes expired remote bundles but never the newest one', async () => { const folder = '/remote.php/dav/files/tester/dorfteich-backups'; dav.collections.add(folder); dav.files.set(`${folder}/dorfteich-backup-20260101-030000.tar.gz`, Buffer.from('old')); dav.files.set(`${folder}/dorfteich-backup-20260102-030000.tar.gz`, Buffer.from('old2')); dav.files.set(`${folder}/unrelated-file.txt`, Buffer.from('keep')); await pruneRemote(target(), 30, new Date('2026-07-12T03:00:00Z'), silentLog); expect([...dav.files.keys()].sort()).toEqual([ `${folder}/dorfteich-backup-20260102-030000.tar.gz`, `${folder}/unrelated-file.txt`, ]); }); it('downloads and unpacks a remote set, verifying the manifest', async () => { await uploadSet({ env: noMailEnv, backupsDir: dir, target: target(), remoteRetentionDays: 30, backupId, previous: undefined, now: () => new Date(), log: silentLog, }); await fetchRemoteSet({ backupsDir: restoreDir, target: target(), backupId, log: silentLog }); expect(await readFile(join(restoreDir, dumpFileName(backupId)), 'utf8')).toBe('dump-bytes'); expect(await readFile(join(restoreDir, archiveFileName(backupId)), 'utf8')).toBe( 'archive-bytes', ); }); it('fails a download of a set that does not exist remotely', async () => { await expect( fetchRemoteSet({ backupsDir: restoreDir, target: target(), backupId: '20250101-000000', log: silentLog, }), ).rejects.toThrow(/download/); }); });