Archived
128 lines
5.9 KiB
TypeScript
128 lines
5.9 KiB
TypeScript
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { moveWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
|
|
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
|
|
|
|
afterEach(async () => {
|
|
await cleanupTempWorkspaces();
|
|
});
|
|
|
|
describe("moveWorkspaceFile", () => {
|
|
it("moves a file to a new path", async () => {
|
|
const root = await createTempWorkspace();
|
|
await writeFile(join(root, "original.txt"), "content");
|
|
|
|
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
|
|
|
|
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
|
|
expect(result.size).toBe(7);
|
|
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
|
|
// Source should no longer exist
|
|
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
|
|
// Target should exist
|
|
const target = await readWorkspaceFile(root, "moved.txt");
|
|
expect(target.content).toBe("content");
|
|
});
|
|
|
|
it("creates intermediate directories by default", async () => {
|
|
const root = await createTempWorkspace();
|
|
await writeFile(join(root, "file.txt"), "data");
|
|
|
|
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
|
|
|
|
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
|
|
expect(target.content).toBe("data");
|
|
});
|
|
|
|
it("fails when createDirs is false and parent directory does not exist", async () => {
|
|
const root = await createTempWorkspace();
|
|
await writeFile(join(root, "file.txt"), "data");
|
|
|
|
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
|
|
const source = await readWorkspaceFile(root, "file.txt");
|
|
expect(source.content).toBe("data");
|
|
});
|
|
|
|
it("overwrites target when overwrite is true", async () => {
|
|
const root = await createTempWorkspace();
|
|
await writeFile(join(root, "source.txt"), "source content");
|
|
await writeFile(join(root, "target.txt"), "target content");
|
|
|
|
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
|
|
|
|
expect(result.toPath).toBe("target.txt");
|
|
const target = await readWorkspaceFile(root, "target.txt");
|
|
expect(target.content).toBe("source content");
|
|
});
|
|
|
|
it("throws when target exists and overwrite is false (default)", async () => {
|
|
const root = await createTempWorkspace();
|
|
await writeFile(join(root, "source.txt"), "source");
|
|
await writeFile(join(root, "target.txt"), "target");
|
|
|
|
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
|
|
// Source and target should remain unchanged
|
|
const source = await readWorkspaceFile(root, "source.txt");
|
|
expect(source.content).toBe("source");
|
|
const target = await readWorkspaceFile(root, "target.txt");
|
|
expect(target.content).toBe("target");
|
|
});
|
|
|
|
it("rejects source path traversal", async () => {
|
|
const root = await createTempWorkspace();
|
|
|
|
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
|
|
});
|
|
|
|
it("rejects target path traversal", async () => {
|
|
const root = await createTempWorkspace();
|
|
await writeFile(join(root, "source.txt"), "data");
|
|
|
|
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
|
const source = await readWorkspaceFile(root, "source.txt");
|
|
expect(source.content).toBe("data");
|
|
});
|
|
|
|
it("rejects moving a directory", async () => {
|
|
const root = await createTempWorkspace();
|
|
await mkdir(join(root, "mydir"), { recursive: true });
|
|
|
|
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
|
|
});
|
|
|
|
it("rejects missing fromPath or toPath", async () => {
|
|
const root = await createTempWorkspace();
|
|
|
|
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
|
|
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
|
|
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
|
|
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
|
|
});
|
|
|
|
it("prevents moving through symlinks that escape the workspace", async () => {
|
|
const root = await createTempWorkspace();
|
|
await mkdir(join(root, "subdir"), { recursive: true });
|
|
await writeFile(join(root, "subdir", "file.txt"), "data");
|
|
// Create a symlink inside the workspace that points outside
|
|
const outsideDir = await createTempWorkspace("pi-web-move-outside-");
|
|
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
|
|
|
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace");
|
|
const source = await readWorkspaceFile(root, "subdir/file.txt");
|
|
expect(source.content).toBe("data");
|
|
await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("prevents moving a source symlink that escapes the workspace", async () => {
|
|
const root = await createTempWorkspace();
|
|
const outsideDir = await createTempWorkspace("pi-web-move-source-outside-");
|
|
await writeFile(join(outsideDir, "secret.txt"), "secret");
|
|
await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt"));
|
|
|
|
await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace");
|
|
await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist");
|
|
await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret");
|
|
});
|
|
});
|