All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m45s
CD / Build and push images (push) Successful in 3m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Off-host backups for every self-hoster, configured entirely in the admin UI — supersedes the host-specific mirror plan behind #84. shared: - webdav.ts (new package entry like token-crypto): minimal WebDAV client with basic auth — PROPFIND (tolerant multistatus parser), MKCOL, PUT (streamed), GET, DELETE; Nextcloud DAV path derived from the plain server URL, explicit DAV bases pass through - backup-status.ts: additive remote-upload status in status.json, the restore-status.json contract (running/succeeded/failed + staleness bound), the backup_command/backup_maintenance NOTIFY channels, and the one-bundle-per-set naming (dorfteich-backup-<id>.tar.gz) - backup-set.ts moved here from apps/backup (api lists local sets) backup sidecar: - reads the backup.* instance settings directly from the database (admin changes apply next run; local retention row overrides the env) and the app password from the secret store - after each successful set: bundle dump + files archive + manifest into ONE self-contained tar.gz, upload via WebDAV per schedule (off/daily/weekly; manual runs always upload), prune remote bundles — never the newest — and record the outcome in status.json; upload failures alert via a new backupUploadFailed mail (de+en) - command listener on backup_command (run / restore) with a serial queue against the nightly timer - restore orchestrator: restore-status.json → maintenance NOTIFY → grace → (remote: download + manifest-verify bundle) → terminate other DB connections → shared perform-restore path (same code as restore.sh) → final status + maintenance exit api: - MaintenanceGuard (global, registered before the setup gate): 503 maintenance_mode while restore-status says running; health endpoints and the new public GET /backup/restore-status stay exempt; a stale running state (crashed sidecar) unblocks after 30 min - MaintenanceStateService watches the file and restarts the api after a successful restore (fresh caches, migrate-on-start for older dumps); main.ts refuses to touch the database while a restore runs — a container restarting mid-restore must not race pg_restore with migrate deploy - worker sweeps (conversion, mail outbox, scheduler) catch transient database failures instead of dying on an unhandled rejection — the restore's connection termination crashed the api in verification - backup admin endpoints under /admin/system/backup: settings (live connection test before save, password write-only into the secret store), nextcloud/test, sets (local via the ro backups mount + remote via WebDAV), run + restore (type-to-confirm backstop, source validation) — commands travel as NOTIFY payloads; audit actions backup.settings_changed/run_triggered/restore_requested - readyz: new warning-level backup_remote check while a target is configured (26 h daily / 170 h weekly bound) collab: - maintenance listener: on enter, persist + close every live session and refuse new connections until exit (failsafe timeout 30 min) — no in-memory document may write pre-restore content back afterwards web: - Admin → System backup section: status card with remote facts and a "Back up now" button, the Nextcloud settings form with test button, and the restore picker (local + remote sets, type-to-confirm) - global maintenance screen: any 503 maintenance_mode flips the SPA to a status page polling the exempt endpoint, reloading when the instance returns Verified end-to-end against a live stack (fresh DB, native api + sidecar, fake WebDAV server): configure → test → manual backup → bundle upload → readyz/sets/status surfaces → remote restore with maintenance gate, marker rollback and api restart; suites: shared 21, backup 9, collab 11, api 58 files green, lint + i18n:check + typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
163 lines
5.7 KiB
TypeScript
163 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
basicAuthHeader,
|
|
folderSegments,
|
|
parsePropfind,
|
|
webdavCheck,
|
|
webdavFileUrl,
|
|
webdavFolderUrl,
|
|
webdavList,
|
|
} from './webdav';
|
|
|
|
/** A trimmed real-world Nextcloud PROPFIND (depth 1) multistatus body. */
|
|
const NEXTCLOUD_PROPFIND = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/backupuser/dorfteich-backups/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getlastmodified>Sat, 11 Jul 2026 03:00:22 GMT</d:getlastmodified>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/backupuser/dorfteich-backups/dorfteich-backup-20260711-030001.tar.gz</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getlastmodified>Sat, 11 Jul 2026 03:00:22 GMT</d:getlastmodified>
|
|
<d:getcontentlength>1048576</d:getcontentlength>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/remote.php/dav/files/backupuser/dorfteich-backups/notes%20%26%20misc</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>`;
|
|
|
|
const target = {
|
|
baseUrl: 'https://cloud.example.com',
|
|
username: 'backupuser',
|
|
password: 'app-password',
|
|
folder: 'dorfteich-backups',
|
|
};
|
|
|
|
describe('webdav url derivation', () => {
|
|
it('derives the Nextcloud DAV path from the base url', () => {
|
|
expect(webdavFolderUrl(target)).toBe(
|
|
'https://cloud.example.com/remote.php/dav/files/backupuser/dorfteich-backups',
|
|
);
|
|
});
|
|
|
|
it('keeps an explicit DAV base verbatim (generic WebDAV servers)', () => {
|
|
expect(webdavFolderUrl({ ...target, baseUrl: 'https://dav.example.com/dav/home/' })).toBe(
|
|
'https://dav.example.com/dav/home/dorfteich-backups',
|
|
);
|
|
});
|
|
|
|
it('encodes folder segments and file names', () => {
|
|
expect(webdavFileUrl({ ...target, folder: 'backups/my instance' }, 'a b.tar.gz')).toBe(
|
|
'https://cloud.example.com/remote.php/dav/files/backupuser/backups/my%20instance/a%20b.tar.gz',
|
|
);
|
|
});
|
|
|
|
it('rejects traversal segments and slashes in file names', () => {
|
|
expect(() => folderSegments('../etc')).toThrow(/segments/);
|
|
expect(() => webdavFileUrl(target, 'x/y')).toThrow(/file name/);
|
|
});
|
|
});
|
|
|
|
describe('parsePropfind', () => {
|
|
it('parses a Nextcloud multistatus and skips the folder itself', () => {
|
|
const entries = parsePropfind(
|
|
NEXTCLOUD_PROPFIND,
|
|
'/remote.php/dav/files/backupuser/dorfteich-backups/',
|
|
);
|
|
expect(entries).toEqual([
|
|
{
|
|
name: 'dorfteich-backup-20260711-030001.tar.gz',
|
|
isCollection: false,
|
|
sizeBytes: 1_048_576,
|
|
lastModified: 'Sat, 11 Jul 2026 03:00:22 GMT',
|
|
},
|
|
{
|
|
name: 'notes & misc',
|
|
isCollection: true,
|
|
sizeBytes: null,
|
|
lastModified: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('tolerates uppercase and missing namespace prefixes', () => {
|
|
const xml = `<multistatus xmlns="DAV:"><response>
|
|
<href>/dav/f/file.bin</href>
|
|
<propstat><prop><getcontentlength>7</getcontentlength><resourcetype/></prop></propstat>
|
|
</response></multistatus>`;
|
|
expect(parsePropfind(xml, '/dav/f/')).toEqual([
|
|
{ name: 'file.bin', isCollection: false, sizeBytes: 7, lastModified: null },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('webdavCheck', () => {
|
|
it('creates missing folder segments via MKCOL', async () => {
|
|
const calls: { url: string; method: string }[] = [];
|
|
const fetchLike = async (url: string, init?: RequestInit): Promise<Response> => {
|
|
const method = init?.method ?? 'GET';
|
|
calls.push({ url, method });
|
|
if (method === 'PROPFIND' && url.endsWith('/dorfteich-backups')) {
|
|
return new Response('', { status: 404 });
|
|
}
|
|
return new Response('<d:multistatus xmlns:d="DAV:"/>', { status: 207 });
|
|
};
|
|
const result = await webdavCheck(target, fetchLike);
|
|
expect(result.ok).toBe(true);
|
|
expect(calls.map((c) => c.method)).toEqual(['PROPFIND', 'PROPFIND', 'MKCOL']);
|
|
});
|
|
|
|
it('reports authentication failures readably', async () => {
|
|
const fetchLike = async (): Promise<Response> =>
|
|
new Response('', { status: 401, statusText: 'Unauthorized' });
|
|
const result = await webdavCheck(target, fetchLike);
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: expect.stringContaining('authentication failed') as unknown as string,
|
|
});
|
|
});
|
|
|
|
it('turns thrown network errors into readable results', async () => {
|
|
const fetchLike = async (): Promise<Response> => {
|
|
throw new Error('getaddrinfo ENOTFOUND cloud.example.com');
|
|
};
|
|
const result = await webdavCheck(target, fetchLike);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('ENOTFOUND');
|
|
});
|
|
});
|
|
|
|
describe('webdavList', () => {
|
|
it('sends PROPFIND depth 1 with basic auth and parses the body', async () => {
|
|
let seen: RequestInit | undefined;
|
|
const fetchLike = async (_url: string, init?: RequestInit): Promise<Response> => {
|
|
seen = init;
|
|
return new Response(NEXTCLOUD_PROPFIND, { status: 207 });
|
|
};
|
|
const result = await webdavList(target, fetchLike);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toHaveLength(2);
|
|
expect((seen?.headers as Record<string, string>).Depth).toBe('1');
|
|
expect((seen?.headers as Record<string, string>).Authorization).toBe(basicAuthHeader(target));
|
|
});
|
|
});
|