IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

trash-service.test.ts

Open raw
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<TrashServiceError>
    >({
      code: "trash_meta_missing",
      statusCode: 404,
    });
  });
});
bytes
8,921
lines
266
Type
ts
Latest commit
16 October 2026