homeio
PublicForked from doctor-io/homeio
service.test.ts
import { lstat, mkdtemp, mkdir, readFile, rm, symlink, 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/stars-repository", () => ({
isPathStarredInDb: vi.fn(async () => false),
listStarredPathsFromDb: vi.fn(async () => []),
setPathStarredInDb: vi.fn(async () => undefined),
}));
vi.mock("@/lib/server/modules/files/trash-repository", () => ({
listTrashEntriesFromDb: vi.fn(async () => []),
}));
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;
allowEmpty?: boolean;
allowHiddenSegments?: boolean;
allowMissingLeaf?: boolean;
requiredPrefix?: string;
}) => {
const cleaned = (input.inputPath ?? "").trim().replaceAll("\\", "/");
if (!cleaned) {
if (!input.allowEmpty) {
throw new FilesPathError("Invalid path", {
code: "invalid_path",
statusCode: 400,
});
}
}
const normalized = cleaned ? path.posix.normalize(cleaned) : "";
if (cleaned.startsWith("/") || cleaned.includes("\0")) {
throw new FilesPathError("Invalid path", {
code: "invalid_path",
statusCode: 400,
});
}
if (normalized === ".." || normalized.startsWith("../")) {
throw new FilesPathError("Path escapes root", {
code: "path_outside_root",
statusCode: 400,
});
}
const relativePath = normalized === "." ? "" : normalized;
const segments = relativePath ? relativePath.split("/") : [];
if (
input.requiredPrefix &&
relativePath !== input.requiredPrefix &&
!relativePath.startsWith(`${input.requiredPrefix}/`)
) {
throw new FilesPathError("Invalid path", {
code: "invalid_path",
statusCode: 400,
});
}
if (!input.allowHiddenSegments && segments.some((segment) => segment.startsWith("."))) {
throw new FilesPathError("Hidden files are not allowed", {
code: "hidden_blocked",
statusCode: 403,
});
}
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);
try {
await lstat(absolutePath);
return {
rootPath: mockDataRoot,
relativePath,
absolutePath,
segments,
exists: true,
};
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError?.code !== "ENOENT") throw error;
}
if (!input.allowMissingLeaf) {
throw new FilesPathError("File or directory not found", {
code: "not_found",
statusCode: 404,
});
}
return {
rootPath: mockDataRoot,
relativePath,
absolutePath,
segments,
exists: false,
};
}),
};
});
import {
createDirectoryEntry,
createFileEntry,
MAX_TEXT_READ_BYTES,
listDirectory,
pasteEntry,
readFileForViewer,
searchFiles,
writeTextFile,
} from "@/lib/server/modules/files/service";
describe("files service", () => {
beforeEach(async () => {
mockDataRoot = await mkdtemp(path.join(os.tmpdir(), "home-server-files-"));
});
afterEach(async () => {
if (mockDataRoot) {
await rm(mockDataRoot, { recursive: true, force: true });
}
});
it("lists folders first and hides hidden and symlink entries by default", async () => {
await mkdir(path.join(mockDataRoot, "ZetaFolder"), { recursive: true });
await writeFile(path.join(mockDataRoot, "alpha.txt"), "alpha", "utf8");
await writeFile(path.join(mockDataRoot, ".hidden.txt"), "hidden", "utf8");
await symlink(path.join(mockDataRoot, "alpha.txt"), path.join(mockDataRoot, "alpha-link"));
const result = await listDirectory({
path: "",
});
expect(result.root).toBe(mockDataRoot);
expect(result.cwd).toBe("");
expect(result.entries.map((entry) => entry.name)).toEqual([
"ZetaFolder",
"alpha.txt",
]);
expect(result.entries[0]?.type).toBe("folder");
expect(result.entries[1]?.type).toBe("file");
});
it("blocks traversal paths outside root", async () => {
await expect(
listDirectory({
path: "../etc",
}),
).rejects.toMatchObject({
code: "path_outside_root",
});
});
it("blocks symlink traversal", async () => {
await mkdir(path.join(mockDataRoot, "safe"), { recursive: true });
await symlink(path.join(mockDataRoot, "safe"), path.join(mockDataRoot, "safe-link"));
await expect(
listDirectory({
path: "safe-link",
}),
).rejects.toMatchObject({
code: "symlink_blocked",
});
});
it("classifies read mode for text, image, and pdf files", async () => {
await writeFile(path.join(mockDataRoot, "config.yaml"), "name: home", "utf8");
await writeFile(path.join(mockDataRoot, "photo.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
await writeFile(path.join(mockDataRoot, "manual.pdf"), Buffer.from("%PDF-1.7", "utf8"));
const textResult = await readFileForViewer({
path: "config.yaml",
});
const imageResult = await readFileForViewer({
path: "photo.png",
});
const pdfResult = await readFileForViewer({
path: "manual.pdf",
});
expect(textResult.mode).toBe("text");
expect(textResult.content).toBe("name: home");
expect(imageResult.mode).toBe("image");
expect(imageResult.content).toBeNull();
expect(pdfResult.mode).toBe("pdf");
expect(pdfResult.content).toBeNull();
});
it("returns too_large mode for oversized text files", async () => {
const largeText = "a".repeat(MAX_TEXT_READ_BYTES + 1);
await writeFile(path.join(mockDataRoot, "large.txt"), largeText, "utf8");
const result = await readFileForViewer({
path: "large.txt",
});
expect(result.mode).toBe("too_large");
expect(result.content).toBeNull();
expect(result.sizeBytes).toBe(MAX_TEXT_READ_BYTES + 1);
});
it("writes text files and enforces optimistic mtime conflict", async () => {
const filePath = path.join(mockDataRoot, "notes.txt");
await writeFile(filePath, "before", "utf8");
const opened = await readFileForViewer({
path: "notes.txt",
});
const saved = await writeTextFile({
path: "notes.txt",
content: "after",
expectedMtimeMs: opened.mtimeMs,
});
expect(saved.path).toBe("notes.txt");
expect(await readFile(filePath, "utf8")).toBe("after");
await writeFile(filePath, "changed-outside", "utf8");
await expect(
writeTextFile({
path: "notes.txt",
content: "conflict-write",
expectedMtimeMs: opened.mtimeMs,
}),
).rejects.toMatchObject({
code: "write_conflict",
});
});
it("shows hidden entries when listing Trash", async () => {
await mkdir(path.join(mockDataRoot, "Trash"), { recursive: true });
await writeFile(path.join(mockDataRoot, "Trash", ".env"), "x=1", "utf8");
await writeFile(path.join(mockDataRoot, "Trash", "notes.txt"), "hello", "utf8");
const result = await listDirectory({
path: "Trash",
});
expect(result.entries.map((entry) => entry.name)).toEqual([
".env",
"notes.txt",
]);
});
it("shows hidden entries when includeHidden is true", async () => {
await writeFile(path.join(mockDataRoot, ".env"), "x=1", "utf8");
await writeFile(path.join(mockDataRoot, "notes.txt"), "hello", "utf8");
const result = await listDirectory({
path: "",
includeHidden: true,
});
expect(result.entries.map((entry) => entry.name)).toEqual([
".env",
"notes.txt",
]);
});
it("creates folder and file entries in the requested directory", async () => {
await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true });
const createdFolder = await createDirectoryEntry({
parentPath: "Documents",
name: "Projects",
});
const createdFile = await createFileEntry({
parentPath: "Documents",
name: "todo.txt",
});
expect(createdFolder.path).toBe("Documents/Projects");
expect(createdFolder.type).toBe("folder");
expect(createdFile.path).toBe("Documents/todo.txt");
expect(createdFile.type).toBe("file");
});
it("copies entries into destination directory", async () => {
await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true });
await mkdir(path.join(mockDataRoot, "Media"), { recursive: true });
await writeFile(path.join(mockDataRoot, "Media", "photo.jpg"), "binary", "utf8");
const copied = await pasteEntry({
sourcePath: "Media/photo.jpg",
destinationPath: "Documents",
operation: "copy",
});
expect(copied.path).toBe("Documents/photo.jpg");
expect(await readFile(path.join(mockDataRoot, "Media", "photo.jpg"), "utf8")).toBe("binary");
expect(await readFile(path.join(mockDataRoot, "Documents", "photo.jpg"), "utf8")).toBe("binary");
});
it("moves entries into destination directory", async () => {
await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true });
await mkdir(path.join(mockDataRoot, "Media"), { recursive: true });
await writeFile(path.join(mockDataRoot, "Media", "move-me.txt"), "m", "utf8");
const moved = await pasteEntry({
sourcePath: "Media/move-me.txt",
destinationPath: "Documents",
operation: "move",
});
expect(moved.path).toBe("Documents/move-me.txt");
await expect(
readFile(path.join(mockDataRoot, "Media", "move-me.txt"), "utf8"),
).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(path.join(mockDataRoot, "Documents", "move-me.txt"), "utf8")).toBe("m");
});
it("rejects paste when destination exists", async () => {
await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true });
await mkdir(path.join(mockDataRoot, "Media"), { recursive: true });
await writeFile(path.join(mockDataRoot, "Media", "photo.jpg"), "src", "utf8");
await writeFile(path.join(mockDataRoot, "Documents", "photo.jpg"), "dst", "utf8");
await expect(
pasteEntry({
sourcePath: "Media/photo.jpg",
destinationPath: "Documents",
operation: "copy",
}),
).rejects.toMatchObject({
code: "destination_exists",
});
});
describe("searchFiles", () => {
it("returns matching entries by name", async () => {
await mkdir(path.join(mockDataRoot, "Documents"), { recursive: true });
await writeFile(path.join(mockDataRoot, "Documents", "notes.md"), "x");
await writeFile(path.join(mockDataRoot, "Documents", "ignore.txt"), "x");
const result = await searchFiles({ query: "notes" });
expect(result.entries.map((e) => e.name)).toEqual(["notes.md"]);
expect(result.truncated).toBeUndefined();
});
it("flags truncated when the deadline expires before walk completes", async () => {
await mkdir(path.join(mockDataRoot, "Deep"), { recursive: true });
// A few nested directories with files; the per-subdir setImmediate
// yield means a 0-ms deadline trips before we finish the walk.
for (let i = 0; i < 5; i += 1) {
const dir = path.join(mockDataRoot, "Deep", `level-${i}`);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "match-target.txt"), "x");
}
const result = await searchFiles({
query: "match-target",
timeoutMs: 1_000,
});
expect(result.entries.length).toBeGreaterThan(0);
expect(result.truncated).toBeUndefined();
const truncatedResult = await searchFiles({
query: "match-target",
timeoutMs: -1_000, // deadline already in the past — trips on first walk
});
expect(truncatedResult.truncated).toBe(true);
});
});
});