diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index 5d01d1e3..7cf3d504 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -13,6 +13,7 @@ import * as spawnModule from "~/server/utils/spawn"; import { restic } from "~/server/utils/restic"; 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 })); @@ -66,10 +67,10 @@ describe("backup execution - validation failures", () => { const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID); expect(hydratedSchedule).toBeDefined(); const scheduleWithoutVolume = { - ...hydratedSchedule!, + ...hydratedSchedule, volume: null, - } as unknown as NonNullable>>; - spyOn(scheduleQueries, "findById").mockResolvedValueOnce(scheduleWithoutVolume); + }; + spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutVolume)); // act const result = await backupsExecutionService.validateBackupExecution(schedule.id); @@ -95,10 +96,10 @@ describe("backup execution - validation failures", () => { const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID); expect(hydratedSchedule).toBeDefined(); const scheduleWithoutRepository = { - ...hydratedSchedule!, + ...hydratedSchedule, repository: null, - } as unknown as NonNullable>>; - spyOn(scheduleQueries, "findById").mockResolvedValueOnce(scheduleWithoutRepository); + }; + spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutRepository)); // act const result = await backupsExecutionService.validateBackupExecution(schedule.id); diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts new file mode 100644 index 00000000..cbd8b3f8 --- /dev/null +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -0,0 +1,79 @@ +import { randomUUID } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { RepositoryConfig } from "~/schemas/restic"; +import { REPOSITORY_BASE } from "~/server/core/constants"; +import { withContext } from "~/server/core/request-context"; +import { db } from "~/server/db/db"; +import { restic } from "~/server/utils/restic"; +import { createTestSession } from "~/test/helpers/auth"; +import { repositoriesService } from "../repositories.service"; + +describe("repositoriesService.createRepository", () => { + const initMock = mock(() => Promise.resolve({ success: true, error: null })); + + beforeEach(() => { + initMock.mockClear(); + spyOn(restic, "init").mockImplementation(initMock); + }); + + afterEach(() => { + mock.restore(); + }); + + test("creates a shortId-scoped repository path when using the repository base directory", async () => { + // arrange + const { organizationId, user } = await createTestSession(); + const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE }; + + // act + const result = await withContext({ organizationId, userId: user.id }, () => + repositoriesService.createRepository("main repo", config), + ); + + const created = await db.query.repositoriesTable.findFirst({ + where: { + id: result.repository.id, + }, + }); + + // assert + expect(created).toBeTruthy(); + if (!created) { + throw new Error("Repository should be created"); + } + + const savedConfig = created.config as Extract; + + expect(savedConfig.path).toBe(`${REPOSITORY_BASE}/${created.shortId}`); + expect(savedConfig.path).not.toBe(REPOSITORY_BASE); + expect(created.status).toBe("healthy"); + }); + + test("keeps an explicit local repository path unchanged", async () => { + // arrange + const { organizationId, user } = await createTestSession(); + const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; + const config: RepositoryConfig = { backend: "local", path: explicitPath }; + + // act + const result = await withContext({ organizationId, userId: user.id }, () => + repositoriesService.createRepository("custom repo", config), + ); + + const created = await db.query.repositoriesTable.findFirst({ + where: { + id: result.repository.id, + }, + }); + + // assert + expect(created).toBeTruthy(); + if (!created) { + throw new Error("Repository should be created"); + } + + const savedConfig = created.config as Extract; + expect(savedConfig.path).toBe(explicitPath); + expect(created.status).toBe("healthy"); + }); +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index e813a95e..d7acf173 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -1,12 +1,7 @@ import crypto from "node:crypto"; import { type } from "arktype"; import { and, eq } from "drizzle-orm"; -import { - BadRequestError, - ConflictError, - InternalServerError, - NotFoundError, -} from "http-errors-enhanced"; +import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import { type CompressionMode, type OverwriteMode, @@ -29,6 +24,7 @@ import { safeSpawn } from "../../utils/spawn"; import { backupsService } from "../backups/backups.service"; import type { UpdateRepositoryBody } from "./repositories.dto"; import { executeDoctor } from "./doctor"; +import { REPOSITORY_BASE } from "~/server/core/constants"; const runningDoctors = new Map(); @@ -130,8 +126,11 @@ const createRepository = async (name: string, config: RepositoryConfig, compress const id = crypto.randomUUID(); const shortId = generateShortId(); - let processedConfig = config; - const encryptedConfig = await encryptConfig(processedConfig); + if (config.backend === "local" && config.path === REPOSITORY_BASE) { + config.path = `${REPOSITORY_BASE}/${shortId}`; + } + + const encryptedConfig = await encryptConfig(config); const [created] = await db .insert(repositoriesTable) @@ -635,8 +634,7 @@ const updateRepository = async (id: string, updates: UpdateRepositoryBody) => { } const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null; - const configChanged = - updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig); + const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig); const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig; const updatedAt = Date.now(); diff --git a/bun.lock b/bun.lock index fbbb5afc..90c71d6c 100644 --- a/bun.lock +++ b/bun.lock @@ -78,6 +78,7 @@ "@tailwindcss/vite": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@total-typescript/shoehorn": "^0.1.2", "@types/bun": "^1.3.6", "@types/node": "^25.2.3", "@types/react": "^19.2.14", @@ -809,6 +810,8 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@total-typescript/shoehorn": ["@total-typescript/shoehorn@0.1.2", "", {}, "sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/package.json b/package.json index 56fe2bc8..f6a6f84a 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@tailwindcss/vite": "^4.1.18", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@total-typescript/shoehorn": "^0.1.2", "@types/bun": "^1.3.6", "@types/node": "^25.2.3", "@types/react": "^19.2.14",