From e4293bbd9cc705b0d32d4bb8b1da64cd95db78bc Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 8 May 2026 08:44:26 +0200 Subject: [PATCH] test: cover workspace file services --- .../workspaces/fileContentService.test.ts | 70 ++++++++++++++++++ src/server/workspaces/fileTreeService.test.ts | 74 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/server/workspaces/fileContentService.test.ts create mode 100644 src/server/workspaces/fileTreeService.test.ts diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts new file mode 100644 index 0000000..db313b4 --- /dev/null +++ b/src/server/workspaces/fileContentService.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readWorkspaceFile } from "./fileContentService.js"; + +const roots: string[] = []; + +async function tempWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "pi-web-file-content-")); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("readWorkspaceFile", () => { + it("reads text files with normalized paths and language metadata", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n"); + + const file = await readWorkspaceFile(root, "./src//main.ts"); + + expect(file).toMatchObject({ + path: "src/main.ts", + language: "typescript", + encoding: "utf8", + content: "const answer = 42;\n", + truncated: false, + binary: false, + }); + expect(file.size).toBe(19); + expect(Date.parse(file.modifiedAt)).not.toBeNaN(); + }); + + it("rejects missing paths, directories, traversal, and absolute paths", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "dir")); + + await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); + await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file"); + await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("detects binary files and omits binary content", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f])); + + const file = await readWorkspaceFile(root, "image.bin"); + + expect(file).toMatchObject({ content: "", binary: true, truncated: false }); + expect(file.size).toBe(4); + }); + + it("truncates large text files", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7)); + + const file = await readWorkspaceFile(root, "large.md"); + + expect(file.language).toBe("markdown"); + expect(file.content).toHaveLength(512 * 1024); + expect(file.truncated).toBe(true); + expect(file.binary).toBe(false); + }); +}); diff --git a/src/server/workspaces/fileTreeService.test.ts b/src/server/workspaces/fileTreeService.test.ts new file mode 100644 index 0000000..2a7a82f --- /dev/null +++ b/src/server/workspaces/fileTreeService.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listWorkspaceTree } from "./fileTreeService.js"; + +const roots: string[] = []; + +async function tempWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "pi-web-file-tree-")); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("listWorkspaceTree", () => { + it("lists visible entries with directories first, sorted by name", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "z-dir")); + await mkdir(join(root, "a-dir")); + await mkdir(join(root, ".git")); + await mkdir(join(root, "node_modules")); + await writeFile(join(root, "b.txt"), "b"); + await writeFile(join(root, "a.txt"), "a"); + await symlink(join(root, "a.txt"), join(root, "link.txt")); + + const tree = await listWorkspaceTree(root, undefined); + + expect(tree.path).toBe(""); + expect(tree.truncated).toBe(false); + expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([ + ["a-dir", "directory"], + ["z-dir", "directory"], + ["a.txt", "file"], + ["b.txt", "file"], + ["link.txt", "symlink"], + ]); + expect(Date.parse(tree.scannedAt)).not.toBeNaN(); + }); + + it("lists nested directories using normalized relative paths", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "src", "client"), { recursive: true }); + await writeFile(join(root, "src", "client", "main.ts"), ""); + + const tree = await listWorkspaceTree(root, "./src//client"); + + expect(tree.path).toBe("src/client"); + expect(tree.entries).toHaveLength(1); + expect(tree.entries[0]).toMatchObject({ name: "main.ts", path: "src/client/main.ts", type: "file" }); + }); + + it("rejects non-directory targets and unsafe paths", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "file.txt"), "content"); + + await expect(listWorkspaceTree(root, "file.txt")).rejects.toThrow("Path is not a directory"); + await expect(listWorkspaceTree(root, "../outside")).rejects.toThrow("Path traversal is not allowed"); + await expect(listWorkspaceTree(root, "/tmp")).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("marks responses as truncated after the service entry limit", async () => { + const root = await tempWorkspace(); + await Promise.all(Array.from({ length: 1001 }, (_, index) => writeFile(join(root, `${String(index).padStart(4, "0")}.txt`), ""))); + + const tree = await listWorkspaceTree(root, undefined); + + expect(tree.entries).toHaveLength(1000); + expect(tree.truncated).toBe(true); + }); +});