From 122a8523e8eec2a4650a4c73df244730db6ae009 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 18 Feb 2026 21:33:56 +0100 Subject: [PATCH] test: backups new include patterns --- app/client/lib/volume-path.test.ts | 28 ++++ .../__tests__/backups.controller.test.ts | 23 +++ .../backups/__tests__/backups.service.test.ts | 42 ++++++ app/server/utils/common-ancestor.test.ts | 34 +++++ app/server/utils/restic.test.ts | 134 +++++++++++++++++- app/server/utils/restic.ts | 18 ++- 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 app/client/lib/volume-path.test.ts create mode 100644 app/server/utils/common-ancestor.test.ts diff --git a/app/client/lib/volume-path.test.ts b/app/client/lib/volume-path.test.ts new file mode 100644 index 00000000..a0369ca8 --- /dev/null +++ b/app/client/lib/volume-path.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { getVolumeMountPath } from "./volume-path"; +import { fromAny } from "@total-typescript/shoehorn"; + +describe("getVolumeMountPath", () => { + test("returns the configured path for directory volumes", () => { + const volume = { + shortId: "abc123", + config: { + backend: "directory", + path: "/mnt/data/projects", + }, + }; + + expect(getVolumeMountPath(fromAny(volume))).toBe("/mnt/data/projects"); + }); + + test("returns the mounted data path for non-directory volumes", () => { + const volume = { + shortId: "vol789", + config: { + backend: "nfs", + }, + }; + + expect(getVolumeMountPath(fromAny(volume))).toBe("/var/lib/zerobyte/volumes/vol789/_data"); + }); +}); diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts index 068ff681..26eb068a 100644 --- a/app/server/modules/backups/__tests__/backups.controller.test.ts +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -1,6 +1,9 @@ import { test, describe, expect } from "bun:test"; import { createApp } from "~/server/app"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; +import { createTestVolume } from "~/test/helpers/volume"; +import { createTestRepository } from "~/test/helpers/repository"; +import { createTestBackupSchedule } from "~/test/helpers/backup"; const app = createApp(); @@ -77,6 +80,26 @@ describe("backups security", () => { }); describe("input validation", () => { + test("should return a schedule when queried by short id", async () => { + const { token, organizationId } = await createTestSession(); + const volume = await createTestVolume({ organizationId }); + const repository = await createTestRepository({ organizationId }); + const schedule = await createTestBackupSchedule({ + organizationId, + volumeId: volume.id, + repositoryId: repository.id, + }); + + const res = await app.request(`/api/v1/backups/${schedule.shortId}`, { + headers: getAuthHeaders(token), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe(schedule.id); + expect(body.shortId).toBe(schedule.shortId); + }); + test("should return 404 for malformed schedule ID", async () => { const { token } = await createTestSession(); const res = await app.request("/api/v1/backups/not-a-number", { diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 3dbbbde7..ebeaf169 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -187,6 +187,48 @@ describe("getSchedulesToExecute", () => { }); }); +describe("getScheduleByIdOrShortId", () => { + test("should resolve a schedule by numeric id string", async () => { + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + const found = await backupsService.getScheduleByIdOrShortId(String(schedule.id)); + + expect(found.id).toBe(schedule.id); + expect(found.shortId).toBe(schedule.shortId); + }); + + test("should resolve a schedule by short id", async () => { + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + const found = await backupsService.getScheduleByIdOrShortId(schedule.shortId); + + expect(found.id).toBe(schedule.id); + expect(found.shortId).toBe(schedule.shortId); + }); + + test("should not return schedules from another organization", async () => { + const otherOrgId = faker.string.uuid(); + const schedule = await createTestBackupSchedule({ + organizationId: otherOrgId, + }); + + await expect(backupsService.getScheduleByIdOrShortId(schedule.shortId)).rejects.toThrow( + "Backup schedule not found", + ); + await expect(backupsService.getScheduleByIdOrShortId(schedule.id)).rejects.toThrow("Backup schedule not found"); + }); +}); + describe("listSchedules", () => { test("should ignore schedules with missing relations", async () => { const healthyVolume = await createTestVolume(); diff --git a/app/server/utils/common-ancestor.test.ts b/app/server/utils/common-ancestor.test.ts new file mode 100644 index 00000000..9178cb9b --- /dev/null +++ b/app/server/utils/common-ancestor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { findCommonAncestor } from "~/utils/common-ancestor"; + +describe("findCommonAncestor", () => { + test("returns root for empty path lists", () => { + expect(findCommonAncestor([])).toBe("/"); + }); + + test("returns the original path for single-item lists", () => { + expect(findCommonAncestor(["/var/lib/zerobyte/volumes/vol123/_data"])).toBe( + "/var/lib/zerobyte/volumes/vol123/_data", + ); + }); + + test("returns the deepest shared ancestor for multiple absolute paths", () => { + expect( + findCommonAncestor([ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + "/var/lib/zerobyte/volumes/vol123/_data/Music/track.mp3", + ]), + ).toBe("/var/lib/zerobyte/volumes/vol123/_data"); + }); + + test("returns root when absolute paths only share the filesystem root", () => { + expect(findCommonAncestor(["/etc/hosts", "/usr/local/bin"])).toBe("/"); + }); + + test("throws when any path is relative", () => { + expect(() => findCommonAncestor(["/var/lib/zerobyte", "relative/path"])).toThrow( + 'Path "relative/path" is not absolute.', + ); + }); +}); diff --git a/app/server/utils/restic.test.ts b/app/server/utils/restic.test.ts index afb1364c..d099f53d 100644 --- a/app/server/utils/restic.test.ts +++ b/app/server/utils/restic.test.ts @@ -1,5 +1,71 @@ -import { describe, expect, test } from "bun:test"; -import { buildRepoUrl } from "./restic"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as spawnModule from "./spawn"; +import { buildRepoUrl, restic } from "./restic"; + +const successfulRestoreSummary = JSON.stringify({ + message_type: "summary", + files_restored: 1, + files_skipped: 0, + bytes_skipped: 0, +}); + +let lastSafeSpawnArgs: string[] = []; + +const safeSpawnMock = mock((params: Parameters[0]) => { + lastSafeSpawnArgs = params.args; + + return Promise.resolve({ + exitCode: 0, + summary: successfulRestoreSummary, + error: "", + }); +}); + +const getRestoreArg = (args: string[]): string => { + const restoreIndex = args.indexOf("restore"); + if (restoreIndex < 0) { + throw new Error("Expected restore command in restic arguments"); + } + + const restoreArg = args[restoreIndex + 1]; + if (!restoreArg) { + throw new Error("Expected restore argument after restore command"); + } + + return restoreArg; +}; + +const getOptionValues = (args: string[], option: string): string[] => { + const values: string[] = []; + for (let i = 0; i < args.length - 1; i++) { + if (args[i] === option) { + const value = args[i + 1]; + if (value) { + values.push(value); + } + } + } + + return values; +}; + +const getLastSafeSpawnArgs = (): string[] => { + if (lastSafeSpawnArgs.length === 0) { + throw new Error("Expected safeSpawn to be called"); + } + + return lastSafeSpawnArgs; +}; + +beforeEach(() => { + safeSpawnMock.mockClear(); + lastSafeSpawnArgs = []; + spyOn(spawnModule, "safeSpawn").mockImplementation(safeSpawnMock); +}); + +afterEach(() => { + mock.restore(); +}); describe("buildRepoUrl", () => { describe("S3 backend", () => { @@ -104,3 +170,67 @@ describe("buildRepoUrl", () => { }); }); }); + +describe("restore", () => { + const config = { + backend: "local" as const, + path: "/tmp/restic-repo", + isExistingRepository: true, + customPassword: "custom-password", + }; + + test("keeps snapshot restore arg and absolute include paths when target is root", async () => { + await restic.restore(config, "snapshot-123", "/", { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-123"); + expect(getOptionValues(args, "--include")).toEqual([ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ]); + }); + + test("restores from common ancestor and strips include paths for non-root targets", async () => { + await restic.restore(config, "snapshot-456", "/tmp/restore-target", { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues(args, "--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); + }); + + test("uses base path for non-root restore when includes are omitted", async () => { + await restic.restore(config, "snapshot-789", "/tmp/restore-target", { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues(args, "--include")).toEqual([]); + }); + + test("does not pass an empty include when include equals restore root", async () => { + await restic.restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", { + organizationId: "org-1", + include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], + overwrite: "always", + }); + + const args = getLastSafeSpawnArgs(); + expect(getRestoreArg(args)).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"); + expect(getOptionValues(args, "--include")).toEqual([]); + expect(args).not.toContain(""); + }); +}); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 7d9bf549..49029b9f 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -432,9 +432,21 @@ const restore = async ( } if (options?.include?.length) { - for (const pattern of options.include) { - const strippedPattern = target === "/" ? pattern : path.relative(commonAncestor, pattern); - args.push("--include", strippedPattern); + if (target === "/") { + for (const pattern of options.include) { + args.push("--include", pattern); + } + } else { + const strippedIncludes = options.include.map((pattern) => path.relative(commonAncestor, pattern)); + const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); + + if (!includesCoverRestoreRoot) { + for (const pattern of strippedIncludes) { + if (pattern !== "" && pattern !== ".") { + args.push("--include", pattern); + } + } + } } }