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:
Nico 2026-02-26 19:54:25 +01:00 committed by GitHub
parent 4a812a884b
commit 942bf2b31b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 400 additions and 444 deletions

View file

@ -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();

View file

@ -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");
});
});

View file

@ -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,

View file

@ -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({

View file

@ -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,

View file

@ -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 });
}
});
});

View file

@ -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]);
});
});
});

View file

@ -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();
});