openclaw/apps/web/app/api/workspace/file-ops.test.ts
kumarabhirup 3fe5a91033
refactor(api): migrate workspace routes to resolveFilesystemPath
All workspace API routes use resolveFilesystemPath and isProtectedSystemPath, enabling browse-mode writes to external paths.
2026-03-15 04:17:11 -07:00

360 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock node:fs
vi.mock("node:fs", () => ({
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
statSync: vi.fn(() => ({ isDirectory: () => false })),
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
renameSync: vi.fn(),
cpSync: vi.fn(),
copyFileSync: vi.fn(),
}));
// Mock workspace utilities
vi.mock("@/lib/workspace", () => ({
readWorkspaceFile: vi.fn(),
safeResolvePath: vi.fn(),
resolveFilesystemPath: vi.fn(),
isProtectedSystemPath: vi.fn(() => false),
resolveWorkspaceRoot: vi.fn(() => "/ws"),
}));
describe("Workspace File Operations API", () => {
beforeEach(() => {
vi.resetModules();
vi.mock("node:fs", () => ({
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
statSync: vi.fn(() => ({ isDirectory: () => false })),
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
renameSync: vi.fn(),
cpSync: vi.fn(),
copyFileSync: vi.fn(),
}));
vi.mock("@/lib/workspace", () => ({
readWorkspaceFile: vi.fn(),
safeResolvePath: vi.fn(),
resolveFilesystemPath: vi.fn(),
isProtectedSystemPath: vi.fn(() => false),
resolveWorkspaceRoot: vi.fn(() => "/ws"),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
// ─── GET /api/workspace/file ────────────────────────────────────
describe("GET /api/workspace/file", () => {
it("returns 400 when path param is missing", async () => {
const { GET } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file");
const res = await GET(req);
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error).toContain("path");
});
it("returns file content when found", async () => {
const { readWorkspaceFile } = await import("@/lib/workspace");
vi.mocked(readWorkspaceFile).mockReturnValue({ content: "# Hello", type: "markdown" });
const { GET } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file?path=doc.md");
const res = await GET(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.content).toBe("# Hello");
expect(json.type).toBe("markdown");
});
it("returns 404 when file not found", async () => {
const { readWorkspaceFile } = await import("@/lib/workspace");
vi.mocked(readWorkspaceFile).mockReturnValue(null);
const { GET } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file?path=missing.md");
const res = await GET(req);
expect(res.status).toBe(404);
});
});
// ─── POST /api/workspace/file ───────────────────────────────────
describe("POST /api/workspace/file", () => {
it("writes file content successfully", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/ws/doc.md",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "doc.md",
});
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "doc.md", content: "# Hello" }),
});
const res = await POST(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(mockMkdir).toHaveBeenCalled();
expect(mockWrite).toHaveBeenCalled();
});
it("writes absolute browse-mode paths outside the workspace", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/tmp/doc.md",
kind: "absolute",
withinWorkspace: false,
workspaceRelativePath: null,
});
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "/tmp/doc.md", content: "# Hello from browse mode" }),
});
const res = await POST(req);
expect(res.status).toBe(200);
expect(mockMkdir).toHaveBeenCalled();
expect(mockWrite).toHaveBeenCalledWith("/tmp/doc.md", "# Hello from browse mode", "utf-8");
});
it("returns 403 when attempting to modify a system file", async () => {
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/ws/IDENTITY.md",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "IDENTITY.md",
});
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "IDENTITY.md", content: "# override" }),
});
const res = await POST(req);
expect(res.status).toBe(403);
});
it("returns 400 for missing path", async () => {
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "text" }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 400 for missing content", async () => {
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "doc.md" }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 400 for path traversal", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue(null);
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "../etc/passwd", content: "hack" }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 400 for invalid JSON body", async () => {
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 500 on write error", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/ws/doc.md",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "doc.md",
});
const { writeFileSync: mockWrite } = await import("node:fs");
vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); });
const { POST } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "doc.md", content: "text" }),
});
const res = await POST(req);
expect(res.status).toBe(500);
});
});
// ─── DELETE /api/workspace/file ─────────────────────────────────
describe("DELETE /api/workspace/file", () => {
it("deletes file successfully", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/ws/file.txt",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "file.txt",
});
const { DELETE } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "file.txt" }),
});
const res = await DELETE(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
});
it("returns 403 for system file", async () => {
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/ws/.object.yaml",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: ".object.yaml",
});
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
const { DELETE } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: ".object.yaml" }),
});
const res = await DELETE(req);
expect(res.status).toBe(403);
});
it("returns 404 when file not found", async () => {
const { resolveFilesystemPath, safeResolvePath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue(null);
vi.mocked(safeResolvePath).mockReturnValue(null);
const { DELETE } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "nonexistent.txt" }),
});
const res = await DELETE(req);
expect(res.status).toBe(404);
});
it("returns 400 for missing path", async () => {
const { DELETE } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const res = await DELETE(req);
expect(res.status).toBe(400);
});
it("returns 400 for invalid JSON body", async () => {
const { DELETE } = await import("./file/route.js");
const req = new Request("http://localhost/api/workspace/file", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: "not json",
});
const res = await DELETE(req);
expect(res.status).toBe(400);
});
});
// ─── POST /api/workspace/mkdir ──────────────────────────────────
describe("POST /api/workspace/mkdir", () => {
it("creates directory successfully", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue({
absolutePath: "/ws/new-folder",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "new-folder",
});
const { POST } = await import("./mkdir/route.js");
const req = new Request("http://localhost/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "new-folder" }),
});
const res = await POST(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
});
it("returns 400 for missing path", async () => {
const { POST } = await import("./mkdir/route.js");
const req = new Request("http://localhost/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 400 for traversal attempt", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValue(null);
const { POST } = await import("./mkdir/route.js");
const req = new Request("http://localhost/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "../../etc" }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
});