From 9890cd9d71f8db7d2b080d169bcad36855946066 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:09:10 -0800 Subject: [PATCH] test(web): add raw-file binary write route coverage --- apps/web/app/api/workspace/raw-file.test.ts | 246 ++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 apps/web/app/api/workspace/raw-file.test.ts diff --git a/apps/web/app/api/workspace/raw-file.test.ts b/apps/web/app/api/workspace/raw-file.test.ts new file mode 100644 index 00000000000..142a3aff158 --- /dev/null +++ b/apps/web/app/api/workspace/raw-file.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.from("")), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("@/lib/workspace", () => ({ + safeResolvePath: vi.fn(), + safeResolveNewPath: vi.fn(), + resolveWorkspaceRoot: vi.fn(() => "/ws"), + isSystemFile: vi.fn(() => false), +})); + +describe("POST /api/workspace/raw-file", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.from("")), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + })); + vi.mock("@/lib/workspace", () => ({ + safeResolvePath: vi.fn(), + safeResolveNewPath: vi.fn(), + resolveWorkspaceRoot: vi.fn(() => "/ws"), + isSystemFile: vi.fn(() => false), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns 400 when path query parameter is missing (prevents blind writes)", async () => { + const { POST } = await import("./raw-file/route.js"); + const req = new Request("http://localhost/api/workspace/raw-file", { + method: "POST", + body: new ArrayBuffer(8), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 403 when path is a system file (protects workspace.duckdb, etc.)", async () => { + const { isSystemFile } = await import("@/lib/workspace"); + vi.mocked(isSystemFile).mockReturnValueOnce(true); + + const { POST } = await import("./raw-file/route.js"); + const req = new Request( + "http://localhost/api/workspace/raw-file?path=workspace.duckdb", + { method: "POST", body: new ArrayBuffer(8) }, + ); + const res = await POST(req); + expect(res.status).toBe(403); + const json = await res.json(); + expect(json.error).toContain("system file"); + }); + + it("returns 400 when safeResolveNewPath rejects the path (path traversal attack)", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValueOnce(null); + + const { POST } = await import("./raw-file/route.js"); + const req = new Request( + "http://localhost/api/workspace/raw-file?path=../../etc/passwd", + { method: "POST", body: new ArrayBuffer(8) }, + ); + const res = await POST(req); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toContain("traversal"); + }); + + it("writes binary data to the resolved path and creates parent dirs", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/data/report.xlsx"); + + const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs"); + + const { POST } = await import("./raw-file/route.js"); + const binaryData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]).buffer; + const req = new Request( + "http://localhost/api/workspace/raw-file?path=data/report.xlsx", + { method: "POST", body: binaryData }, + ); + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.path).toBe("data/report.xlsx"); + + expect(mockMkdir).toHaveBeenCalledWith("/ws/data", { recursive: true }); + expect(mockWrite).toHaveBeenCalledWith( + "/ws/data/report.xlsx", + expect.any(Buffer), + ); + }); + + it("returns 500 when writeFileSync throws (disk full, permission denied)", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/data.xlsx"); + + const { writeFileSync: mockWrite } = await import("node:fs"); + vi.mocked(mockWrite).mockImplementationOnce(() => { + throw new Error("ENOSPC: no space left on device"); + }); + + const { POST } = await import("./raw-file/route.js"); + const req = new Request( + "http://localhost/api/workspace/raw-file?path=data.xlsx", + { method: "POST", body: new ArrayBuffer(8) }, + ); + const res = await POST(req); + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.error).toContain("ENOSPC"); + }); + + it("preserves binary content exactly as received (no encoding corruption)", async () => { + const { safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/file.xlsx"); + + const { writeFileSync: mockWrite } = await import("node:fs"); + vi.mocked(mockWrite).mockClear(); + + const { POST } = await import("./raw-file/route.js"); + const bytes = new Uint8Array([0x00, 0xff, 0x80, 0x7f, 0x01]); + const req = new Request( + "http://localhost/api/workspace/raw-file?path=file.xlsx", + { method: "POST", body: bytes.buffer }, + ); + await POST(req); + + const writtenBuffer = vi.mocked(mockWrite).mock.calls[0][1] as Buffer; + expect(writtenBuffer[0]).toBe(0x00); + expect(writtenBuffer[1]).toBe(0xff); + expect(writtenBuffer[2]).toBe(0x80); + expect(writtenBuffer[3]).toBe(0x7f); + expect(writtenBuffer[4]).toBe(0x01); + }); + + it("calls isSystemFile before safeResolveNewPath (rejects early, prevents resolve overhead)", async () => { + const { isSystemFile, safeResolveNewPath } = await import("@/lib/workspace"); + vi.mocked(isSystemFile).mockClear(); + vi.mocked(safeResolveNewPath).mockClear(); + vi.mocked(isSystemFile).mockReturnValueOnce(true); + + const { POST } = await import("./raw-file/route.js"); + const req = new Request( + "http://localhost/api/workspace/raw-file?path=workspace.duckdb", + { method: "POST", body: new ArrayBuffer(1) }, + ); + await POST(req); + + expect(isSystemFile).toHaveBeenCalledWith("workspace.duckdb"); + expect(safeResolveNewPath).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/workspace/raw-file", () => { + beforeEach(() => { + vi.resetModules(); + vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.from("")), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + })); + vi.mock("@/lib/workspace", () => ({ + safeResolvePath: vi.fn(), + safeResolveNewPath: vi.fn(), + resolveWorkspaceRoot: vi.fn(() => "/ws"), + isSystemFile: vi.fn(() => false), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns DOCX MIME type for .docx files (required for browser/editor interoperability)", async () => { + const { safeResolvePath } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/docs/spec.docx"); + const { readFileSync } = await import("node:fs"); + vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from([0x50, 0x4b])); + + const { GET } = await import("./raw-file/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/raw-file?path=docs/spec.docx"), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + }); + + it("returns text/plain for .txt files (ensures plain-text previews render correctly)", async () => { + const { safeResolvePath } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/notes/today.txt"); + const { readFileSync } = await import("node:fs"); + vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from("hello")); + + const { GET } = await import("./raw-file/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/raw-file?path=notes/today.txt"), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + }); + + it("falls back to octet-stream for unknown extensions (prevents incorrect sniffing assumptions)", async () => { + const { safeResolvePath } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/blob.unknown"); + const { readFileSync } = await import("node:fs"); + vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from([1, 2, 3])); + + const { GET } = await import("./raw-file/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/raw-file?path=blob.unknown"), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); + }); + + it("returns 400 when path query is missing (rejects ambiguous read requests)", async () => { + const { GET } = await import("./raw-file/route.js"); + const res = await GET(new Request("http://localhost/api/workspace/raw-file")); + expect(res.status).toBe(400); + }); + + it("returns 404 when file cannot be resolved (prevents leaking host paths)", async () => { + const { safeResolvePath, resolveWorkspaceRoot } = await import("@/lib/workspace"); + vi.mocked(safeResolvePath).mockReturnValueOnce(null); + vi.mocked(resolveWorkspaceRoot).mockReturnValueOnce(null); + + const { GET } = await import("./raw-file/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/raw-file?path=missing.docx"), + ); + expect(res.status).toBe(404); + }); +});