diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index b28c21fa..c815e2d7 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -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(); diff --git a/app/server/modules/backups/__tests__/backups.patterns.test.ts b/app/server/modules/backups/__tests__/backups.patterns.test.ts index 9dee49de..ada2bb20 100644 --- a/app/server/modules/backups/__tests__/backups.patterns.test.ts +++ b/app/server/modules/backups/__tests__/backups.patterns.test.ts @@ -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[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 => + 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"); }); }); diff --git a/app/server/modules/backups/__tests__/backups.queries.test.ts b/app/server/modules/backups/__tests__/backups.queries.test.ts index 2a8d6464..4744aee9 100644 --- a/app/server/modules/backups/__tests__/backups.queries.test.ts +++ b/app/server/modules/backups/__tests__/backups.queries.test.ts @@ -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, diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index ebeaf169..7f9da6da 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -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({ diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index 9f88e72e..10a7bb32 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -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, diff --git a/app/server/modules/volumes/__tests__/volumes.service.test.ts b/app/server/modules/volumes/__tests__/volumes.service.test.ts index 41b16254..ac0b4966 100644 --- a/app/server/modules/volumes/__tests__/volumes.service.test.ts +++ b/app/server/modules/volumes/__tests__/volumes.service.test.ts @@ -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 }); + } }); }); diff --git a/app/server/utils/__tests__/spawn.test.ts b/app/server/utils/__tests__/spawn.test.ts index 1bc98552..b86bc6f1 100644 --- a/app/server/utils/__tests__/spawn.test.ts +++ b/app/server/utils/__tests__/spawn.test.ts @@ -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]); }); }); }); diff --git a/app/server/utils/restic/helpers/__tests__/build-env.test.ts b/app/server/utils/restic/helpers/__tests__/build-env.test.ts index 4b892f41..d9c9724a 100644 --- a/app/server/utils/restic/helpers/__tests__/build-env.test.ts +++ b/app/server/utils/restic/helpers/__tests__/build-env.test.ts @@ -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(); + +const trackTempFile = (filePath: string | undefined) => { + if (filePath) { + tempFiles.add(filePath); + } +}; + +const buildEnvForTest = async ( + config: Parameters[0], + organizationId: string, +): Promise> => { + 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(); });