fix(repositories): append short id when using the default path for local repos (#536)

This commit is contained in:
Nico 2026-02-17 18:28:33 +01:00 committed by GitHub
parent 0bbdbc85ad
commit ebfafa4143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 98 additions and 16 deletions

View file

@ -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<Awaited<ReturnType<typeof scheduleQueries.findById>>>;
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<Awaited<ReturnType<typeof scheduleQueries.findById>>>;
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(scheduleWithoutRepository);
};
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutRepository));
// act
const result = await backupsExecutionService.validateBackupExecution(schedule.id);

View file

@ -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<RepositoryConfig, { backend: "local" }>;
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<RepositoryConfig, { backend: "local" }>;
expect(savedConfig.path).toBe(explicitPath);
expect(created.status).toBe("healthy");
});
});

View file

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

View file

@ -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=="],

View file

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