You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
8.2 KiB
276 lines
8.2 KiB
// @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");
|
|
});
|
|
});
|
|
});
|
|
|