import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let mockDataRoot = ""; vi.mock("@/lib/server/modules/files/path-resolver", () => { class FilesPathError extends Error { code: string; statusCode: number; constructor( message: string, options?: { code?: string; statusCode?: number; }, ) { super(message); this.code = options?.code ?? "internal_error"; this.statusCode = options?.statusCode ?? 500; } } function ensureWithinRoot(rootPath: string, absolutePath: string) { const relative = path.relative(rootPath, absolutePath); const within = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); if (!within) { throw new FilesPathError("Path escapes root", { code: "path_outside_root", statusCode: 400, }); } } return { FilesPathError, resolvePathWithinFilesRoot: vi.fn(async (input: { inputPath?: string; allowHiddenSegments?: boolean; requiredPrefix?: string; allowMissingLeaf?: boolean; }) => { const cleaned = (input.inputPath ?? "").trim().replaceAll("\\", "/"); const normalized = cleaned ? path.posix.normalize(cleaned) : ""; if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { throw new FilesPathError("Invalid path", { code: "invalid_path", statusCode: 400, }); } const relativePath = normalized; const segments = relativePath.split("/"); if (!input.allowHiddenSegments && segments.some((segment) => segment.startsWith("."))) { throw new FilesPathError("Hidden files are not allowed", { code: "hidden_blocked", statusCode: 403, }); } if ( input.requiredPrefix && relativePath !== input.requiredPrefix && !relativePath.startsWith(`${input.requiredPrefix}/`) ) { throw new FilesPathError("Invalid path", { code: "invalid_path", statusCode: 400, }); } let current = mockDataRoot; for (const segment of segments) { current = path.join(current, segment); try { const info = await lstat(current); if (info.isSymbolicLink()) { throw new FilesPathError("Symlinks are not allowed", { code: "symlink_blocked", statusCode: 403, }); } } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError?.code === "ENOENT") { if (input.allowMissingLeaf) break; throw new FilesPathError("File or directory not found", { code: "not_found", statusCode: 404, }); } throw error; } } const absolutePath = path.resolve(mockDataRoot, relativePath); ensureWithinRoot(mockDataRoot, absolutePath); return { rootPath: mockDataRoot, relativePath, absolutePath, segments, exists: true, }; }), }; }); vi.mock("@/lib/server/modules/files/trash-repository", () => ({ upsertTrashEntryInDb: vi.fn(), getTrashEntryFromDb: vi.fn(), deleteTrashEntryFromDb: vi.fn(), })); import { deleteFromTrash, moveToTrash, restoreFromTrash, type TrashServiceError, } from "@/lib/server/modules/files/trash-service"; import { resolvePathWithinFilesRoot } from "@/lib/server/modules/files/path-resolver"; import { deleteTrashEntryFromDb, getTrashEntryFromDb, upsertTrashEntryInDb, } from "@/lib/server/modules/files/trash-repository"; describe("trash service", () => { beforeEach(async () => { mockDataRoot = await mkdtemp(path.join(os.tmpdir(), "home-server-trash-")); await mkdir(path.join(mockDataRoot, "Trash"), { recursive: true }); await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true }); }); afterEach(async () => { if (mockDataRoot) { await rm(mockDataRoot, { recursive: true, force: true }); } vi.clearAllMocks(); }); it("moves files to Trash and stores metadata", async () => { await writeFile(path.join(mockDataRoot, "Documents", "notes.txt"), "hello", "utf8"); const moved = await moveToTrash("Documents/notes.txt"); expect(moved.trashPath.startsWith("Trash/")).toBe(true); expect(moved.originalPath).toBe("Documents/notes.txt"); expect( await readFile(path.join(mockDataRoot, moved.trashPath), "utf8"), ).toBe("hello"); expect(vi.mocked(upsertTrashEntryInDb)).toHaveBeenCalledWith( expect.objectContaining({ trashPath: moved.trashPath, originalPath: "Documents/notes.txt", }), ); }); it("restores using keep-both collision policy", async () => { await writeFile(path.join(mockDataRoot, "Trash", "notes.txt"), "from-trash", "utf8"); await writeFile(path.join(mockDataRoot, "Documents", "notes.txt"), "existing", "utf8"); vi.mocked(getTrashEntryFromDb).mockResolvedValueOnce({ id: "trash-1", trashPath: "Trash/notes.txt", originalPath: "Documents/notes.txt", deletedAt: new Date(), }); const restored = await restoreFromTrash("Trash/notes.txt", "keep-both"); expect(restored.sourceTrashPath).toBe("Trash/notes.txt"); expect(restored.restoredPath).toBe("Documents/notes (2).txt"); expect( await readFile(path.join(mockDataRoot, "Documents", "notes (2).txt"), "utf8"), ).toBe("from-trash"); expect(vi.mocked(deleteTrashEntryFromDb)).toHaveBeenCalledWith("Trash/notes.txt"); }); it("restores when original target no longer exists", async () => { await writeFile(path.join(mockDataRoot, "Trash", "restorable.txt"), "from-trash", "utf8"); vi.mocked(getTrashEntryFromDb).mockResolvedValueOnce({ id: "trash-restore-1", trashPath: "Trash/restorable.txt", originalPath: "Documents/restorable.txt", deletedAt: new Date(), }); const restored = await restoreFromTrash("Trash/restorable.txt", "keep-both"); expect(restored.sourceTrashPath).toBe("Trash/restorable.txt"); expect(restored.restoredPath).toBe("Documents/restorable.txt"); expect( await readFile(path.join(mockDataRoot, "Documents", "restorable.txt"), "utf8"), ).toBe("from-trash"); expect(vi.mocked(deleteTrashEntryFromDb)).toHaveBeenCalledWith("Trash/restorable.txt"); }); it("restores when source recheck transiently reports not_found", async () => { await writeFile(path.join(mockDataRoot, "Trash", "race.txt"), "from-trash", "utf8"); vi.mocked(getTrashEntryFromDb).mockResolvedValueOnce({ id: "trash-race-1", trashPath: "Trash/race.txt", originalPath: "Documents/race.txt", deletedAt: new Date(), }); const defaultResolver = vi.mocked(resolvePathWithinFilesRoot).getMockImplementation(); if (!defaultResolver) { throw new Error("Expected mocked resolvePathWithinFilesRoot implementation"); } let trashResolveCount = 0; vi.mocked(resolvePathWithinFilesRoot).mockImplementation(async (input) => { if (input.inputPath === "Trash/race.txt") { trashResolveCount += 1; if (trashResolveCount === 2) { const transientNotFound = new Error("transient not found") as Error & { code?: string; }; transientNotFound.code = "ENOENT"; throw transientNotFound; } } return defaultResolver(input); }); const restored = await restoreFromTrash("Trash/race.txt", "keep-both"); expect(restored.sourceTrashPath).toBe("Trash/race.txt"); expect(restored.restoredPath).toBe("Documents/race.txt"); expect( await readFile(path.join(mockDataRoot, "Documents", "race.txt"), "utf8"), ).toBe("from-trash"); expect(vi.mocked(deleteTrashEntryFromDb)).toHaveBeenCalledWith("Trash/race.txt"); }); it("deletes items permanently from Trash", async () => { await writeFile(path.join(mockDataRoot, "Trash", "old.log"), "bye", "utf8"); const deleted = await deleteFromTrash("Trash/old.log"); expect(deleted.deleted).toBe(true); expect(deleted.path).toBe("Trash/old.log"); expect(vi.mocked(deleteTrashEntryFromDb)).toHaveBeenCalledWith("Trash/old.log"); }); it("errors when trash metadata is missing on restore", async () => { await writeFile(path.join(mockDataRoot, "Trash", "missing.txt"), "x", "utf8"); vi.mocked(getTrashEntryFromDb).mockResolvedValueOnce(null); await expect(restoreFromTrash("Trash/missing.txt", "keep-both")).rejects.toMatchObject< Partial >({ code: "trash_meta_missing", statusCode: 404, }); }); });