import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { execFileMock, spawnMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), spawnMock: vi.fn(), })); const { serverEnvMock } = vi.hoisted(() => ({ serverEnvMock: { DATABASE_URL: "postgres://postgres:postgres@localhost:5432/homeio", STORE_APP_DATA_ROOT: "", FILES_ROOT: "", NODE_ENV: "test", }, })); vi.mock("node:child_process", () => ({ execFile: execFileMock, spawn: spawnMock, })); const tempRoots = { dataRoot: "", stacksRoot: "", }; vi.mock("@/lib/server/storage/data-root", () => ({ resolveDataRootDirectory: () => tempRoots.dataRoot, resolveStoreStacksRoot: () => tempRoots.stacksRoot, })); vi.mock("@/lib/server/env", () => ({ serverEnv: serverEnvMock, })); import { buildRestoreShellCommand, getSystemBackupsSnapshot, resolveManagedBackupRoot, runSystemBackupNow, scheduleSystemBackupRestore, } from "@/lib/server/modules/system/backup-service"; function resolveExecFileCallback(args: unknown[]) { const maybeCallback = args.at(-1); if (typeof maybeCallback !== "function") { throw new Error("Expected execFile callback"); } return maybeCallback as ( error: Error | null, stdout?: string, stderr?: string, ) => void; } function mockExecFileForBackup() { execFileMock.mockImplementation( (command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "systemctl") { callback(new Error("disabled")); return; } if (command === "pg_dump") { const dumpIndex = args.indexOf("--file"); void writeFile(args[dumpIndex + 1]!, "db-dump").then(() => { callback(null, "", ""); }); return; } if (command === "tar") { void writeFile(args[1]!, "archive").then(() => { callback(null, "", ""); }); return; } callback(null, "", ""); }, ); } describe("backup-service", () => { beforeEach(async () => { execFileMock.mockReset(); spawnMock.mockReset(); const root = await mkdtemp(path.join(os.tmpdir(), "homeio-backup-")); tempRoots.dataRoot = path.join(root, "DATA"); tempRoots.stacksRoot = path.join(tempRoots.dataRoot, "Apps"); await mkdir(tempRoots.stacksRoot, { recursive: true }); await writeFile( path.join(root, "package.json"), JSON.stringify({ version: "0.1.72" }), ); process.chdir(root); serverEnvMock.STORE_APP_DATA_ROOT = path.join( tempRoots.dataRoot, "AppData", ); serverEnvMock.FILES_ROOT = tempRoots.dataRoot; serverEnvMock.NODE_ENV = "test"; }); it("pins the managed backup root to /DATA in production", () => { serverEnvMock.NODE_ENV = "production"; expect(resolveManagedBackupRoot()).toBe("/DATA/Backups/homeio"); }); it("creates an archive manifest and prunes old backups beyond retention", async () => { mockExecFileForBackup(); const backupRoot = resolveManagedBackupRoot(); await mkdir(backupRoot, { recursive: true }); for (let index = 0; index < 8; index += 1) { const id = `backup-old-${index}`; await writeFile(path.join(backupRoot, `${id}.tar.gz`), "old"); await writeFile( path.join(backupRoot, `${id}.manifest.json`), JSON.stringify({ id, createdAt: `2026-03-0${index + 1}T09:00:00.000Z`, sizeBytes: 3, appVersion: "0.1.71", hostname: "old-host", backupPath: path.join(backupRoot, `${id}.tar.gz`), dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }), ); } const backup = await runSystemBackupNow(); expect(backup.id).toContain("backup-"); const manifestRaw = await readFile( path.join(backupRoot, `${backup.id}.manifest.json`), "utf8", ); const manifest = JSON.parse(manifestRaw); expect(manifest.backupPath).toBe( path.join(backupRoot, `${backup.id}.tar.gz`), ); // stacks root is inside data root — should NOT be marked as separately archived expect(manifest.stacksRootIncluded).toBe(false); await expect( stat(path.join(backupRoot, "backup-old-0.tar.gz")), ).rejects.toBeTruthy(); }); it("marks stacksRootIncluded true only when stacks root is external and exists", async () => { const externalStacksRoot = path.join(tempRoots.dataRoot, "..", "ext-stacks"); tempRoots.stacksRoot = externalStacksRoot; await mkdir(externalStacksRoot, { recursive: true }); mockExecFileForBackup(); const backupRoot = resolveManagedBackupRoot(); await mkdir(backupRoot, { recursive: true }); const backup = await runSystemBackupNow(); const manifestRaw = await readFile( path.join(backupRoot, `${backup.id}.manifest.json`), "utf8", ); expect(JSON.parse(manifestRaw).stacksRootIncluded).toBe(true); }); it("lists settings and backups from the managed backup root", async () => { const backupRoot = resolveManagedBackupRoot(); await mkdir(backupRoot, { recursive: true }); await writeFile( path.join(backupRoot, "backup-1.manifest.json"), JSON.stringify({ id: "backup-1", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: path.join(backupRoot, "backup-1.tar.gz"), dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }), ); execFileMock.mockImplementation((...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); callback(new Error("disabled")); }); const snapshot = await getSystemBackupsSnapshot(); expect(snapshot.backups).toHaveLength(1); expect(snapshot.backupRoot).toBe(backupRoot); }); it("builds a restore command and schedules it for valid backups", async () => { const backupRoot = resolveManagedBackupRoot(); await mkdir(backupRoot, { recursive: true }); await writeFile(path.join(backupRoot, "backup-1.tar.gz"), "archive"); await writeFile( path.join(backupRoot, "backup-1.manifest.json"), JSON.stringify({ id: "backup-1", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: path.join(backupRoot, "backup-1.tar.gz"), dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }), ); // All execFile calls fail EXCEPT systemd-run (which schedules the restore). execFileMock.mockImplementation((...rest: unknown[]) => { const cmd = rest[0] as string; const callback = resolveExecFileCallback(rest); if (cmd === "systemd-run") { callback(null); } else { callback(new Error("disabled")); } }); const accepted = await scheduleSystemBackupRestore("backup-1"); expect(accepted.backupId).toBe("backup-1"); const command = buildRestoreShellCommand({ id: "backup-1", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: path.join(backupRoot, "backup-1.tar.gz"), dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }); expect(command).toContain("docker compose -f"); expect(command).toContain("set -Eeuo pipefail"); expect(command).toContain("systemctl stop home-server.service || true"); expect(command).toContain("DROP SCHEMA IF EXISTS public CASCADE"); expect(command).toContain("psql"); expect(command).toContain("systemctl reboot"); }); it("restores without being able to destroy what it cannot replace", async () => { const command = buildRestoreShellCommand({ id: "backup-guard", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: "/DATA/Backups/homeio/backup-guard.tar.gz", dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }); const restoreRoot = "/var/tmp/homeio-restore-backup-guard"; // The dump has to be there before anything is deleted; an archive without // one used to take the database with it. expect(command).toContain(`test -s '${restoreRoot}/db/database.sql'`); expect(command.indexOf("test -s")).toBeLessThan(command.indexOf("rm -rf {} +")); // drizzle holds the migration journal outside `public`; leaving it behind // made the dump's own CREATE SCHEMA fail 25 lines in. expect(command).toContain("DROP SCHEMA IF EXISTS drizzle CASCADE"); // Reset and reload as one transaction, so a failure rolls back rather than // leaving an empty database. expect(command).toContain("--single-transaction"); // Backups live under the data root but are excluded from the archive, so // wiping it wholesale destroyed every backup, including this one. expect(command).toContain("! -name 'Backups'"); // And the reset has to come before the dump in the same file, or the // transaction would load into a schema it is about to drop. const combined = command.indexOf("printf '%s"); expect(combined).toBeGreaterThan(-1); expect(combined).toBeLessThan(command.indexOf("--single-transaction")); }); it("uses FILES_ROOT as the managed data snapshot root when it differs from stacks", async () => { const root = await mkdtemp( path.join(os.tmpdir(), "homeio-backup-files-root-"), ); tempRoots.dataRoot = path.join(root, "fallback-DATA"); tempRoots.stacksRoot = "/var/lib/home-server/stacks"; serverEnvMock.STORE_APP_DATA_ROOT = "/DATA/AppData"; serverEnvMock.FILES_ROOT = "/wrong-files-root"; const restoreRoot = path.join("/var/tmp", "homeio-restore-backup-2"); const command = buildRestoreShellCommand({ id: "backup-2", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: "/DATA/Backups/homeio/backup-2.tar.gz", dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }); // Backups sit outside this data root here, so there is nothing to spare // and the wipe stays unguarded. expect(command).toContain( "find '/DATA' -mindepth 1 -maxdepth 1 -exec rm -rf {} +", ); expect(command).toContain(`cp -a '${restoreRoot}/data-root/.' '/DATA/'`); expect(command).toContain( `cp -a '${restoreRoot}/stacks-root' '/var/lib/home-server/stacks'`, ); }); it("derives the restore root from STORE_APP_DATA_ROOT even for legacy /DATA/AppData installs", () => { tempRoots.stacksRoot = "/var/lib/home-server/stacks"; serverEnvMock.STORE_APP_DATA_ROOT = "/DATA/AppData"; serverEnvMock.FILES_ROOT = "/DATA/data-root"; const restoreRoot = path.join("/var/tmp", "homeio-restore-backup-legacy"); const command = buildRestoreShellCommand({ id: "backup-legacy", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: "/DATA/Backups/homeio/backup-legacy.tar.gz", dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }); expect(command).toContain( "find '/DATA' -mindepth 1 -maxdepth 1 -exec rm -rf {} +", ); expect(command).toContain(`cp -a '${restoreRoot}/data-root/.' '/DATA/'`); }); it("archives the store registry when it sits outside the data root", async () => { // Mirrors a real install: files under /DATA, store sources under // /var/lib/home-server/AppStore. Restoring used to bring the files and the // database back while dropping every catalog source the user had added. const filesRoot = path.join(tempRoots.dataRoot, "..", "DATA2"); serverEnvMock.STORE_APP_DATA_ROOT = path.join(filesRoot, "AppData"); await mkdir(path.join(tempRoots.dataRoot, "AppStore"), { recursive: true }); mockExecFileForBackup(); const backupRoot = resolveManagedBackupRoot(); await mkdir(backupRoot, { recursive: true }); const backup = await runSystemBackupNow(); const tarArgs = execFileMock.mock.calls.find( ([command]) => command === "tar", )?.[1] as string[]; expect(tarArgs).toContain("store-config"); const manifestRaw = await readFile( path.join(backupRoot, `${backup.id}.manifest.json`), "utf8", ); expect(JSON.parse(manifestRaw).storeConfigIncluded).toBe(true); const command = buildRestoreShellCommand(backup); const restoreRoot = `/var/tmp/homeio-restore-${backup.id}`; expect(command).toContain( `cp -a '${restoreRoot}/store-config' '${path.join(tempRoots.dataRoot, "AppStore")}'`, ); }); it("leaves the store registry alone for archives that predate it", async () => { const command = buildRestoreShellCommand({ id: "backup-legacy", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: "/DATA/Backups/homeio/backup-legacy.tar.gz", dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: false, status: "completed", }); // The copy is guarded on the archive actually carrying the member, so an // older backup must not wipe the sources that are on disk today. expect(command).toContain( "if [ -d '/var/tmp/homeio-restore-backup-legacy/store-config' ]; then", ); }); it("brings the stacks back up before rebooting", async () => { const command = buildRestoreShellCommand({ id: "backup-stacks", createdAt: "2026-03-08T09:00:00.000Z", sizeBytes: 10, appVersion: "0.1.72", hostname: "home-node", backupPath: "/DATA/Backups/homeio/backup-stacks.tar.gz", dbDumpIncluded: true, dataRootIncluded: true, stacksRootIncluded: true, storeConfigIncluded: true, status: "completed", }); // `docker compose down` removes the containers, so without this a // successful restore left no container for a restart policy to revive. const restartIndex = command.lastIndexOf("restart_existing_stacks"); const rebootIndex = command.indexOf("systemctl reboot"); expect(restartIndex).toBeGreaterThan(command.indexOf("docker compose down")); expect(restartIndex).toBeLessThan(rebootIndex); }); it("skips copying an external stacks root when it does not exist", async () => { tempRoots.stacksRoot = path.join( tempRoots.dataRoot, "..", "missing-stacks", ); mockExecFileForBackup(); await runSystemBackupNow(); const tarInvocation = execFileMock.mock.calls.find( ([command]) => command === "tar", ); expect(tarInvocation).toBeDefined(); const tarArgs = tarInvocation?.[1] as string[]; expect(tarArgs).not.toContain("stacks-root"); }); });