refactor: tests (#585)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Improved test infrastructure and organization across backup, repository, volume, and utility modules with streamlined mock setup and cleaner test patterns. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
4a812a884b
commit
942bf2b31b
8 changed files with 400 additions and 444 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import waitForExpect from "wait-for-expect";
|
||||
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { test, describe, mock, expect, afterEach, spyOn } from "bun:test";
|
||||
import { backupsService } from "../backups.service";
|
||||
import { backupsExecutionService } from "../backups.execution";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
|
|
@ -15,19 +15,22 @@ import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
|||
import { scheduleQueries } from "../backups.queries";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
|
||||
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null }));
|
||||
const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" }));
|
||||
const setup = () => {
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
|
||||
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null }));
|
||||
const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" }));
|
||||
|
||||
beforeEach(() => {
|
||||
resticBackupMock.mockClear();
|
||||
resticForgetMock.mockClear();
|
||||
resticCopyMock.mockClear();
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
spyOn(restic, "forget").mockImplementation(resticForgetMock);
|
||||
spyOn(restic, "copy").mockImplementation(resticCopyMock);
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
return {
|
||||
resticBackupMock,
|
||||
resticForgetMock,
|
||||
resticCopyMock,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
|
@ -36,6 +39,7 @@ afterEach(() => {
|
|||
describe("backup execution - validation failures", () => {
|
||||
test("should fail backup when volume is not mounted", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume({ status: "unmounted" });
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -57,6 +61,7 @@ describe("backup execution - validation failures", () => {
|
|||
|
||||
test("should fail backup when volume does not exist", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -86,6 +91,7 @@ describe("backup execution - validation failures", () => {
|
|||
|
||||
test("should fail backup when repository does not exist", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -115,6 +121,7 @@ describe("backup execution - validation failures", () => {
|
|||
});
|
||||
|
||||
test("should fail backup when schedule does not exist", async () => {
|
||||
setup();
|
||||
// act
|
||||
const result = await backupsExecutionService.validateBackupExecution(99999);
|
||||
|
||||
|
|
@ -130,6 +137,7 @@ describe("backup execution - validation failures", () => {
|
|||
describe("stop backup", () => {
|
||||
test("should stop a running backup", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -160,6 +168,7 @@ describe("stop backup", () => {
|
|||
|
||||
test("should throw ConflictError when trying to stop non-running backup", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -174,6 +183,7 @@ describe("stop backup", () => {
|
|||
});
|
||||
|
||||
test("should throw NotFoundError when schedule does not exist", async () => {
|
||||
setup();
|
||||
// act & assert
|
||||
await expect(backupsExecutionService.stopBackup(99999)).rejects.toThrow("Backup schedule not found");
|
||||
});
|
||||
|
|
@ -182,6 +192,7 @@ describe("stop backup", () => {
|
|||
describe("retention policy - runForget", () => {
|
||||
test("should execute forget with retention policy", async () => {
|
||||
// arrange
|
||||
const { resticForgetMock } = setup();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
repositoryId: repository.id,
|
||||
|
|
@ -216,6 +227,7 @@ describe("retention policy - runForget", () => {
|
|||
|
||||
test("should throw BadRequestError if no retention policy configured", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
repositoryId: repository.id,
|
||||
|
|
@ -229,12 +241,14 @@ describe("retention policy - runForget", () => {
|
|||
});
|
||||
|
||||
test("should throw NotFoundError when schedule does not exist", async () => {
|
||||
setup();
|
||||
// act & assert
|
||||
await expect(backupsExecutionService.runForget(99999)).rejects.toThrow("Backup schedule not found");
|
||||
});
|
||||
|
||||
test("should throw NotFoundError when repository does not exist", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
retentionPolicy: {
|
||||
keepHourly: 24,
|
||||
|
|
@ -251,6 +265,7 @@ describe("retention policy - runForget", () => {
|
|||
describe("mirror operations", () => {
|
||||
test("should copy snapshots to mirror repositories", async () => {
|
||||
// arrange
|
||||
const { resticCopyMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
@ -277,6 +292,7 @@ describe("mirror operations", () => {
|
|||
|
||||
test("should skip disabled mirrors", async () => {
|
||||
// arrange
|
||||
const { resticCopyMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
@ -296,6 +312,7 @@ describe("mirror operations", () => {
|
|||
|
||||
test("should update mirror status on success", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
@ -319,6 +336,7 @@ describe("mirror operations", () => {
|
|||
|
||||
test("should finalize mirror status when mirror settings are updated during copy", async () => {
|
||||
// arrange
|
||||
const { resticCopyMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
@ -350,6 +368,7 @@ describe("mirror operations", () => {
|
|||
|
||||
test("should update mirror status on failure", async () => {
|
||||
// arrange
|
||||
const { resticCopyMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
@ -375,6 +394,7 @@ describe("mirror operations", () => {
|
|||
|
||||
test("should run forget on mirror after successful copy when retention policy exists", async () => {
|
||||
// arrange
|
||||
const { resticCopyMock, resticForgetMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
@ -406,6 +426,7 @@ describe("mirror operations", () => {
|
|||
|
||||
test("should not run forget on mirror when no retention policy", async () => {
|
||||
// arrange
|
||||
const { resticCopyMock, resticForgetMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const sourceRepository = await createTestRepository();
|
||||
const mirrorRepository = await createTestRepository();
|
||||
|
|
|
|||
|
|
@ -1,141 +1,94 @@
|
|||
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||
import { createTestRepository } from "~/test/helpers/repository";
|
||||
import { generateBackupOutput } from "~/test/helpers/restic";
|
||||
import { getVolumePath } from "../../volumes/helpers";
|
||||
import { restic } from "~/server/utils/restic";
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import path from "node:path";
|
||||
import { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||
import * as context from "~/server/core/request-context";
|
||||
import { backupsExecutionService } from "../backups.execution";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
import { createBackupOptions, processPattern } from "../backup.helpers";
|
||||
|
||||
const backupMock = mock(() => Promise.resolve({ exitCode: 0, result: JSON.parse(generateBackupOutput()) }));
|
||||
type BackupScheduleInput = Parameters<typeof createBackupOptions>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
backupMock.mockClear();
|
||||
spyOn(restic, "backup").mockImplementation(backupMock);
|
||||
spyOn(restic, "forget").mockImplementation(mock(() => Promise.resolve({ success: true, data: null })));
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
const createSchedule = (overrides: Partial<BackupScheduleInput> = {}): BackupScheduleInput =>
|
||||
fromAny({
|
||||
shortId: "sched-1234",
|
||||
oneFileSystem: false,
|
||||
includePatterns: [],
|
||||
excludePatterns: [],
|
||||
excludeIfPresent: [],
|
||||
...overrides,
|
||||
}) as BackupScheduleInput;
|
||||
|
||||
describe("executeBackup - include / exclude patterns", () => {
|
||||
test("should correctly build include and exclude patterns", async () => {
|
||||
test("should correctly build include and exclude patterns", () => {
|
||||
// arrange
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const volumePath = getVolumePath(volume);
|
||||
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||
const schedule = createSchedule({
|
||||
includePatterns: ["*.zip", "/Photos", "!/Temp", "!*.log"],
|
||||
excludePatterns: [".DS_Store", "/Config", "!/Important", "!*.tmp"],
|
||||
excludeIfPresent: [".nobackup"],
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
// act
|
||||
await backupsExecutionService.executeBackup(schedule.id);
|
||||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
// assert
|
||||
expect(backupMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
volumePath,
|
||||
expect.objectContaining({
|
||||
include: ["*.zip", path.join(volumePath, "Photos"), `!${path.join(volumePath, "Temp")}`, "!*.log"],
|
||||
exclude: [".DS_Store", path.join(volumePath, "Config"), `!${path.join(volumePath, "Important")}`, "!*.tmp"],
|
||||
excludeIfPresent: [".nobackup"],
|
||||
}),
|
||||
);
|
||||
expect(options).toMatchObject({
|
||||
include: ["*.zip", path.join(volumePath, "Photos"), `!${path.join(volumePath, "Temp")}`, "!*.log"],
|
||||
exclude: [".DS_Store", path.join(volumePath, "Config"), `!${path.join(volumePath, "Important")}`, "!*.tmp"],
|
||||
excludeIfPresent: [".nobackup"],
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle the case where a subfolder has the exact same name as the volume name", async () => {
|
||||
test("should handle the case where a subfolder has the exact same name as the volume name", () => {
|
||||
// arrange
|
||||
const volumeName = "SyncFolder";
|
||||
const volume = await createTestVolume({
|
||||
name: volumeName,
|
||||
type: "directory",
|
||||
config: { backend: "directory", path: `/${volumeName}` },
|
||||
});
|
||||
const volumePath = getVolumePath(volume);
|
||||
const repository = await createTestRepository();
|
||||
|
||||
const selectedPath = `/${volumeName}`; // Selection of the folder inside the volume
|
||||
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
const volumePath = `/${volumeName}`;
|
||||
const selectedPath = `/${volumeName}`;
|
||||
const schedule = createSchedule({
|
||||
includePatterns: [selectedPath],
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
// act
|
||||
await backupsExecutionService.executeBackup(schedule.id);
|
||||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
// assert
|
||||
expect(backupMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
volumePath,
|
||||
expect.objectContaining({
|
||||
// Should produce /SyncFolder/SyncFolder and not just /SyncFolder
|
||||
include: [path.join(volumePath, volumeName)],
|
||||
}),
|
||||
);
|
||||
expect(options.include).toEqual([path.join(volumePath, volumeName)]);
|
||||
});
|
||||
|
||||
test("should correctly mix relative and absolute patterns", async () => {
|
||||
test("should correctly mix relative and absolute patterns", () => {
|
||||
// arrange
|
||||
const volume = await createTestVolume();
|
||||
const volumePath = getVolumePath(volume);
|
||||
const repository = await createTestRepository();
|
||||
|
||||
const volumePath = "/var/lib/zerobyte/volumes/vol456/_data";
|
||||
const relativeInclude = "relative/include";
|
||||
const anchoredInclude = "/anchored/include";
|
||||
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
const schedule = createSchedule({
|
||||
includePatterns: [relativeInclude, anchoredInclude],
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
// act
|
||||
await backupsExecutionService.executeBackup(schedule.id);
|
||||
const options = createBackupOptions(schedule, volumePath, signal);
|
||||
|
||||
// assert
|
||||
expect(backupMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
volumePath,
|
||||
expect.objectContaining({
|
||||
include: [relativeInclude, path.join(volumePath, "anchored/include")],
|
||||
}),
|
||||
);
|
||||
expect(options.include).toEqual([relativeInclude, path.join(volumePath, "anchored/include")]);
|
||||
});
|
||||
|
||||
test("should handle empty include and exclude patterns", async () => {
|
||||
test("should handle empty include and exclude patterns", () => {
|
||||
// arrange
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
const schedule = createSchedule({
|
||||
includePatterns: [],
|
||||
excludePatterns: [],
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
// act
|
||||
await backupsExecutionService.executeBackup(schedule.id);
|
||||
const options = createBackupOptions(schedule, "/var/lib/zerobyte/volumes/vol999/_data", signal);
|
||||
|
||||
// assert
|
||||
expect(backupMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
getVolumePath(volume),
|
||||
expect.objectContaining({
|
||||
include: [],
|
||||
exclude: [],
|
||||
}),
|
||||
);
|
||||
expect(options.include).toEqual([]);
|
||||
expect(options.exclude).toEqual([]);
|
||||
});
|
||||
|
||||
test("processPattern keeps relative and negated relative patterns unchanged", () => {
|
||||
expect(processPattern("relative/include", "/volume")).toBe("relative/include");
|
||||
expect(processPattern("!*.log", "/volume")).toBe("!*.log");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, describe, expect, beforeEach } from "bun:test";
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import { scheduleQueries } from "../backups.queries";
|
||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
|
|
@ -8,19 +8,22 @@ import { TEST_ORG_ID } from "~/test/helpers/organization";
|
|||
import { faker } from "@faker-js/faker";
|
||||
|
||||
describe("scheduleQueries.findExecutable", () => {
|
||||
let volume: { id: number };
|
||||
let repository: { id: string };
|
||||
const createScheduleDependencies = async () => {
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
|
||||
beforeEach(async () => {
|
||||
volume = await createTestVolume();
|
||||
repository = await createTestRepository();
|
||||
});
|
||||
return {
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
};
|
||||
};
|
||||
|
||||
test("should return enabled schedules with null nextBackupAt", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: null,
|
||||
lastBackupStatus: null,
|
||||
|
|
@ -35,10 +38,11 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should return enabled schedules with past nextBackupAt", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const pastTime = faker.date.past().getTime();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: pastTime,
|
||||
lastBackupStatus: null,
|
||||
|
|
@ -53,10 +57,11 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should not return schedules with future nextBackupAt", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const futureTime = faker.date.future().getTime();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: futureTime,
|
||||
lastBackupStatus: null,
|
||||
|
|
@ -71,9 +76,10 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should not return disabled schedules", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: false,
|
||||
nextBackupAt: null,
|
||||
lastBackupStatus: null,
|
||||
|
|
@ -88,9 +94,10 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should not return schedules with in_progress status", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: null,
|
||||
lastBackupStatus: "in_progress",
|
||||
|
|
@ -105,10 +112,11 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should return schedules with success status and past nextBackupAt", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const pastTime = faker.date.past().getTime();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: pastTime,
|
||||
lastBackupStatus: "success",
|
||||
|
|
@ -123,9 +131,10 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should return schedules with error status and null nextBackupAt", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: null,
|
||||
lastBackupStatus: "error",
|
||||
|
|
@ -140,11 +149,12 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should not return schedules from other organizations", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
const otherOrgId = faker.string.uuid();
|
||||
await createTestOrganization({ id: otherOrgId });
|
||||
const schedule = await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: null,
|
||||
lastBackupStatus: null,
|
||||
|
|
@ -160,9 +170,10 @@ describe("scheduleQueries.findExecutable", () => {
|
|||
|
||||
test("should only return schedule IDs", async () => {
|
||||
// arrange
|
||||
const { volumeId, repositoryId } = await createScheduleDependencies();
|
||||
await createTestBackupSchedule({
|
||||
volumeId: volume.id,
|
||||
repositoryId: repository.id,
|
||||
volumeId,
|
||||
repositoryId,
|
||||
enabled: true,
|
||||
nextBackupAt: null,
|
||||
lastBackupStatus: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import waitForExpect from "wait-for-expect";
|
||||
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { test, describe, mock, expect, afterEach, spyOn } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { backupsService } from "../backups.service";
|
||||
import { createTestVolume } from "~/test/helpers/volume";
|
||||
|
|
@ -14,13 +14,15 @@ import { TEST_ORG_ID } from "~/test/helpers/organization";
|
|||
import * as context from "~/server/core/request-context";
|
||||
import { backupsExecutionService } from "../backups.execution";
|
||||
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||
|
||||
beforeEach(() => {
|
||||
resticBackupMock.mockClear();
|
||||
const setup = () => {
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
|
||||
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
return {
|
||||
resticBackupMock,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
|
@ -29,6 +31,7 @@ afterEach(() => {
|
|||
describe("execute backup", () => {
|
||||
test("should correctly set next backup time", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -58,6 +61,7 @@ describe("execute backup", () => {
|
|||
|
||||
test("should skip backup if schedule is disabled", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -75,6 +79,7 @@ describe("execute backup", () => {
|
|||
|
||||
test("should execute backup if schedule is disabled but the run is manual", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -96,6 +101,7 @@ describe("execute backup", () => {
|
|||
|
||||
test("should skip the backup if the previous one is still running", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -123,6 +129,7 @@ describe("execute backup", () => {
|
|||
|
||||
test("should set the backup status to failed if restic returns a 3 exit code", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -144,6 +151,7 @@ describe("execute backup", () => {
|
|||
|
||||
test("should set the backup status to failed if restic returns a non zero exit code", async () => {
|
||||
// arrange
|
||||
const { resticBackupMock } = setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -167,6 +175,7 @@ describe("execute backup", () => {
|
|||
describe("getSchedulesToExecute", () => {
|
||||
test("should return schedules with NULL lastBackupStatus", async () => {
|
||||
// arrange
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
|
||||
|
|
@ -189,6 +198,7 @@ describe("getSchedulesToExecute", () => {
|
|||
|
||||
describe("getScheduleByIdOrShortId", () => {
|
||||
test("should resolve a schedule by numeric id string", async () => {
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -203,6 +213,7 @@ describe("getScheduleByIdOrShortId", () => {
|
|||
});
|
||||
|
||||
test("should resolve a schedule by short id", async () => {
|
||||
setup();
|
||||
const volume = await createTestVolume();
|
||||
const repository = await createTestRepository();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
|
|
@ -217,6 +228,7 @@ describe("getScheduleByIdOrShortId", () => {
|
|||
});
|
||||
|
||||
test("should not return schedules from another organization", async () => {
|
||||
setup();
|
||||
const otherOrgId = faker.string.uuid();
|
||||
const schedule = await createTestBackupSchedule({
|
||||
organizationId: otherOrgId,
|
||||
|
|
@ -231,6 +243,7 @@ describe("getScheduleByIdOrShortId", () => {
|
|||
|
||||
describe("listSchedules", () => {
|
||||
test("should ignore schedules with missing relations", async () => {
|
||||
setup();
|
||||
const healthyVolume = await createTestVolume();
|
||||
const healthyRepository = await createTestRepository();
|
||||
const healthySchedule = await createTestBackupSchedule({
|
||||
|
|
@ -254,6 +267,7 @@ describe("listSchedules", () => {
|
|||
});
|
||||
|
||||
test("should ignore schedules with missing repository relation", async () => {
|
||||
setup();
|
||||
const healthyVolume = await createTestVolume();
|
||||
const healthyRepository = await createTestRepository();
|
||||
const healthySchedule = await createTestBackupSchedule({
|
||||
|
|
@ -279,6 +293,7 @@ describe("listSchedules", () => {
|
|||
|
||||
describe("cleanupOrphanedSchedules", () => {
|
||||
test("should return zero when cascades already removed orphaned schedules", async () => {
|
||||
setup();
|
||||
const healthyVolume = await createTestVolume();
|
||||
const healthyRepository = await createTestRepository();
|
||||
const healthySchedule = await createTestBackupSchedule({
|
||||
|
|
|
|||
|
|
@ -140,10 +140,15 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
mock.restore();
|
||||
});
|
||||
|
||||
test("calls restic.dump with common-ancestor selector and stripped path", async () => {
|
||||
const createDumpResult = () => ({
|
||||
stream: Readable.from([]),
|
||||
completion: Promise.resolve(),
|
||||
abort: () => {},
|
||||
});
|
||||
|
||||
const setupDumpSnapshotScenario = async ({ snapshotId, basePath }: { snapshotId: string; basePath: string }) => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const shortId = generateShortId();
|
||||
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: randomUUID(),
|
||||
|
|
@ -159,31 +164,36 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
organizationId,
|
||||
});
|
||||
|
||||
const snapshotsMock = mock(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "snapshot-123",
|
||||
short_id: "snapshot-123",
|
||||
time: new Date().toISOString(),
|
||||
tree: "tree-1",
|
||||
paths: [basePath],
|
||||
hostname: "host",
|
||||
},
|
||||
]),
|
||||
);
|
||||
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
|
||||
spyOn(restic, "snapshots").mockResolvedValue([
|
||||
{
|
||||
id: snapshotId,
|
||||
short_id: snapshotId,
|
||||
time: new Date().toISOString(),
|
||||
paths: [basePath],
|
||||
hostname: "host",
|
||||
},
|
||||
]);
|
||||
|
||||
const dumpMock = mock(() =>
|
||||
Promise.resolve({
|
||||
stream: Readable.from([]),
|
||||
completion: Promise.resolve(),
|
||||
abort: () => {},
|
||||
}),
|
||||
);
|
||||
const dumpMock = mock(() => Promise.resolve(createDumpResult()));
|
||||
spyOn(restic, "dump").mockImplementation(dumpMock);
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
shortId,
|
||||
basePath,
|
||||
dumpMock,
|
||||
};
|
||||
};
|
||||
|
||||
test("calls restic.dump with common-ancestor selector and stripped path", async () => {
|
||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
||||
snapshotId: "snapshot-123",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
});
|
||||
const emitSpy = spyOn(serverEvents, "emit");
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, () =>
|
||||
await withContext({ organizationId, userId }, () =>
|
||||
repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"),
|
||||
);
|
||||
|
||||
|
|
@ -210,48 +220,12 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
});
|
||||
|
||||
test("streams a single file directly when selected path is a file", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const shortId = generateShortId();
|
||||
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: randomUUID(),
|
||||
shortId,
|
||||
name: `Repository-${randomUUID()}`,
|
||||
type: "local",
|
||||
config: {
|
||||
backend: "local",
|
||||
path: `/tmp/repository-${randomUUID()}`,
|
||||
isExistingRepository: true,
|
||||
},
|
||||
compressionMode: "off",
|
||||
organizationId,
|
||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
||||
snapshotId: "snapshot-file",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
});
|
||||
|
||||
const snapshotsMock = mock(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "snapshot-file",
|
||||
short_id: "snapshot-file",
|
||||
time: new Date().toISOString(),
|
||||
tree: "tree-file",
|
||||
paths: [basePath],
|
||||
hostname: "host",
|
||||
},
|
||||
]),
|
||||
);
|
||||
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
|
||||
|
||||
const dumpMock = mock(() =>
|
||||
Promise.resolve({
|
||||
stream: Readable.from([]),
|
||||
completion: Promise.resolve(),
|
||||
abort: () => {},
|
||||
}),
|
||||
);
|
||||
spyOn(restic, "dump").mockImplementation(dumpMock);
|
||||
|
||||
const result = await withContext({ organizationId, userId: user.id }, () =>
|
||||
const result = await withContext({ organizationId, userId }, () =>
|
||||
repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"),
|
||||
);
|
||||
|
||||
|
|
@ -265,90 +239,25 @@ describe("repositoriesService.dumpSnapshot", () => {
|
|||
});
|
||||
|
||||
test("rejects path downloads without a kind", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const shortId = generateShortId();
|
||||
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: randomUUID(),
|
||||
shortId,
|
||||
name: `Repository-${randomUUID()}`,
|
||||
type: "local",
|
||||
config: {
|
||||
backend: "local",
|
||||
path: `/tmp/repository-${randomUUID()}`,
|
||||
isExistingRepository: true,
|
||||
},
|
||||
compressionMode: "off",
|
||||
organizationId,
|
||||
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
||||
snapshotId: "snapshot-no-kind",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
});
|
||||
|
||||
const snapshotsMock = mock(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "snapshot-no-kind",
|
||||
short_id: "snapshot-no-kind",
|
||||
time: new Date().toISOString(),
|
||||
tree: "tree-no-kind",
|
||||
paths: [basePath],
|
||||
hostname: "host",
|
||||
},
|
||||
]),
|
||||
);
|
||||
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
|
||||
|
||||
expect(
|
||||
withContext({ organizationId, userId: user.id }, () =>
|
||||
withContext({ organizationId, userId }, () =>
|
||||
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
|
||||
),
|
||||
).rejects.toThrow("Path kind is required when downloading a specific snapshot path");
|
||||
});
|
||||
|
||||
test("downloads full snapshot relative to common ancestor when path is omitted", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const shortId = generateShortId();
|
||||
const basePath = "/var/lib/zerobyte/volumes/vol555/_data";
|
||||
|
||||
await db.insert(repositoriesTable).values({
|
||||
id: randomUUID(),
|
||||
shortId,
|
||||
name: `Repository-${randomUUID()}`,
|
||||
type: "local",
|
||||
config: {
|
||||
backend: "local",
|
||||
path: `/tmp/repository-${randomUUID()}`,
|
||||
isExistingRepository: true,
|
||||
},
|
||||
compressionMode: "off",
|
||||
organizationId,
|
||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
||||
snapshotId: "snapshot-999",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol555/_data",
|
||||
});
|
||||
|
||||
const snapshotsMock = mock(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "snapshot-999",
|
||||
short_id: "snapshot-999",
|
||||
time: new Date().toISOString(),
|
||||
tree: "tree-9",
|
||||
paths: [basePath],
|
||||
hostname: "host",
|
||||
},
|
||||
]),
|
||||
);
|
||||
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
|
||||
|
||||
const dumpMock = mock(() =>
|
||||
Promise.resolve({
|
||||
stream: Readable.from([]),
|
||||
completion: Promise.resolve(),
|
||||
abort: () => {},
|
||||
}),
|
||||
);
|
||||
spyOn(restic, "dump").mockImplementation(dumpMock);
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, () =>
|
||||
repositoriesService.dumpSnapshot(shortId, "snapshot-999"),
|
||||
);
|
||||
await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-999"));
|
||||
|
||||
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, {
|
||||
organizationId,
|
||||
|
|
|
|||
|
|
@ -10,121 +10,117 @@ import { createTestSession } from "~/test/helpers/auth";
|
|||
import { withContext } from "~/server/core/request-context";
|
||||
import { asShortId } from "~/server/utils/branded";
|
||||
|
||||
describe("volumeService", () => {
|
||||
describe("findVolume", () => {
|
||||
test("should find volume by shortId", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
describe("volumeService.getVolume", () => {
|
||||
test("should find volume by shortId", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId(randomUUID().slice(0, 8)),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: "/" },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId(randomUUID().slice(0, 8)),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: "/" },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const result = await volumeService.getVolume(volume.shortId);
|
||||
expect(result.volume.id).toBe(volume.id);
|
||||
expect(result.volume.shortId).toBe(volume.shortId);
|
||||
});
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const result = await volumeService.getVolume(volume.shortId);
|
||||
expect(result.volume.id).toBe(volume.id);
|
||||
expect(result.volume.shortId).toBe(volume.shortId);
|
||||
});
|
||||
});
|
||||
|
||||
test("should find volume by shortId from literal input", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
test("should find volume by shortId from literal input", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId("test1234"),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: "/" },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId("test1234"),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: "/" },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const result = await volumeService.getVolume(volume.shortId);
|
||||
expect(result.volume.id).toBe(volume.id);
|
||||
expect(result.volume.shortId).toBe(volume.shortId);
|
||||
});
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const result = await volumeService.getVolume(volume.shortId);
|
||||
expect(result.volume.id).toBe(volume.id);
|
||||
expect(result.volume.shortId).toBe(volume.shortId);
|
||||
});
|
||||
});
|
||||
|
||||
test("should find volume by numeric-looking shortId", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
test("should find volume by numeric-looking shortId", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId("499780"),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: "/" },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId("499780"),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: "/" },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const result = await volumeService.getVolume(asShortId("499780"));
|
||||
expect(result.volume.id).toBe(volume.id);
|
||||
expect(result.volume.shortId).toBe(asShortId("499780"));
|
||||
});
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const result = await volumeService.getVolume(asShortId("499780"));
|
||||
expect(result.volume.id).toBe(volume.id);
|
||||
expect(result.volume.shortId).toBe(asShortId("499780"));
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw NotFoundError for non-existent volume", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
test("should throw NotFoundError for non-existent volume", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
await expect(volumeService.getVolume(asShortId("nonexistent"))).rejects.toThrow("Volume not found");
|
||||
});
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
await expect(volumeService.getVolume(asShortId("nonexistent"))).rejects.toThrow("Volume not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("volumeService security", () => {
|
||||
describe("path traversal", () => {
|
||||
test("should reject traversal outside the volume root in listFiles", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-vol-svc-"));
|
||||
const volumePath = path.join(tempRoot, "vol");
|
||||
const secretPath = path.join(tempRoot, "volume-secret");
|
||||
describe("volumeService.listFiles security", () => {
|
||||
test("should reject traversal outside the volume root in listFiles", async () => {
|
||||
const { organizationId, user } = await createTestSession();
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-vol-svc-"));
|
||||
const volumePath = path.join(tempRoot, "vol");
|
||||
const secretPath = path.join(tempRoot, "volume-secret");
|
||||
|
||||
await fs.mkdir(volumePath, { recursive: true });
|
||||
await fs.mkdir(secretPath, { recursive: true });
|
||||
await fs.writeFile(path.join(secretPath, "secret.txt"), "top secret", "utf-8");
|
||||
await fs.mkdir(volumePath, { recursive: true });
|
||||
await fs.mkdir(secretPath, { recursive: true });
|
||||
await fs.writeFile(path.join(secretPath, "secret.txt"), "top secret", "utf-8");
|
||||
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId(randomUUID().slice(0, 8)),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: volumePath },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
const [volume] = await db
|
||||
.insert(volumesTable)
|
||||
.values({
|
||||
shortId: asShortId(randomUUID().slice(0, 8)),
|
||||
name: `test-vol-${randomUUID().slice(0, 8)}`,
|
||||
type: "directory",
|
||||
status: "mounted",
|
||||
config: { backend: "directory", path: volumePath },
|
||||
autoRemount: true,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
try {
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const traversalPath = `../${path.basename(secretPath)}`;
|
||||
try {
|
||||
await withContext({ organizationId, userId: user.id }, async () => {
|
||||
const traversalPath = `../${path.basename(secretPath)}`;
|
||||
|
||||
await expect(volumeService.listFiles(volume.shortId, traversalPath)).rejects.toThrow("Invalid path");
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
await expect(volumeService.listFiles(volume.shortId, traversalPath)).rejects.toThrow("Invalid path");
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,34 +75,30 @@ describe("safeExec", () => {
|
|||
});
|
||||
|
||||
describe("shell injection protection", () => {
|
||||
test("treats semicolon-separated commands as a single literal argument", async () => {
|
||||
test.each([
|
||||
{
|
||||
name: "treats semicolon-separated commands as a single literal argument",
|
||||
argument: "safe; echo injected",
|
||||
expected: "safe; echo injected",
|
||||
},
|
||||
{
|
||||
name: "does not evaluate command substitution syntax",
|
||||
argument: "$(echo injected)",
|
||||
expected: "$(echo injected)",
|
||||
},
|
||||
{
|
||||
name: "does not expand glob patterns",
|
||||
argument: "*.ts",
|
||||
expected: "*.ts",
|
||||
},
|
||||
])("$name", async ({ argument, expected }) => {
|
||||
const result = await safeExec({
|
||||
command: "echo",
|
||||
args: ["safe; echo injected"],
|
||||
args: [argument],
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("safe; echo injected");
|
||||
});
|
||||
|
||||
test("does not evaluate command substitution syntax", async () => {
|
||||
const result = await safeExec({
|
||||
command: "echo",
|
||||
args: ["$(echo injected)"],
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("$(echo injected)");
|
||||
});
|
||||
|
||||
test("does not expand glob patterns", async () => {
|
||||
const result = await safeExec({
|
||||
command: "echo",
|
||||
args: ["*.ts"],
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("*.ts");
|
||||
expect(result.stdout.trim()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -268,40 +264,32 @@ describe("safeSpawn", () => {
|
|||
});
|
||||
|
||||
describe("shell injection protection", () => {
|
||||
test("treats semicolon-separated commands as a single literal argument", async () => {
|
||||
test.each([
|
||||
{
|
||||
name: "treats semicolon-separated commands as a single literal argument",
|
||||
argument: "safe; echo injected",
|
||||
expected: "safe; echo injected",
|
||||
},
|
||||
{
|
||||
name: "does not evaluate command substitution syntax",
|
||||
argument: "$(echo injected)",
|
||||
expected: "$(echo injected)",
|
||||
},
|
||||
{
|
||||
name: "does not expand glob patterns",
|
||||
argument: "*.ts",
|
||||
expected: "*.ts",
|
||||
},
|
||||
])("$name", async ({ argument, expected }) => {
|
||||
const lines: string[] = [];
|
||||
|
||||
await safeSpawn({
|
||||
command: "echo",
|
||||
args: ["safe; echo injected"],
|
||||
args: [argument],
|
||||
onStdout: (line) => lines.push(line),
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["safe; echo injected"]);
|
||||
});
|
||||
|
||||
test("does not evaluate command substitution syntax", async () => {
|
||||
const lines: string[] = [];
|
||||
|
||||
await safeSpawn({
|
||||
command: "echo",
|
||||
args: ["$(echo injected)"],
|
||||
onStdout: (line) => lines.push(line),
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["$(echo injected)"]);
|
||||
});
|
||||
|
||||
test("does not expand glob patterns", async () => {
|
||||
const lines: string[] = [];
|
||||
|
||||
await safeSpawn({
|
||||
command: "echo",
|
||||
args: ["*.ts"],
|
||||
onStdout: (line) => lines.push(line),
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["*.ts"]);
|
||||
expect(lines).toEqual([expected]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs/promises";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { organization } from "~/server/db/schema";
|
||||
import { RESTIC_CACHE_DIR } from "~/server/core/constants";
|
||||
|
|
@ -27,16 +28,49 @@ const createTestOrg = async (overrides: Partial<typeof organization.$inferInsert
|
|||
const PLAIN_PRIVATE_KEY =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
const tempFiles = new Set<string>();
|
||||
|
||||
const trackTempFile = (filePath: string | undefined) => {
|
||||
if (filePath) {
|
||||
tempFiles.add(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
const buildEnvForTest = async (
|
||||
config: Parameters<typeof import("../build-env").buildEnv>[0],
|
||||
organizationId: string,
|
||||
): Promise<ReturnType<typeof import("../build-env").buildEnv>> => {
|
||||
const env = await buildEnv(config, organizationId);
|
||||
|
||||
// Automatically track all temp file paths created by buildEnv
|
||||
trackTempFile(env.RESTIC_PASSWORD_FILE);
|
||||
trackTempFile(env.GOOGLE_APPLICATION_CREDENTIALS);
|
||||
trackTempFile(env._SFTP_KEY_PATH);
|
||||
trackTempFile(env._SFTP_KNOWN_HOSTS_PATH);
|
||||
trackTempFile(env.RESTIC_CACERT);
|
||||
|
||||
return env;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...tempFiles].map(async (filePath) => {
|
||||
await fs.rm(filePath, { force: true });
|
||||
}),
|
||||
);
|
||||
tempFiles.clear();
|
||||
});
|
||||
|
||||
describe("buildEnv", () => {
|
||||
describe("base environment", () => {
|
||||
test("always sets RESTIC_CACHE_DIR", async () => {
|
||||
const env = await buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
|
||||
expect(env.RESTIC_CACHE_DIR).toBe(RESTIC_CACHE_DIR);
|
||||
});
|
||||
|
||||
test("always sets PATH", async () => {
|
||||
const env = await buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
|
||||
expect(env.PATH).toBeTruthy();
|
||||
});
|
||||
|
|
@ -44,20 +78,34 @@ describe("buildEnv", () => {
|
|||
|
||||
describe("password resolution", () => {
|
||||
test("writes a password file when using customPassword on an existing repository", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
{ backend: "local" as const, path: "/tmp/repo", isExistingRepository: true, customPassword: "my-secret" },
|
||||
"org-1",
|
||||
);
|
||||
|
||||
expect(env.RESTIC_PASSWORD_FILE).toMatch(/^\/tmp\/zerobyte-pass-.+\.txt$/);
|
||||
const passwordFilePath = env.RESTIC_PASSWORD_FILE;
|
||||
expect(passwordFilePath).toBeDefined();
|
||||
if (!passwordFilePath) {
|
||||
throw new Error("Expected password file path to be defined");
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(passwordFilePath, "utf-8");
|
||||
expect(fileContent).toBe("my-secret");
|
||||
});
|
||||
|
||||
test("writes a password file from the organization's resticPassword when no customPassword is given", async () => {
|
||||
const orgId = await createTestOrg();
|
||||
|
||||
const env = await buildEnv({ backend: "local" as const, path: "/tmp/repo" }, orgId);
|
||||
const env = await buildEnvForTest({ backend: "local" as const, path: "/tmp/repo" }, orgId);
|
||||
|
||||
expect(env.RESTIC_PASSWORD_FILE).toMatch(/^\/tmp\/zerobyte-pass-.+\.txt$/);
|
||||
const passwordFilePath = env.RESTIC_PASSWORD_FILE;
|
||||
expect(passwordFilePath).toBeDefined();
|
||||
if (!passwordFilePath) {
|
||||
throw new Error("Expected password file path to be defined");
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(passwordFilePath, "utf-8");
|
||||
expect(fileContent).toBe("org-restic-password");
|
||||
});
|
||||
|
||||
test("throws when the organization does not exist", async () => {
|
||||
|
|
@ -85,20 +133,20 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("sets AWS credentials", async () => {
|
||||
const env = await buildEnv(base, "org-1");
|
||||
const env = await buildEnvForTest(base, "org-1");
|
||||
|
||||
expect(env.AWS_ACCESS_KEY_ID).toBe("my-access-key");
|
||||
expect(env.AWS_SECRET_ACCESS_KEY).toBe("my-secret-key");
|
||||
});
|
||||
|
||||
test("sets AWS_S3_BUCKET_LOOKUP=dns for Huawei Cloud endpoints", async () => {
|
||||
const env = await buildEnv({ ...base, endpoint: "https://obs.ap-southeast-1.myhuaweicloud.com" }, "org-1");
|
||||
const env = await buildEnvForTest({ ...base, endpoint: "https://obs.ap-southeast-1.myhuaweicloud.com" }, "org-1");
|
||||
|
||||
expect(env.AWS_S3_BUCKET_LOOKUP).toBe("dns");
|
||||
});
|
||||
|
||||
test("does not set AWS_S3_BUCKET_LOOKUP for standard S3 endpoints", async () => {
|
||||
const env = await buildEnv(base, "org-1");
|
||||
const env = await buildEnvForTest(base, "org-1");
|
||||
|
||||
expect(env.AWS_S3_BUCKET_LOOKUP).toBeUndefined();
|
||||
});
|
||||
|
|
@ -106,7 +154,7 @@ describe("buildEnv", () => {
|
|||
|
||||
describe("r2 backend", () => {
|
||||
test("sets AWS credentials with auto region and forced path style", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
withCustomPassword({
|
||||
backend: "r2" as const,
|
||||
endpoint: "https://myaccount.r2.cloudflarestorage.com",
|
||||
|
|
@ -126,7 +174,7 @@ describe("buildEnv", () => {
|
|||
|
||||
describe("gcs backend", () => {
|
||||
test("sets project ID and writes credentials file", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
withCustomPassword({
|
||||
backend: "gcs" as const,
|
||||
bucket: "my-gcs-bucket",
|
||||
|
|
@ -137,7 +185,15 @@ describe("buildEnv", () => {
|
|||
);
|
||||
|
||||
expect(env.GOOGLE_PROJECT_ID).toBe("my-gcp-project");
|
||||
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toMatch(/^\/tmp\/zerobyte-gcs-.+\.json$/);
|
||||
|
||||
const credentialsPath = env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
expect(credentialsPath).toBeDefined();
|
||||
if (!credentialsPath) {
|
||||
throw new Error("Expected credentials path to be defined");
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(credentialsPath, "utf-8");
|
||||
expect(fileContent).toBe('{"type":"service_account"}');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -150,20 +206,20 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("sets account name and key", async () => {
|
||||
const env = await buildEnv(base, "org-1");
|
||||
const env = await buildEnvForTest(base, "org-1");
|
||||
|
||||
expect(env.AZURE_ACCOUNT_NAME).toBe("mystorageaccount");
|
||||
expect(env.AZURE_ACCOUNT_KEY).toBe("my-account-key");
|
||||
});
|
||||
|
||||
test("includes endpoint suffix when provided", async () => {
|
||||
const env = await buildEnv({ ...base, endpointSuffix: "core.chinacloudapi.cn" }, "org-1");
|
||||
const env = await buildEnvForTest({ ...base, endpointSuffix: "core.chinacloudapi.cn" }, "org-1");
|
||||
|
||||
expect(env.AZURE_ENDPOINT_SUFFIX).toBe("core.chinacloudapi.cn");
|
||||
});
|
||||
|
||||
test("omits endpoint suffix when not provided", async () => {
|
||||
const env = await buildEnv(base, "org-1");
|
||||
const env = await buildEnvForTest(base, "org-1");
|
||||
|
||||
expect(env.AZURE_ENDPOINT_SUFFIX).toBeUndefined();
|
||||
});
|
||||
|
|
@ -171,7 +227,7 @@ describe("buildEnv", () => {
|
|||
|
||||
describe("rest backend", () => {
|
||||
test("sets username and password when both are provided", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
withCustomPassword({
|
||||
backend: "rest" as const,
|
||||
url: "https://rest-server.example.com",
|
||||
|
|
@ -186,7 +242,7 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("omits REST credentials when neither username nor password is provided", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
withCustomPassword({ backend: "rest" as const, url: "https://rest-server.example.com" }),
|
||||
"org-1",
|
||||
);
|
||||
|
|
@ -226,21 +282,21 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("uses StrictHostKeyChecking=no when skipHostKeyCheck is true", async () => {
|
||||
const env = await buildEnv({ ...baseSftpConfig, skipHostKeyCheck: true }, "org-1");
|
||||
const env = await buildEnvForTest({ ...baseSftpConfig, skipHostKeyCheck: true }, "org-1");
|
||||
|
||||
expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=no");
|
||||
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null");
|
||||
});
|
||||
|
||||
test("uses StrictHostKeyChecking=no when knownHosts is absent", async () => {
|
||||
const env = await buildEnv({ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, "org-1");
|
||||
const env = await buildEnvForTest({ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, "org-1");
|
||||
|
||||
expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=no");
|
||||
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null");
|
||||
});
|
||||
|
||||
test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
{
|
||||
...baseSftpConfig,
|
||||
skipHostKeyCheck: false,
|
||||
|
|
@ -255,19 +311,19 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("adds -p flag for non-default ports", async () => {
|
||||
const env = await buildEnv({ ...baseSftpConfig, port: 2222 }, "org-1");
|
||||
const env = await buildEnvForTest({ ...baseSftpConfig, port: 2222 }, "org-1");
|
||||
|
||||
expect(env._SFTP_SSH_ARGS).toContain("-p 2222");
|
||||
});
|
||||
|
||||
test("omits -p flag for the default port 22", async () => {
|
||||
const env = await buildEnv({ ...baseSftpConfig, port: 22 }, "org-1");
|
||||
const env = await buildEnvForTest({ ...baseSftpConfig, port: 22 }, "org-1");
|
||||
|
||||
expect(env._SFTP_SSH_ARGS).not.toContain("-p 22");
|
||||
});
|
||||
|
||||
test("sets the key path in both _SFTP_KEY_PATH and SSH args -i flag", async () => {
|
||||
const env = await buildEnv(baseSftpConfig, "org-1");
|
||||
const env = await buildEnvForTest(baseSftpConfig, "org-1");
|
||||
|
||||
expect(env._SFTP_KEY_PATH).toMatch(/^\/tmp\/zerobyte-ssh-/);
|
||||
expect(env._SFTP_SSH_ARGS).toContain(`-i ${env._SFTP_KEY_PATH}`);
|
||||
|
|
@ -276,7 +332,7 @@ describe("buildEnv", () => {
|
|||
|
||||
describe("cacert", () => {
|
||||
test("sets RESTIC_CACERT pointing to a temp file when cacert is provided", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
withCustomPassword({
|
||||
backend: "local" as const,
|
||||
path: "/tmp/repo",
|
||||
|
|
@ -285,11 +341,18 @@ describe("buildEnv", () => {
|
|||
"org-1",
|
||||
);
|
||||
|
||||
expect(env.RESTIC_CACERT).toMatch(/^\/tmp\/zerobyte-cacert-.+\.pem$/);
|
||||
const certPath = env.RESTIC_CACERT;
|
||||
expect(certPath).toBeDefined();
|
||||
if (!certPath) {
|
||||
throw new Error("Expected certificate path to be defined");
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(certPath, "utf-8");
|
||||
expect(fileContent).toBe("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----");
|
||||
});
|
||||
|
||||
test("does not set RESTIC_CACERT when cacert is absent", async () => {
|
||||
const env = await buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
|
||||
expect(env.RESTIC_CACERT).toBeUndefined();
|
||||
});
|
||||
|
|
@ -297,7 +360,7 @@ describe("buildEnv", () => {
|
|||
|
||||
describe("insecure TLS", () => {
|
||||
test("sets _INSECURE_TLS=true when insecureTls is true", async () => {
|
||||
const env = await buildEnv(
|
||||
const env = await buildEnvForTest(
|
||||
withCustomPassword({ backend: "local" as const, path: "/tmp/repo", insecureTls: true }),
|
||||
"org-1",
|
||||
);
|
||||
|
|
@ -306,7 +369,7 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("does not set _INSECURE_TLS when insecureTls is absent", async () => {
|
||||
const env = await buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1");
|
||||
|
||||
expect(env._INSECURE_TLS).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue