fix(repositories): append short id when using the default path for local repos (#536)
This commit is contained in:
parent
0bbdbc85ad
commit
ebfafa4143
5 changed files with 98 additions and 16 deletions
|
|
@ -13,6 +13,7 @@ import * as spawnModule from "~/server/utils/spawn";
|
||||||
import { restic } from "~/server/utils/restic";
|
import { restic } from "~/server/utils/restic";
|
||||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
||||||
import { scheduleQueries } from "../backups.queries";
|
import { scheduleQueries } from "../backups.queries";
|
||||||
|
import { fromAny } from "@total-typescript/shoehorn";
|
||||||
|
|
||||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
|
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
|
||||||
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null }));
|
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);
|
const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID);
|
||||||
expect(hydratedSchedule).toBeDefined();
|
expect(hydratedSchedule).toBeDefined();
|
||||||
const scheduleWithoutVolume = {
|
const scheduleWithoutVolume = {
|
||||||
...hydratedSchedule!,
|
...hydratedSchedule,
|
||||||
volume: null,
|
volume: null,
|
||||||
} as unknown as NonNullable<Awaited<ReturnType<typeof scheduleQueries.findById>>>;
|
};
|
||||||
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(scheduleWithoutVolume);
|
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutVolume));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const result = await backupsExecutionService.validateBackupExecution(schedule.id);
|
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);
|
const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID);
|
||||||
expect(hydratedSchedule).toBeDefined();
|
expect(hydratedSchedule).toBeDefined();
|
||||||
const scheduleWithoutRepository = {
|
const scheduleWithoutRepository = {
|
||||||
...hydratedSchedule!,
|
...hydratedSchedule,
|
||||||
repository: null,
|
repository: null,
|
||||||
} as unknown as NonNullable<Awaited<ReturnType<typeof scheduleQueries.findById>>>;
|
};
|
||||||
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(scheduleWithoutRepository);
|
spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutRepository));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const result = await backupsExecutionService.validateBackupExecution(schedule.id);
|
const result = await backupsExecutionService.validateBackupExecution(schedule.id);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
BadRequestError,
|
|
||||||
ConflictError,
|
|
||||||
InternalServerError,
|
|
||||||
NotFoundError,
|
|
||||||
} from "http-errors-enhanced";
|
|
||||||
import {
|
import {
|
||||||
type CompressionMode,
|
type CompressionMode,
|
||||||
type OverwriteMode,
|
type OverwriteMode,
|
||||||
|
|
@ -29,6 +24,7 @@ import { safeSpawn } from "../../utils/spawn";
|
||||||
import { backupsService } from "../backups/backups.service";
|
import { backupsService } from "../backups/backups.service";
|
||||||
import type { UpdateRepositoryBody } from "./repositories.dto";
|
import type { UpdateRepositoryBody } from "./repositories.dto";
|
||||||
import { executeDoctor } from "./doctor";
|
import { executeDoctor } from "./doctor";
|
||||||
|
import { REPOSITORY_BASE } from "~/server/core/constants";
|
||||||
|
|
||||||
const runningDoctors = new Map<string, AbortController>();
|
const runningDoctors = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
|
@ -130,8 +126,11 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const shortId = generateShortId();
|
const shortId = generateShortId();
|
||||||
|
|
||||||
let processedConfig = config;
|
if (config.backend === "local" && config.path === REPOSITORY_BASE) {
|
||||||
const encryptedConfig = await encryptConfig(processedConfig);
|
config.path = `${REPOSITORY_BASE}/${shortId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedConfig = await encryptConfig(config);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(repositoriesTable)
|
.insert(repositoriesTable)
|
||||||
|
|
@ -635,8 +634,7 @@ const updateRepository = async (id: string, updates: UpdateRepositoryBody) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null;
|
const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null;
|
||||||
const configChanged =
|
const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
|
||||||
updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
|
|
||||||
const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig;
|
const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig;
|
||||||
|
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -78,6 +78,7 @@
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@total-typescript/shoehorn": "^0.1.2",
|
||||||
"@types/bun": "^1.3.6",
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@total-typescript/shoehorn": "^0.1.2",
|
||||||
"@types/bun": "^1.3.6",
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue