test: backups new include patterns
This commit is contained in:
parent
ec8be733b1
commit
122a8523e8
6 changed files with 274 additions and 5 deletions
28
app/client/lib/volume-path.test.ts
Normal file
28
app/client/lib/volume-path.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { test, describe, expect } from "bun:test";
|
import { test, describe, expect } from "bun:test";
|
||||||
import { createApp } from "~/server/app";
|
import { createApp } from "~/server/app";
|
||||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
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();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -77,6 +80,26 @@ describe("backups security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
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 () => {
|
test("should return 404 for malformed schedule ID", async () => {
|
||||||
const { token } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/backups/not-a-number", {
|
const res = await app.request("/api/v1/backups/not-a-number", {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("listSchedules", () => {
|
||||||
test("should ignore schedules with missing relations", async () => {
|
test("should ignore schedules with missing relations", async () => {
|
||||||
const healthyVolume = await createTestVolume();
|
const healthyVolume = await createTestVolume();
|
||||||
|
|
|
||||||
34
app/server/utils/common-ancestor.test.ts
Normal file
34
app/server/utils/common-ancestor.test.ts
Normal file
|
|
@ -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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,71 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||||
import { buildRepoUrl } from "./restic";
|
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<typeof spawnModule.safeSpawn>[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("buildRepoUrl", () => {
|
||||||
describe("S3 backend", () => {
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -432,9 +432,21 @@ const restore = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.include?.length) {
|
if (options?.include?.length) {
|
||||||
for (const pattern of options.include) {
|
if (target === "/") {
|
||||||
const strippedPattern = target === "/" ? pattern : path.relative(commonAncestor, pattern);
|
for (const pattern of options.include) {
|
||||||
args.push("--include", strippedPattern);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue