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 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 { backupsService } from "../backups.service";
import { backupsExecutionService } from "../backups.execution"; import { backupsExecutionService } from "../backups.execution";
import { createTestVolume } from "~/test/helpers/volume"; import { createTestVolume } from "~/test/helpers/volume";
@ -15,19 +15,22 @@ import { NotFoundError, BadRequestError } from "http-errors-enhanced";
import { scheduleQueries } from "../backups.queries"; import { scheduleQueries } from "../backups.queries";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" })); const setup = () => {
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null })); const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" })); 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(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
spyOn(restic, "forget").mockImplementation(resticForgetMock); spyOn(restic, "forget").mockImplementation(resticForgetMock);
spyOn(restic, "copy").mockImplementation(resticCopyMock); spyOn(restic, "copy").mockImplementation(resticCopyMock);
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
});
return {
resticBackupMock,
resticForgetMock,
resticCopyMock,
};
};
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
@ -36,6 +39,7 @@ afterEach(() => {
describe("backup execution - validation failures", () => { describe("backup execution - validation failures", () => {
test("should fail backup when volume is not mounted", async () => { test("should fail backup when volume is not mounted", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume({ status: "unmounted" }); const volume = await createTestVolume({ status: "unmounted" });
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -57,6 +61,7 @@ describe("backup execution - validation failures", () => {
test("should fail backup when volume does not exist", async () => { test("should fail backup when volume does not exist", async () => {
// arrange // arrange
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -86,6 +91,7 @@ describe("backup execution - validation failures", () => {
test("should fail backup when repository does not exist", async () => { test("should fail backup when repository does not exist", async () => {
// arrange // arrange
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -115,6 +121,7 @@ describe("backup execution - validation failures", () => {
}); });
test("should fail backup when schedule does not exist", async () => { test("should fail backup when schedule does not exist", async () => {
setup();
// act // act
const result = await backupsExecutionService.validateBackupExecution(99999); const result = await backupsExecutionService.validateBackupExecution(99999);
@ -130,6 +137,7 @@ describe("backup execution - validation failures", () => {
describe("stop backup", () => { describe("stop backup", () => {
test("should stop a running backup", async () => { test("should stop a running backup", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -160,6 +168,7 @@ describe("stop backup", () => {
test("should throw ConflictError when trying to stop non-running backup", async () => { test("should throw ConflictError when trying to stop non-running backup", async () => {
// arrange // arrange
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -174,6 +183,7 @@ describe("stop backup", () => {
}); });
test("should throw NotFoundError when schedule does not exist", async () => { test("should throw NotFoundError when schedule does not exist", async () => {
setup();
// act & assert // act & assert
await expect(backupsExecutionService.stopBackup(99999)).rejects.toThrow("Backup schedule not found"); await expect(backupsExecutionService.stopBackup(99999)).rejects.toThrow("Backup schedule not found");
}); });
@ -182,6 +192,7 @@ describe("stop backup", () => {
describe("retention policy - runForget", () => { describe("retention policy - runForget", () => {
test("should execute forget with retention policy", async () => { test("should execute forget with retention policy", async () => {
// arrange // arrange
const { resticForgetMock } = setup();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
repositoryId: repository.id, repositoryId: repository.id,
@ -216,6 +227,7 @@ describe("retention policy - runForget", () => {
test("should throw BadRequestError if no retention policy configured", async () => { test("should throw BadRequestError if no retention policy configured", async () => {
// arrange // arrange
setup();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
repositoryId: repository.id, repositoryId: repository.id,
@ -229,12 +241,14 @@ describe("retention policy - runForget", () => {
}); });
test("should throw NotFoundError when schedule does not exist", async () => { test("should throw NotFoundError when schedule does not exist", async () => {
setup();
// act & assert // act & assert
await expect(backupsExecutionService.runForget(99999)).rejects.toThrow("Backup schedule not found"); await expect(backupsExecutionService.runForget(99999)).rejects.toThrow("Backup schedule not found");
}); });
test("should throw NotFoundError when repository does not exist", async () => { test("should throw NotFoundError when repository does not exist", async () => {
// arrange // arrange
setup();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
retentionPolicy: { retentionPolicy: {
keepHourly: 24, keepHourly: 24,
@ -251,6 +265,7 @@ describe("retention policy - runForget", () => {
describe("mirror operations", () => { describe("mirror operations", () => {
test("should copy snapshots to mirror repositories", async () => { test("should copy snapshots to mirror repositories", async () => {
// arrange // arrange
const { resticCopyMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = await createTestRepository(); const mirrorRepository = await createTestRepository();
@ -277,6 +292,7 @@ describe("mirror operations", () => {
test("should skip disabled mirrors", async () => { test("should skip disabled mirrors", async () => {
// arrange // arrange
const { resticCopyMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = await createTestRepository(); const mirrorRepository = await createTestRepository();
@ -296,6 +312,7 @@ describe("mirror operations", () => {
test("should update mirror status on success", async () => { test("should update mirror status on success", async () => {
// arrange // arrange
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = 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 () => { test("should finalize mirror status when mirror settings are updated during copy", async () => {
// arrange // arrange
const { resticCopyMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = await createTestRepository(); const mirrorRepository = await createTestRepository();
@ -350,6 +368,7 @@ describe("mirror operations", () => {
test("should update mirror status on failure", async () => { test("should update mirror status on failure", async () => {
// arrange // arrange
const { resticCopyMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = 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 () => { test("should run forget on mirror after successful copy when retention policy exists", async () => {
// arrange // arrange
const { resticCopyMock, resticForgetMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = 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 () => { test("should not run forget on mirror when no retention policy", async () => {
// arrange // arrange
const { resticCopyMock, resticForgetMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const sourceRepository = await createTestRepository(); const sourceRepository = await createTestRepository();
const mirrorRepository = await createTestRepository(); const mirrorRepository = await createTestRepository();

View file

@ -1,141 +1,94 @@
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { test, describe, expect } 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 path from "node:path"; import path from "node:path";
import { TEST_ORG_ID } from "~/test/helpers/organization"; import { fromAny } from "@total-typescript/shoehorn";
import * as context from "~/server/core/request-context"; import { createBackupOptions, processPattern } from "../backup.helpers";
import { backupsExecutionService } from "../backups.execution";
const backupMock = mock(() => Promise.resolve({ exitCode: 0, result: JSON.parse(generateBackupOutput()) })); type BackupScheduleInput = Parameters<typeof createBackupOptions>[0];
beforeEach(() => { const createSchedule = (overrides: Partial<BackupScheduleInput> = {}): BackupScheduleInput =>
backupMock.mockClear(); fromAny({
spyOn(restic, "backup").mockImplementation(backupMock); shortId: "sched-1234",
spyOn(restic, "forget").mockImplementation(mock(() => Promise.resolve({ success: true, data: null }))); oneFileSystem: false,
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); includePatterns: [],
}); excludePatterns: [],
excludeIfPresent: [],
afterEach(() => { ...overrides,
mock.restore(); }) as BackupScheduleInput;
});
describe("executeBackup - include / exclude patterns", () => { describe("executeBackup - include / exclude patterns", () => {
test("should correctly build include and exclude patterns", async () => { test("should correctly build include and exclude patterns", () => {
// arrange // arrange
const volume = await createTestVolume(); const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
const repository = await createTestRepository(); const schedule = createSchedule({
const volumePath = getVolumePath(volume);
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
includePatterns: ["*.zip", "/Photos", "!/Temp", "!*.log"], includePatterns: ["*.zip", "/Photos", "!/Temp", "!*.log"],
excludePatterns: [".DS_Store", "/Config", "!/Important", "!*.tmp"], excludePatterns: [".DS_Store", "/Config", "!/Important", "!*.tmp"],
excludeIfPresent: [".nobackup"], excludeIfPresent: [".nobackup"],
}); });
const signal = new AbortController().signal;
// act // act
await backupsExecutionService.executeBackup(schedule.id); const options = createBackupOptions(schedule, volumePath, signal);
// assert // assert
expect(backupMock).toHaveBeenCalledWith( expect(options).toMatchObject({
expect.anything(), include: ["*.zip", path.join(volumePath, "Photos"), `!${path.join(volumePath, "Temp")}`, "!*.log"],
volumePath, exclude: [".DS_Store", path.join(volumePath, "Config"), `!${path.join(volumePath, "Important")}`, "!*.tmp"],
expect.objectContaining({ excludeIfPresent: [".nobackup"],
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 // arrange
const volumeName = "SyncFolder"; const volumeName = "SyncFolder";
const volume = await createTestVolume({ const volumePath = `/${volumeName}`;
name: volumeName, const selectedPath = `/${volumeName}`;
type: "directory", const schedule = createSchedule({
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,
includePatterns: [selectedPath], includePatterns: [selectedPath],
}); });
const signal = new AbortController().signal;
// act // act
await backupsExecutionService.executeBackup(schedule.id); const options = createBackupOptions(schedule, volumePath, signal);
// assert // assert
expect(backupMock).toHaveBeenCalledWith( expect(options.include).toEqual([path.join(volumePath, volumeName)]);
expect.anything(),
volumePath,
expect.objectContaining({
// Should produce /SyncFolder/SyncFolder and not just /SyncFolder
include: [path.join(volumePath, volumeName)],
}),
);
}); });
test("should correctly mix relative and absolute patterns", async () => { test("should correctly mix relative and absolute patterns", () => {
// arrange // arrange
const volume = await createTestVolume(); const volumePath = "/var/lib/zerobyte/volumes/vol456/_data";
const volumePath = getVolumePath(volume);
const repository = await createTestRepository();
const relativeInclude = "relative/include"; const relativeInclude = "relative/include";
const anchoredInclude = "/anchored/include"; const anchoredInclude = "/anchored/include";
const schedule = createSchedule({
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
includePatterns: [relativeInclude, anchoredInclude], includePatterns: [relativeInclude, anchoredInclude],
}); });
const signal = new AbortController().signal;
// act // act
await backupsExecutionService.executeBackup(schedule.id); const options = createBackupOptions(schedule, volumePath, signal);
// assert // assert
expect(backupMock).toHaveBeenCalledWith( expect(options.include).toEqual([relativeInclude, path.join(volumePath, "anchored/include")]);
expect.anything(),
volumePath,
expect.objectContaining({
include: [relativeInclude, path.join(volumePath, "anchored/include")],
}),
);
}); });
test("should handle empty include and exclude patterns", async () => { test("should handle empty include and exclude patterns", () => {
// arrange // arrange
const volume = await createTestVolume(); const schedule = createSchedule({
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
includePatterns: [], includePatterns: [],
excludePatterns: [], excludePatterns: [],
}); });
const signal = new AbortController().signal;
// act // act
await backupsExecutionService.executeBackup(schedule.id); const options = createBackupOptions(schedule, "/var/lib/zerobyte/volumes/vol999/_data", signal);
// assert // assert
expect(backupMock).toHaveBeenCalledWith( expect(options.include).toEqual([]);
expect.anything(), expect(options.exclude).toEqual([]);
getVolumePath(volume), });
expect.objectContaining({
include: [], test("processPattern keeps relative and negated relative patterns unchanged", () => {
exclude: [], 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 { scheduleQueries } from "../backups.queries";
import { createTestBackupSchedule } from "~/test/helpers/backup"; import { createTestBackupSchedule } from "~/test/helpers/backup";
import { createTestVolume } from "~/test/helpers/volume"; import { createTestVolume } from "~/test/helpers/volume";
@ -8,19 +8,22 @@ import { TEST_ORG_ID } from "~/test/helpers/organization";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
describe("scheduleQueries.findExecutable", () => { describe("scheduleQueries.findExecutable", () => {
let volume: { id: number }; const createScheduleDependencies = async () => {
let repository: { id: string }; const volume = await createTestVolume();
const repository = await createTestRepository();
beforeEach(async () => { return {
volume = await createTestVolume(); volumeId: volume.id,
repository = await createTestRepository(); repositoryId: repository.id,
}); };
};
test("should return enabled schedules with null nextBackupAt", async () => { test("should return enabled schedules with null nextBackupAt", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: null, nextBackupAt: null,
lastBackupStatus: null, lastBackupStatus: null,
@ -35,10 +38,11 @@ describe("scheduleQueries.findExecutable", () => {
test("should return enabled schedules with past nextBackupAt", async () => { test("should return enabled schedules with past nextBackupAt", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const pastTime = faker.date.past().getTime(); const pastTime = faker.date.past().getTime();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: pastTime, nextBackupAt: pastTime,
lastBackupStatus: null, lastBackupStatus: null,
@ -53,10 +57,11 @@ describe("scheduleQueries.findExecutable", () => {
test("should not return schedules with future nextBackupAt", async () => { test("should not return schedules with future nextBackupAt", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const futureTime = faker.date.future().getTime(); const futureTime = faker.date.future().getTime();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: futureTime, nextBackupAt: futureTime,
lastBackupStatus: null, lastBackupStatus: null,
@ -71,9 +76,10 @@ describe("scheduleQueries.findExecutable", () => {
test("should not return disabled schedules", async () => { test("should not return disabled schedules", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: false, enabled: false,
nextBackupAt: null, nextBackupAt: null,
lastBackupStatus: null, lastBackupStatus: null,
@ -88,9 +94,10 @@ describe("scheduleQueries.findExecutable", () => {
test("should not return schedules with in_progress status", async () => { test("should not return schedules with in_progress status", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: null, nextBackupAt: null,
lastBackupStatus: "in_progress", lastBackupStatus: "in_progress",
@ -105,10 +112,11 @@ describe("scheduleQueries.findExecutable", () => {
test("should return schedules with success status and past nextBackupAt", async () => { test("should return schedules with success status and past nextBackupAt", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const pastTime = faker.date.past().getTime(); const pastTime = faker.date.past().getTime();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: pastTime, nextBackupAt: pastTime,
lastBackupStatus: "success", lastBackupStatus: "success",
@ -123,9 +131,10 @@ describe("scheduleQueries.findExecutable", () => {
test("should return schedules with error status and null nextBackupAt", async () => { test("should return schedules with error status and null nextBackupAt", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: null, nextBackupAt: null,
lastBackupStatus: "error", lastBackupStatus: "error",
@ -140,11 +149,12 @@ describe("scheduleQueries.findExecutable", () => {
test("should not return schedules from other organizations", async () => { test("should not return schedules from other organizations", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
const otherOrgId = faker.string.uuid(); const otherOrgId = faker.string.uuid();
await createTestOrganization({ id: otherOrgId }); await createTestOrganization({ id: otherOrgId });
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: null, nextBackupAt: null,
lastBackupStatus: null, lastBackupStatus: null,
@ -160,9 +170,10 @@ describe("scheduleQueries.findExecutable", () => {
test("should only return schedule IDs", async () => { test("should only return schedule IDs", async () => {
// arrange // arrange
const { volumeId, repositoryId } = await createScheduleDependencies();
await createTestBackupSchedule({ await createTestBackupSchedule({
volumeId: volume.id, volumeId,
repositoryId: repository.id, repositoryId,
enabled: true, enabled: true,
nextBackupAt: null, nextBackupAt: null,
lastBackupStatus: null, lastBackupStatus: null,

View file

@ -1,5 +1,5 @@
import waitForExpect from "wait-for-expect"; 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 { eq } from "drizzle-orm";
import { backupsService } from "../backups.service"; import { backupsService } from "../backups.service";
import { createTestVolume } from "~/test/helpers/volume"; 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 * as context from "~/server/core/request-context";
import { backupsExecutionService } from "../backups.execution"; import { backupsExecutionService } from "../backups.execution";
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); const setup = () => {
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
beforeEach(() => {
resticBackupMock.mockClear();
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
});
return {
resticBackupMock,
};
};
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
@ -29,6 +31,7 @@ afterEach(() => {
describe("execute backup", () => { describe("execute backup", () => {
test("should correctly set next backup time", async () => { test("should correctly set next backup time", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -58,6 +61,7 @@ describe("execute backup", () => {
test("should skip backup if schedule is disabled", async () => { test("should skip backup if schedule is disabled", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ 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 () => { test("should execute backup if schedule is disabled but the run is manual", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -96,6 +101,7 @@ describe("execute backup", () => {
test("should skip the backup if the previous one is still running", async () => { test("should skip the backup if the previous one is still running", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ 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 () => { test("should set the backup status to failed if restic returns a 3 exit code", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ 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 () => { test("should set the backup status to failed if restic returns a non zero exit code", async () => {
// arrange // arrange
const { resticBackupMock } = setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -167,6 +175,7 @@ describe("execute backup", () => {
describe("getSchedulesToExecute", () => { describe("getSchedulesToExecute", () => {
test("should return schedules with NULL lastBackupStatus", async () => { test("should return schedules with NULL lastBackupStatus", async () => {
// arrange // arrange
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
@ -189,6 +198,7 @@ describe("getSchedulesToExecute", () => {
describe("getScheduleByIdOrShortId", () => { describe("getScheduleByIdOrShortId", () => {
test("should resolve a schedule by numeric id string", async () => { test("should resolve a schedule by numeric id string", async () => {
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -203,6 +213,7 @@ describe("getScheduleByIdOrShortId", () => {
}); });
test("should resolve a schedule by short id", async () => { test("should resolve a schedule by short id", async () => {
setup();
const volume = await createTestVolume(); const volume = await createTestVolume();
const repository = await createTestRepository(); const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
@ -217,6 +228,7 @@ describe("getScheduleByIdOrShortId", () => {
}); });
test("should not return schedules from another organization", async () => { test("should not return schedules from another organization", async () => {
setup();
const otherOrgId = faker.string.uuid(); const otherOrgId = faker.string.uuid();
const schedule = await createTestBackupSchedule({ const schedule = await createTestBackupSchedule({
organizationId: otherOrgId, organizationId: otherOrgId,
@ -231,6 +243,7 @@ describe("getScheduleByIdOrShortId", () => {
describe("listSchedules", () => { describe("listSchedules", () => {
test("should ignore schedules with missing relations", async () => { test("should ignore schedules with missing relations", async () => {
setup();
const healthyVolume = await createTestVolume(); const healthyVolume = await createTestVolume();
const healthyRepository = await createTestRepository(); const healthyRepository = await createTestRepository();
const healthySchedule = await createTestBackupSchedule({ const healthySchedule = await createTestBackupSchedule({
@ -254,6 +267,7 @@ describe("listSchedules", () => {
}); });
test("should ignore schedules with missing repository relation", async () => { test("should ignore schedules with missing repository relation", async () => {
setup();
const healthyVolume = await createTestVolume(); const healthyVolume = await createTestVolume();
const healthyRepository = await createTestRepository(); const healthyRepository = await createTestRepository();
const healthySchedule = await createTestBackupSchedule({ const healthySchedule = await createTestBackupSchedule({
@ -279,6 +293,7 @@ describe("listSchedules", () => {
describe("cleanupOrphanedSchedules", () => { describe("cleanupOrphanedSchedules", () => {
test("should return zero when cascades already removed orphaned schedules", async () => { test("should return zero when cascades already removed orphaned schedules", async () => {
setup();
const healthyVolume = await createTestVolume(); const healthyVolume = await createTestVolume();
const healthyRepository = await createTestRepository(); const healthyRepository = await createTestRepository();
const healthySchedule = await createTestBackupSchedule({ const healthySchedule = await createTestBackupSchedule({

View file

@ -140,10 +140,15 @@ describe("repositoriesService.dumpSnapshot", () => {
mock.restore(); 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 { organizationId, user } = await createTestSession();
const shortId = generateShortId(); const shortId = generateShortId();
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
await db.insert(repositoriesTable).values({ await db.insert(repositoriesTable).values({
id: randomUUID(), id: randomUUID(),
@ -159,31 +164,36 @@ describe("repositoriesService.dumpSnapshot", () => {
organizationId, organizationId,
}); });
const snapshotsMock = mock(() => spyOn(restic, "snapshots").mockResolvedValue([
Promise.resolve([ {
{ id: snapshotId,
id: "snapshot-123", short_id: snapshotId,
short_id: "snapshot-123", time: new Date().toISOString(),
time: new Date().toISOString(), paths: [basePath],
tree: "tree-1", hostname: "host",
paths: [basePath], },
hostname: "host", ]);
},
]),
);
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
const dumpMock = mock(() => const dumpMock = mock(() => Promise.resolve(createDumpResult()));
Promise.resolve({
stream: Readable.from([]),
completion: Promise.resolve(),
abort: () => {},
}),
);
spyOn(restic, "dump").mockImplementation(dumpMock); 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"); const emitSpy = spyOn(serverEvents, "emit");
await withContext({ organizationId, userId: user.id }, () => await withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"), 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 () => { test("streams a single file directly when selected path is a file", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
const shortId = generateShortId(); snapshotId: "snapshot-file",
const basePath = "/var/lib/zerobyte/volumes/vol123/_data"; 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 snapshotsMock = mock(() => const result = await withContext({ organizationId, userId }, () =>
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 }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"), 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 () => { test("rejects path downloads without a kind", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
const shortId = generateShortId(); snapshotId: "snapshot-no-kind",
const basePath = "/var/lib/zerobyte/volumes/vol123/_data"; 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 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( expect(
withContext({ organizationId, userId: user.id }, () => withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`), repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
), ),
).rejects.toThrow("Path kind is required when downloading a specific snapshot path"); ).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 () => { test("downloads full snapshot relative to common ancestor when path is omitted", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
const shortId = generateShortId(); snapshotId: "snapshot-999",
const basePath = "/var/lib/zerobyte/volumes/vol555/_data"; 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 snapshotsMock = mock(() => await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-999"));
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"),
);
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, { expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, {
organizationId, organizationId,

View file

@ -10,121 +10,117 @@ import { createTestSession } from "~/test/helpers/auth";
import { withContext } from "~/server/core/request-context"; import { withContext } from "~/server/core/request-context";
import { asShortId } from "~/server/utils/branded"; import { asShortId } from "~/server/utils/branded";
describe("volumeService", () => { describe("volumeService.getVolume", () => {
describe("findVolume", () => { test("should find volume by shortId", async () => {
test("should find volume by shortId", async () => { const { organizationId, user } = await createTestSession();
const { organizationId, user } = await createTestSession();
const [volume] = await db const [volume] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId: asShortId(randomUUID().slice(0, 8)), shortId: asShortId(randomUUID().slice(0, 8)),
name: `test-vol-${randomUUID().slice(0, 8)}`, name: `test-vol-${randomUUID().slice(0, 8)}`,
type: "directory", type: "directory",
status: "mounted", status: "mounted",
config: { backend: "directory", path: "/" }, config: { backend: "directory", path: "/" },
autoRemount: true, autoRemount: true,
organizationId, organizationId,
}) })
.returning(); .returning();
await withContext({ organizationId, userId: user.id }, async () => { await withContext({ organizationId, userId: user.id }, async () => {
const result = await volumeService.getVolume(volume.shortId); const result = await volumeService.getVolume(volume.shortId);
expect(result.volume.id).toBe(volume.id); expect(result.volume.id).toBe(volume.id);
expect(result.volume.shortId).toBe(volume.shortId); expect(result.volume.shortId).toBe(volume.shortId);
});
}); });
});
test("should find volume by shortId from literal input", async () => { test("should find volume by shortId from literal input", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, user } = await createTestSession();
const [volume] = await db const [volume] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId: asShortId("test1234"), shortId: asShortId("test1234"),
name: `test-vol-${randomUUID().slice(0, 8)}`, name: `test-vol-${randomUUID().slice(0, 8)}`,
type: "directory", type: "directory",
status: "mounted", status: "mounted",
config: { backend: "directory", path: "/" }, config: { backend: "directory", path: "/" },
autoRemount: true, autoRemount: true,
organizationId, organizationId,
}) })
.returning(); .returning();
await withContext({ organizationId, userId: user.id }, async () => { await withContext({ organizationId, userId: user.id }, async () => {
const result = await volumeService.getVolume(volume.shortId); const result = await volumeService.getVolume(volume.shortId);
expect(result.volume.id).toBe(volume.id); expect(result.volume.id).toBe(volume.id);
expect(result.volume.shortId).toBe(volume.shortId); expect(result.volume.shortId).toBe(volume.shortId);
});
}); });
});
test("should find volume by numeric-looking shortId", async () => { test("should find volume by numeric-looking shortId", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, user } = await createTestSession();
const [volume] = await db const [volume] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId: asShortId("499780"), shortId: asShortId("499780"),
name: `test-vol-${randomUUID().slice(0, 8)}`, name: `test-vol-${randomUUID().slice(0, 8)}`,
type: "directory", type: "directory",
status: "mounted", status: "mounted",
config: { backend: "directory", path: "/" }, config: { backend: "directory", path: "/" },
autoRemount: true, autoRemount: true,
organizationId, organizationId,
}) })
.returning(); .returning();
await withContext({ organizationId, userId: user.id }, async () => { await withContext({ organizationId, userId: user.id }, async () => {
const result = await volumeService.getVolume(asShortId("499780")); const result = await volumeService.getVolume(asShortId("499780"));
expect(result.volume.id).toBe(volume.id); expect(result.volume.id).toBe(volume.id);
expect(result.volume.shortId).toBe(asShortId("499780")); expect(result.volume.shortId).toBe(asShortId("499780"));
});
}); });
});
test("should throw NotFoundError for non-existent volume", async () => { test("should throw NotFoundError for non-existent volume", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, user } = await createTestSession();
await withContext({ organizationId, userId: user.id }, async () => { await withContext({ organizationId, userId: user.id }, async () => {
await expect(volumeService.getVolume(asShortId("nonexistent"))).rejects.toThrow("Volume not found"); await expect(volumeService.getVolume(asShortId("nonexistent"))).rejects.toThrow("Volume not found");
});
}); });
}); });
}); });
describe("volumeService security", () => { describe("volumeService.listFiles security", () => {
describe("path traversal", () => { test("should reject traversal outside the volume root in listFiles", async () => {
test("should reject traversal outside the volume root in listFiles", async () => { const { organizationId, user } = await createTestSession();
const { organizationId, user } = await createTestSession(); const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-vol-svc-"));
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-vol-svc-")); const volumePath = path.join(tempRoot, "vol");
const volumePath = path.join(tempRoot, "vol"); const secretPath = path.join(tempRoot, "volume-secret");
const secretPath = path.join(tempRoot, "volume-secret");
await fs.mkdir(volumePath, { recursive: true }); await fs.mkdir(volumePath, { recursive: true });
await fs.mkdir(secretPath, { recursive: true }); await fs.mkdir(secretPath, { recursive: true });
await fs.writeFile(path.join(secretPath, "secret.txt"), "top secret", "utf-8"); await fs.writeFile(path.join(secretPath, "secret.txt"), "top secret", "utf-8");
const [volume] = await db const [volume] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId: asShortId(randomUUID().slice(0, 8)), shortId: asShortId(randomUUID().slice(0, 8)),
name: `test-vol-${randomUUID().slice(0, 8)}`, name: `test-vol-${randomUUID().slice(0, 8)}`,
type: "directory", type: "directory",
status: "mounted", status: "mounted",
config: { backend: "directory", path: volumePath }, config: { backend: "directory", path: volumePath },
autoRemount: true, autoRemount: true,
organizationId, organizationId,
}) })
.returning(); .returning();
try { try {
await withContext({ organizationId, userId: user.id }, async () => { await withContext({ organizationId, userId: user.id }, async () => {
const traversalPath = `../${path.basename(secretPath)}`; const traversalPath = `../${path.basename(secretPath)}`;
await expect(volumeService.listFiles(volume.shortId, traversalPath)).rejects.toThrow("Invalid path"); await expect(volumeService.listFiles(volume.shortId, traversalPath)).rejects.toThrow("Invalid path");
}); });
} finally { } finally {
await fs.rm(tempRoot, { recursive: true, force: true }); await fs.rm(tempRoot, { recursive: true, force: true });
} }
});
}); });
}); });

View file

@ -75,34 +75,30 @@ describe("safeExec", () => {
}); });
describe("shell injection protection", () => { 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({ const result = await safeExec({
command: "echo", command: "echo",
args: ["safe; echo injected"], args: [argument],
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("safe; echo injected"); expect(result.stdout.trim()).toBe(expected);
});
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");
}); });
}); });
@ -268,40 +264,32 @@ describe("safeSpawn", () => {
}); });
describe("shell injection protection", () => { 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[] = []; const lines: string[] = [];
await safeSpawn({ await safeSpawn({
command: "echo", command: "echo",
args: ["safe; echo injected"], args: [argument],
onStdout: (line) => lines.push(line), onStdout: (line) => lines.push(line),
}); });
expect(lines).toEqual(["safe; echo injected"]); expect(lines).toEqual([expected]);
});
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"]);
}); });
}); });
}); });

View file

@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"; 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 { db } from "~/server/db/db";
import { organization } from "~/server/db/schema"; import { organization } from "~/server/db/schema";
import { RESTIC_CACHE_DIR } from "~/server/core/constants"; import { RESTIC_CACHE_DIR } from "~/server/core/constants";
@ -27,16 +28,49 @@ const createTestOrg = async (overrides: Partial<typeof organization.$inferInsert
const PLAIN_PRIVATE_KEY = const PLAIN_PRIVATE_KEY =
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH 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("buildEnv", () => {
describe("base environment", () => { describe("base environment", () => {
test("always sets RESTIC_CACHE_DIR", async () => { 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); expect(env.RESTIC_CACHE_DIR).toBe(RESTIC_CACHE_DIR);
}); });
test("always sets PATH", async () => { 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(); expect(env.PATH).toBeTruthy();
}); });
@ -44,20 +78,34 @@ describe("buildEnv", () => {
describe("password resolution", () => { describe("password resolution", () => {
test("writes a password file when using customPassword on an existing repository", async () => { 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" }, { backend: "local" as const, path: "/tmp/repo", isExistingRepository: true, customPassword: "my-secret" },
"org-1", "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 () => { test("writes a password file from the organization's resticPassword when no customPassword is given", async () => {
const orgId = await createTestOrg(); 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 () => { test("throws when the organization does not exist", async () => {
@ -85,20 +133,20 @@ describe("buildEnv", () => {
}); });
test("sets AWS credentials", async () => { 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_ACCESS_KEY_ID).toBe("my-access-key");
expect(env.AWS_SECRET_ACCESS_KEY).toBe("my-secret-key"); expect(env.AWS_SECRET_ACCESS_KEY).toBe("my-secret-key");
}); });
test("sets AWS_S3_BUCKET_LOOKUP=dns for Huawei Cloud endpoints", async () => { 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"); expect(env.AWS_S3_BUCKET_LOOKUP).toBe("dns");
}); });
test("does not set AWS_S3_BUCKET_LOOKUP for standard S3 endpoints", async () => { 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(); expect(env.AWS_S3_BUCKET_LOOKUP).toBeUndefined();
}); });
@ -106,7 +154,7 @@ describe("buildEnv", () => {
describe("r2 backend", () => { describe("r2 backend", () => {
test("sets AWS credentials with auto region and forced path style", async () => { test("sets AWS credentials with auto region and forced path style", async () => {
const env = await buildEnv( const env = await buildEnvForTest(
withCustomPassword({ withCustomPassword({
backend: "r2" as const, backend: "r2" as const,
endpoint: "https://myaccount.r2.cloudflarestorage.com", endpoint: "https://myaccount.r2.cloudflarestorage.com",
@ -126,7 +174,7 @@ describe("buildEnv", () => {
describe("gcs backend", () => { describe("gcs backend", () => {
test("sets project ID and writes credentials file", async () => { test("sets project ID and writes credentials file", async () => {
const env = await buildEnv( const env = await buildEnvForTest(
withCustomPassword({ withCustomPassword({
backend: "gcs" as const, backend: "gcs" as const,
bucket: "my-gcs-bucket", bucket: "my-gcs-bucket",
@ -137,7 +185,15 @@ describe("buildEnv", () => {
); );
expect(env.GOOGLE_PROJECT_ID).toBe("my-gcp-project"); 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 () => { 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_NAME).toBe("mystorageaccount");
expect(env.AZURE_ACCOUNT_KEY).toBe("my-account-key"); expect(env.AZURE_ACCOUNT_KEY).toBe("my-account-key");
}); });
test("includes endpoint suffix when provided", async () => { 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"); expect(env.AZURE_ENDPOINT_SUFFIX).toBe("core.chinacloudapi.cn");
}); });
test("omits endpoint suffix when not provided", async () => { 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(); expect(env.AZURE_ENDPOINT_SUFFIX).toBeUndefined();
}); });
@ -171,7 +227,7 @@ describe("buildEnv", () => {
describe("rest backend", () => { describe("rest backend", () => {
test("sets username and password when both are provided", async () => { test("sets username and password when both are provided", async () => {
const env = await buildEnv( const env = await buildEnvForTest(
withCustomPassword({ withCustomPassword({
backend: "rest" as const, backend: "rest" as const,
url: "https://rest-server.example.com", url: "https://rest-server.example.com",
@ -186,7 +242,7 @@ describe("buildEnv", () => {
}); });
test("omits REST credentials when neither username nor password is provided", async () => { 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" }), withCustomPassword({ backend: "rest" as const, url: "https://rest-server.example.com" }),
"org-1", "org-1",
); );
@ -226,21 +282,21 @@ describe("buildEnv", () => {
}); });
test("uses StrictHostKeyChecking=no when skipHostKeyCheck is true", async () => { 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("StrictHostKeyChecking=no");
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null");
}); });
test("uses StrictHostKeyChecking=no when knownHosts is absent", async () => { 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("StrictHostKeyChecking=no");
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null");
}); });
test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => { test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => {
const env = await buildEnv( const env = await buildEnvForTest(
{ {
...baseSftpConfig, ...baseSftpConfig,
skipHostKeyCheck: false, skipHostKeyCheck: false,
@ -255,19 +311,19 @@ describe("buildEnv", () => {
}); });
test("adds -p flag for non-default ports", async () => { 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"); expect(env._SFTP_SSH_ARGS).toContain("-p 2222");
}); });
test("omits -p flag for the default port 22", async () => { 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"); 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 () => { 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_KEY_PATH).toMatch(/^\/tmp\/zerobyte-ssh-/);
expect(env._SFTP_SSH_ARGS).toContain(`-i ${env._SFTP_KEY_PATH}`); expect(env._SFTP_SSH_ARGS).toContain(`-i ${env._SFTP_KEY_PATH}`);
@ -276,7 +332,7 @@ describe("buildEnv", () => {
describe("cacert", () => { describe("cacert", () => {
test("sets RESTIC_CACERT pointing to a temp file when cacert is provided", async () => { test("sets RESTIC_CACERT pointing to a temp file when cacert is provided", async () => {
const env = await buildEnv( const env = await buildEnvForTest(
withCustomPassword({ withCustomPassword({
backend: "local" as const, backend: "local" as const,
path: "/tmp/repo", path: "/tmp/repo",
@ -285,11 +341,18 @@ describe("buildEnv", () => {
"org-1", "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 () => { 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(); expect(env.RESTIC_CACERT).toBeUndefined();
}); });
@ -297,7 +360,7 @@ describe("buildEnv", () => {
describe("insecure TLS", () => { describe("insecure TLS", () => {
test("sets _INSECURE_TLS=true when insecureTls is true", async () => { 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 }), withCustomPassword({ backend: "local" as const, path: "/tmp/repo", insecureTls: true }),
"org-1", "org-1",
); );
@ -306,7 +369,7 @@ describe("buildEnv", () => {
}); });
test("does not set _INSECURE_TLS when insecureTls is absent", async () => { 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(); expect(env._INSECURE_TLS).toBeUndefined();
}); });