Browse Source
- 14 tests for /api/open-file: localhost guard, input validation, path restriction, file validation, platform commands - 13 tests for getExtensionFromUrl: 3D extensions, media extensions, query strings, fragments, edge cases, zip exclusion - 3 tests for fal.ai 3D response formats: model_glb.url, model_urls.glb.url, enhanced error log with Result keys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
3 changed files with 491 additions and 1 deletions
@ -0,0 +1,276 @@ |
|||||
|
// @vitest-environment node
|
||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"; |
||||
|
import { NextRequest } from "next/server"; |
||||
|
|
||||
|
// Use vi.hoisted so mock fns are available during vi.mock() hoisting
|
||||
|
const { mockExecFileAsync, mockStat, mockPlatform, mockHomedir } = vi.hoisted(() => ({ |
||||
|
mockExecFileAsync: vi.fn(), |
||||
|
mockStat: vi.fn(), |
||||
|
mockPlatform: vi.fn(), |
||||
|
mockHomedir: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
vi.mock(import("child_process"), async (importOriginal) => { |
||||
|
const actual = await importOriginal(); |
||||
|
return { |
||||
|
...actual, |
||||
|
execFile: vi.fn(), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
vi.mock(import("util"), async (importOriginal) => { |
||||
|
const actual = await importOriginal(); |
||||
|
return { |
||||
|
...actual, |
||||
|
promisify: () => mockExecFileAsync, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
vi.mock(import("fs/promises"), async (importOriginal) => { |
||||
|
const actual = await importOriginal(); |
||||
|
return { |
||||
|
...actual, |
||||
|
stat: (...args: unknown[]) => mockStat(...args), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
vi.mock(import("os"), async (importOriginal) => { |
||||
|
const actual = await importOriginal(); |
||||
|
return { |
||||
|
...actual, |
||||
|
default: { |
||||
|
...actual, |
||||
|
platform: () => mockPlatform(), |
||||
|
homedir: () => mockHomedir(), |
||||
|
}, |
||||
|
platform: () => mockPlatform(), |
||||
|
homedir: () => mockHomedir(), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
import { POST } from "../route"; |
||||
|
|
||||
|
// Helper to create mock NextRequest
|
||||
|
function createMockRequest( |
||||
|
body: unknown, |
||||
|
headers?: Record<string, string> |
||||
|
): NextRequest { |
||||
|
return { |
||||
|
json: vi.fn().mockResolvedValue(body), |
||||
|
headers: new Headers({ host: "localhost:3000", ...headers }), |
||||
|
} as unknown as NextRequest; |
||||
|
} |
||||
|
|
||||
|
describe("/api/open-file route", () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
mockPlatform.mockReturnValue("darwin"); |
||||
|
mockHomedir.mockReturnValue("/Users/testuser"); |
||||
|
}); |
||||
|
|
||||
|
describe("localhost guard", () => { |
||||
|
it("should return 403 for non-localhost x-forwarded-for", async () => { |
||||
|
const request = createMockRequest( |
||||
|
{ filePath: "/Users/testuser/file.glb" }, |
||||
|
{ "x-forwarded-for": "203.0.113.50", host: "localhost:3000" } |
||||
|
); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(403); |
||||
|
expect(data.error).toBe("Forbidden: localhost only"); |
||||
|
}); |
||||
|
|
||||
|
it("should return 403 for non-localhost host header", async () => { |
||||
|
const request = createMockRequest( |
||||
|
{ filePath: "/Users/testuser/file.glb" }, |
||||
|
{ host: "example.com" } |
||||
|
); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(403); |
||||
|
expect(data.error).toBe("Forbidden: localhost only"); |
||||
|
}); |
||||
|
|
||||
|
it("should allow requests from 127.0.0.1 x-forwarded-for", async () => { |
||||
|
mockStat.mockResolvedValue({ isFile: () => true }); |
||||
|
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); |
||||
|
|
||||
|
const request = createMockRequest( |
||||
|
{ filePath: "/Users/testuser/file.glb" }, |
||||
|
{ "x-forwarded-for": "127.0.0.1", host: "localhost:3000" } |
||||
|
); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(200); |
||||
|
expect(data.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("should allow requests from ::1 x-forwarded-for", async () => { |
||||
|
mockStat.mockResolvedValue({ isFile: () => true }); |
||||
|
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); |
||||
|
|
||||
|
const request = createMockRequest( |
||||
|
{ filePath: "/Users/testuser/file.glb" }, |
||||
|
{ "x-forwarded-for": "::1", host: "localhost:3000" } |
||||
|
); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(200); |
||||
|
expect(data.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("should allow requests with localhost host header", async () => { |
||||
|
mockStat.mockResolvedValue({ isFile: () => true }); |
||||
|
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); |
||||
|
|
||||
|
const request = createMockRequest( |
||||
|
{ filePath: "/Users/testuser/file.glb" }, |
||||
|
{ host: "localhost:3000" } |
||||
|
); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(200); |
||||
|
expect(data.success).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("input validation", () => { |
||||
|
it("should return 400 for missing filePath", async () => { |
||||
|
const request = createMockRequest({}); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(400); |
||||
|
expect(data.error).toBe("File path is required"); |
||||
|
}); |
||||
|
|
||||
|
it("should return 400 for empty filePath", async () => { |
||||
|
const request = createMockRequest({ filePath: "" }); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(400); |
||||
|
expect(data.error).toBe("File path is required"); |
||||
|
}); |
||||
|
|
||||
|
it("should return 400 for non-string filePath", async () => { |
||||
|
const request = createMockRequest({ filePath: 12345 }); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(400); |
||||
|
expect(data.error).toBe("File path is required"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("path restriction", () => { |
||||
|
it("should return 403 for path outside home directory", async () => { |
||||
|
const request = createMockRequest({ filePath: "/etc/passwd" }); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(403); |
||||
|
expect(data.error).toBe("Path is outside allowed directory"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("file validation", () => { |
||||
|
it("should return 400 when path is a directory", async () => { |
||||
|
mockStat.mockResolvedValue({ isFile: () => false }); |
||||
|
|
||||
|
const request = createMockRequest({ |
||||
|
filePath: "/Users/testuser/generations", |
||||
|
}); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(400); |
||||
|
expect(data.error).toBe("Path is not a file"); |
||||
|
}); |
||||
|
|
||||
|
it("should return 400 when file does not exist", async () => { |
||||
|
mockStat.mockRejectedValue(new Error("ENOENT")); |
||||
|
|
||||
|
const request = createMockRequest({ |
||||
|
filePath: "/Users/testuser/nonexistent.glb", |
||||
|
}); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(400); |
||||
|
expect(data.error).toBe("File does not exist"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("platform commands", () => { |
||||
|
it("should call 'open -R' on macOS", async () => { |
||||
|
mockPlatform.mockReturnValue("darwin"); |
||||
|
mockStat.mockResolvedValue({ isFile: () => true }); |
||||
|
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); |
||||
|
|
||||
|
const request = createMockRequest({ |
||||
|
filePath: "/Users/testuser/generations/model.glb", |
||||
|
}); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(200); |
||||
|
expect(data.success).toBe(true); |
||||
|
expect(mockExecFileAsync).toHaveBeenCalledWith("open", [ |
||||
|
"-R", |
||||
|
"/Users/testuser/generations/model.glb", |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it("should call 'xdg-open' with parent directory on Linux", async () => { |
||||
|
mockPlatform.mockReturnValue("linux"); |
||||
|
mockStat.mockResolvedValue({ isFile: () => true }); |
||||
|
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); |
||||
|
|
||||
|
const request = createMockRequest({ |
||||
|
filePath: "/Users/testuser/generations/model.glb", |
||||
|
}); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(200); |
||||
|
expect(data.success).toBe(true); |
||||
|
expect(mockExecFileAsync).toHaveBeenCalledWith("xdg-open", [ |
||||
|
"/Users/testuser/generations", |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it("should return 500 when command execution fails", async () => { |
||||
|
mockStat.mockResolvedValue({ isFile: () => true }); |
||||
|
mockExecFileAsync.mockRejectedValue(new Error("Command not found")); |
||||
|
|
||||
|
const request = createMockRequest({ |
||||
|
filePath: "/Users/testuser/generations/model.glb", |
||||
|
}); |
||||
|
|
||||
|
const response = await POST(request); |
||||
|
const data = await response.json(); |
||||
|
|
||||
|
expect(response.status).toBe(500); |
||||
|
expect(data.error).toBe("Failed to open file location"); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue