From f3f6a4e5f928aaba855db745c259745acc86dec9 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:36:52 +0200 Subject: [PATCH] test(integration): add restore test suite (#1018) Add an integration test suite for restore --- app/test/integration/infra/Dockerfile | 1 + .../integration/src/restore-route.test.ts | 383 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 app/test/integration/src/restore-route.test.ts diff --git a/app/test/integration/infra/Dockerfile b/app/test/integration/infra/Dockerfile index 4099b0c6..b7e7ecac 100644 --- a/app/test/integration/infra/Dockerfile +++ b/app/test/integration/infra/Dockerfile @@ -10,6 +10,7 @@ COPY ./packages/core/package.json ./packages/core/package.json COPY ./packages/contracts/package.json ./packages/contracts/package.json COPY ./apps/agent/package.json ./apps/agent/package.json COPY ./apps/docs/package.json ./apps/docs/package.json +COPY ./apps/desktop/package.json ./apps/desktop/package.json RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile diff --git a/app/test/integration/src/restore-route.test.ts b/app/test/integration/src/restore-route.test.ts new file mode 100644 index 00000000..91711624 --- /dev/null +++ b/app/test/integration/src/restore-route.test.ts @@ -0,0 +1,383 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { beforeAll, expect, test } from "vitest"; +import waitForExpect from "wait-for-expect"; +import { OVERWRITE_MODES, type OverwriteMode, type RepositoryConfig } from "@zerobyte/core/restic"; +import { organization, repositoriesTable, tasksTable } from "~/server/db/schema"; +import { generateShortId } from "~/server/utils/id"; +import { INTEGRATION_RUNS_DIR } from "./constants"; +import { createIntegrationRestic } from "./helpers/restic"; + +process.env.NODE_ENV = "test"; +process.env.APP_SECRET = "8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69"; +process.env.BASE_URL = "http://localhost:4096"; +process.env.TRUSTED_ORIGINS = "http://localhost:4096"; +process.env.ZEROBYTE_DATABASE_URL = ":memory:"; +process.env.ENABLE_LOCAL_AGENT = "false"; +process.env.RESTIC_CACHE_DIR = path.join(os.tmpdir(), "zerobyte-integration-server-restic-cache"); +process.env.RESTIC_PASS_FILE = path.join(os.tmpdir(), "zerobyte-integration-server-restic.pass"); + +type RestoreLocation = "original" | "custom"; +type ConflictAge = "old" | "newer"; + +type SelectionScenario = { + id: string; + name: string; + include: (sourceRoot: string) => string[] | undefined; + selectedItemKind?: "file" | "dir"; + selectedFiles: Record; + conflicts: Partial>; + missing: string[]; +}; + +type RestoreMatrixCase = { + name: string; + selection: SelectionScenario; + location: RestoreLocation; + overwrite: OverwriteMode; +}; + +let app: ReturnType; +let db: (typeof import("~/server/db/db"))["db"]; +let cryptoUtils: typeof import("~/server/utils/crypto").cryptoUtils; +let session: Awaited>; + +const resticPassword = `restore-route-${crypto.randomBytes(16).toString("hex")}`; +const snapshotTime = new Date("2025-01-01T00:00:00.000Z"); +const oldTime = new Date("2024-01-01T00:00:00.000Z"); +const newerTime = new Date("2026-01-01T00:00:00.000Z"); + +const allFiles = [ + "top.txt", + "folder-a/a.txt", + "folder-a/deep/deep.txt", + "folder-b/b.txt", + "folder-c/keep.txt", +] as const; + +const snapshotContent = (relativePath: string) => `snapshot:${relativePath}\n`; +const currentContent = (caseId: string, relativePath: string) => `current:${caseId}:${relativePath}\n`; + +beforeAll(async () => { + const database = await import("~/server/db/db"); + await database.runDbMigrations(); + + db = database.db; + ({ cryptoUtils } = await import("~/server/utils/crypto")); + + const { createApp } = await import("~/server/app"); + const { createTestSession } = await import("~/test/helpers/auth"); + app = createApp(); + session = await createTestSession(); + + await db + .update(organization) + .set({ metadata: { resticPassword: await cryptoUtils.sealSecret(resticPassword) } }) + .where(eq(organization.id, session.organizationId)); +}); + +const writeFile = async (filePath: string, content: string, mtime = snapshotTime) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + await fs.utimes(filePath, mtime, mtime); +}; + +const createFixture = async (sourceRoot: string) => { + for (const relativePath of allFiles) { + await writeFile(path.join(sourceRoot, relativePath), snapshotContent(relativePath)); + } +}; + +const directoryExists = async (directory: string) => { + try { + await fs.stat(directory); + return true; + } catch (error) { + if ((error as { code?: string }).code === "ENOENT") { + return false; + } + + throw error; + } +}; + +const removeDirectory = async (directory: string) => { + const deadline = Date.now() + 40_000; + let missingSince: number | null = null; + + while (Date.now() < deadline) { + if (await directoryExists(directory)) { + missingSince = null; + await fs.rm(directory, { recursive: true, force: true }); + } else { + missingSince ??= Date.now(); + if (Date.now() - missingSince >= 2_000) { + return; + } + } + + await delay(100); + } + + throw new Error(`Failed to remove ${directory}`); +}; + +const selections: SelectionScenario[] = [ + { + id: "restore-all", + name: "restore all", + include: () => undefined, + selectedFiles: Object.fromEntries(allFiles.map((relativePath) => [relativePath, relativePath])), + conflicts: { + "top.txt": "old", + "folder-a/a.txt": "newer", + }, + missing: ["folder-b/b.txt"], + }, + { + id: "single-file", + name: "single selected file", + include: (sourceRoot) => [path.join(sourceRoot, "folder-a/deep/deep.txt")], + selectedItemKind: "file", + selectedFiles: { + "folder-a/deep/deep.txt": "deep.txt", + }, + conflicts: { + "folder-a/deep/deep.txt": "old", + }, + missing: [], + }, + { + id: "single-dir", + name: "single selected directory", + include: (sourceRoot) => [path.join(sourceRoot, "folder-a")], + selectedItemKind: "dir", + selectedFiles: { + "folder-a/a.txt": "a.txt", + "folder-a/deep/deep.txt": "deep/deep.txt", + }, + conflicts: { + "folder-a/a.txt": "old", + "folder-a/deep/deep.txt": "newer", + }, + missing: [], + }, + { + id: "multi-depth", + name: "multiple selected items at different depths", + include: (sourceRoot) => [ + path.join(sourceRoot, "top.txt"), + path.join(sourceRoot, "folder-a/deep/deep.txt"), + path.join(sourceRoot, "folder-b"), + ], + selectedFiles: { + "top.txt": "top.txt", + "folder-a/deep/deep.txt": "folder-a/deep/deep.txt", + "folder-b/b.txt": "folder-b/b.txt", + }, + conflicts: { + "top.txt": "old", + "folder-b/b.txt": "newer", + }, + missing: ["folder-a/deep/deep.txt"], + }, +]; + +const overwriteModes = [ + OVERWRITE_MODES.always, + OVERWRITE_MODES.ifChanged, + OVERWRITE_MODES.ifNewer, + OVERWRITE_MODES.never, +]; + +const restoreCases: RestoreMatrixCase[] = selections.flatMap((selection) => + (["original", "custom"] as const).flatMap((location) => + overwriteModes.map((overwrite) => ({ + name: `${selection.name} to ${location} location with ${overwrite} overwrite`, + selection, + location, + overwrite, + })), + ), +); + +const prepareDestination = async (testCase: RestoreMatrixCase, sourceRoot: string, restoreTarget: string) => { + const destinationRoot = testCase.location === "original" ? sourceRoot : restoreTarget; + + if (testCase.location === "custom") { + await fs.mkdir(restoreTarget, { recursive: true }); + } + + for (const [sourceRelativePath, destinationRelativePath] of Object.entries(testCase.selection.selectedFiles)) { + const destinationPath = + testCase.location === "original" + ? path.join(sourceRoot, sourceRelativePath) + : path.join(destinationRoot, destinationRelativePath); + const conflictAge = testCase.selection.conflicts[sourceRelativePath]; + + if (testCase.selection.missing.includes(sourceRelativePath)) { + await fs.rm(destinationPath, { force: true }); + continue; + } + + if (conflictAge) { + await writeFile( + destinationPath, + currentContent(testCase.selection.id, sourceRelativePath), + conflictAge === "old" ? oldTime : newerTime, + ); + } + } + + if (testCase.selection.id !== "restore-all") { + const unselectedPath = path.join(sourceRoot, "folder-c/keep.txt"); + await writeFile(unselectedPath, currentContent(testCase.selection.id, "folder-c/keep.txt")); + } +}; + +const expectedContent = (testCase: RestoreMatrixCase, sourceRelativePath: string) => { + const conflictAge = testCase.selection.conflicts[sourceRelativePath]; + + if (testCase.overwrite === OVERWRITE_MODES.never && conflictAge) { + return currentContent(testCase.selection.id, sourceRelativePath); + } + + if (testCase.overwrite === OVERWRITE_MODES.ifNewer && conflictAge === "newer") { + return currentContent(testCase.selection.id, sourceRelativePath); + } + + return snapshotContent(sourceRelativePath); +}; + +test.concurrent.each(restoreCases)( + "$name", + async (testCase) => { + const runId = crypto.randomUUID(); + const workspace = path.join( + INTEGRATION_RUNS_DIR, + `restore-route-${testCase.selection.id}-${testCase.location}-${testCase.overwrite}-${runId}`, + ); + const targetRoot = path.join( + process.env.HOME ?? path.dirname(INTEGRATION_RUNS_DIR), + ".zerobyte-restore-route-runs", + runId, + ); + const sourceRoot = path.join(workspace, "source"); + const restoreTarget = path.join(targetRoot, "restore"); + const repositoryConfig: RepositoryConfig = { backend: "local", path: path.join(workspace, "repo") }; + const restic = createIntegrationRestic(workspace, resticPassword); + + let passed = false; + + try { + await fs.mkdir(workspace, { recursive: true }); + await createFixture(sourceRoot); + + const initResult = await Effect.runPromise( + restic.init(repositoryConfig, { + organizationId: session.organizationId, + timeoutMs: 120_000, + }), + ); + expect(initResult.success).toBe(true); + expect(initResult.error).toBeNull(); + + const backupResult = await Effect.runPromise( + restic.backup(repositoryConfig, sourceRoot, { + organizationId: session.organizationId, + tags: [`restore-route-${runId}`], + }), + ); + expect(backupResult.exitCode).toBe(0); + expect(backupResult.result?.snapshot_id).toEqual(expect.any(String)); + + const snapshotId = backupResult.result?.snapshot_id; + if (!snapshotId) { + throw new Error("Restic backup completed without a snapshot id"); + } + + const repositoryShortId = generateShortId(); + const [repository] = await db + .insert(repositoriesTable) + .values({ + id: crypto.randomUUID(), + shortId: repositoryShortId, + name: `Restore route ${runId}`, + type: "local", + config: repositoryConfig, + compressionMode: "auto", + status: "healthy", + organizationId: session.organizationId, + }) + .returning(); + expect(repository).toBeDefined(); + + await prepareDestination(testCase, sourceRoot, restoreTarget); + + const response = await app.request(`/api/v1/repositories/${repositoryShortId}/restore`, { + method: "POST", + headers: { + ...session.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + snapshotId, + targetPath: testCase.location === "custom" ? restoreTarget : undefined, + overwrite: testCase.overwrite, + include: testCase.selection.include(sourceRoot), + selectedItemKind: testCase.selection.selectedItemKind, + }), + }); + + expect(response.status).toBe(202); + const body = (await response.json()) as { restoreId: string; status: "started" }; + expect(body.status).toBe("started"); + + await waitForExpect(async () => { + const [task] = await db.select().from(tasksTable).where(eq(tasksTable.id, body.restoreId)).limit(1); + expect(task?.status).toBe("succeeded"); + }, 30_000); + + for (const [sourceRelativePath, destinationRelativePath] of Object.entries( + testCase.selection.selectedFiles, + )) { + const restoredPath = + testCase.location === "original" + ? path.join(sourceRoot, sourceRelativePath) + : path.join(restoreTarget, destinationRelativePath); + await expect(fs.readFile(restoredPath, "utf8")).resolves.toBe( + expectedContent(testCase, sourceRelativePath), + ); + } + + if (testCase.selection.id !== "restore-all") { + await expect(fs.readFile(path.join(sourceRoot, "folder-c/keep.txt"), "utf8")).resolves.toBe( + currentContent(testCase.selection.id, "folder-c/keep.txt"), + ); + if (testCase.location === "custom") { + await expect(fs.stat(path.join(restoreTarget, "folder-c/keep.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } + } + + passed = true; + } finally { + if (passed) { + await removeDirectory(workspace); + if (testCase.location === "custom") { + await removeDirectory(targetRoot); + await fs.rmdir(path.dirname(targetRoot)).catch(() => undefined); + } + } else { + console.error(`Restore route integration artifacts retained in ${workspace} and ${targetRoot}`); + } + } + }, + 90_000, +);