openclaw/apps/web/app/api/workspace/write-binary.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

196 lines
6.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("node:fs", () => ({
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("@/lib/workspace", () => ({
resolveFilesystemPath: vi.fn(),
isProtectedSystemPath: vi.fn(() => false),
}));
describe("POST /api/workspace/write-binary", () => {
beforeEach(() => {
vi.resetModules();
vi.mock("node:fs", () => ({
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("@/lib/workspace", () => ({
resolveFilesystemPath: vi.fn(),
isProtectedSystemPath: vi.fn(() => false),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns 400 when form data parsing fails (rejects malformed multipart payload)", async () => {
const { POST } = await import("./write-binary/route.js");
const req = {
formData: vi.fn().mockRejectedValue(new Error("invalid")),
} as unknown as Request;
const res = await POST(req);
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error).toContain("Invalid form data");
});
it("returns 400 when path is missing (prevents ambiguous write target)", async () => {
const { POST } = await import("./write-binary/route.js");
const form = new FormData();
form.append("file", new Blob([new Uint8Array([1, 2, 3])]));
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error).toContain("Missing 'path'");
});
it("returns 400 when file blob is missing (rejects partial payloads)", async () => {
const { POST } = await import("./write-binary/route.js");
const form = new FormData();
form.append("path", "docs/report.docx");
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error).toContain("Missing 'file'");
});
it("returns 403 when writing a system file (prevents protected-file tampering)", async () => {
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
absolutePath: "/ws/workspace.duckdb",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "workspace.duckdb",
});
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
const { POST } = await import("./write-binary/route.js");
const form = new FormData();
form.append("path", "workspace.duckdb");
form.append("file", new Blob([new Uint8Array([1])]));
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(403);
});
it("returns 400 when path resolution fails (blocks path traversal attacks)", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null);
const { POST } = await import("./write-binary/route.js");
const form = new FormData();
form.append("path", "../../etc/passwd");
form.append("file", new Blob([new Uint8Array([1])]));
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error).toContain("traversal");
});
it("writes binary bytes exactly to the resolved destination", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
absolutePath: "/ws/docs/report.docx",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "docs/report.docx",
});
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
const { POST } = await import("./write-binary/route.js");
const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xff]);
const form = new FormData();
form.append("path", "docs/report.docx");
form.append("file", new Blob([bytes]));
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(200);
expect(mockMkdir).toHaveBeenCalledWith("/ws/docs", { recursive: true });
expect(mockWrite).toHaveBeenCalledWith(
"/ws/docs/report.docx",
expect.any(Buffer),
);
const written = vi.mocked(mockWrite).mock.calls[0]?.[1] as Buffer;
expect(written[0]).toBe(0x50);
expect(written[1]).toBe(0x4b);
expect(written[2]).toBe(0x03);
expect(written[3]).toBe(0x04);
expect(written[4]).toBe(0xff);
});
it("writes absolute browse-mode DOCX paths outside the workspace", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
absolutePath: "/tmp/report.docx",
kind: "absolute",
withinWorkspace: false,
workspaceRelativePath: null,
});
const { writeFileSync: mockWrite } = await import("node:fs");
const { POST } = await import("./write-binary/route.js");
const form = new FormData();
form.append("path", "/tmp/report.docx");
form.append("file", new Blob([new Uint8Array([1, 2, 3])]));
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(200);
expect(mockWrite).toHaveBeenCalledWith("/tmp/report.docx", expect.any(Buffer));
});
it("returns 500 when disk write throws (surfaces actionable failure)", async () => {
const { resolveFilesystemPath } = await import("@/lib/workspace");
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
absolutePath: "/ws/docs/report.docx",
kind: "workspaceRelative",
withinWorkspace: true,
workspaceRelativePath: "docs/report.docx",
});
const { writeFileSync: mockWrite } = await import("node:fs");
vi.mocked(mockWrite).mockImplementationOnce(() => {
throw new Error("ENOSPC: no space left on device");
});
const { POST } = await import("./write-binary/route.js");
const form = new FormData();
form.append("path", "docs/report.docx");
form.append("file", new Blob([new Uint8Array([1, 2, 3])]));
const req = new Request("http://localhost/api/workspace/write-binary", {
method: "POST",
body: form,
});
const res = await POST(req);
expect(res.status).toBe(500);
const json = await res.json();
expect(json.error).toContain("ENOSPC");
});
});