Browse Source
WorkflowBrowserModal tests (6): modal visibility, backdrop close, content click propagation, callback forwarding. list-workflows route tests (10): path validation (missing, traversal, dangerous), directory probing (empty, no JSON, valid workflow, non- workflow JSON, name fallback, sort order), error handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 356 additions and 0 deletions
@ -0,0 +1,241 @@ |
|||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
|||
import { NextRequest } from "next/server"; |
|||
|
|||
const mockReaddir = vi.fn(); |
|||
const mockStat = vi.fn(); |
|||
const mockOpen = vi.fn(); |
|||
|
|||
vi.mock("fs/promises", () => ({ |
|||
readdir: (...args: unknown[]) => mockReaddir(...args), |
|||
stat: (...args: unknown[]) => mockStat(...args), |
|||
open: (...args: unknown[]) => mockOpen(...args), |
|||
})); |
|||
|
|||
vi.mock("@/utils/pathValidation", () => ({ |
|||
validateWorkflowPath: (p: string) => { |
|||
if (p.includes("..")) return { valid: false, resolved: p, error: "Path contains traversal sequences" }; |
|||
if (!p.startsWith("/")) return { valid: false, resolved: p, error: "Path must be absolute" }; |
|||
if (p.startsWith("/etc")) return { valid: false, resolved: p, error: "Access to /etc is not allowed" }; |
|||
return { valid: true, resolved: p }; |
|||
}, |
|||
})); |
|||
|
|||
import { GET } from "../route"; |
|||
|
|||
function createRequest(path?: string): NextRequest { |
|||
const url = path |
|||
? `http://localhost/api/list-workflows?path=${encodeURIComponent(path)}` |
|||
: "http://localhost/api/list-workflows"; |
|||
return new NextRequest(url); |
|||
} |
|||
|
|||
function createFileHandle(content: string) { |
|||
const buf = Buffer.from(content); |
|||
return { |
|||
read: vi.fn().mockResolvedValue({ bytesRead: buf.length }), |
|||
close: vi.fn().mockResolvedValue(undefined), |
|||
// Store the buffer for inspection — the actual route allocates its own buffer
|
|||
_content: content, |
|||
}; |
|||
} |
|||
|
|||
describe("/api/list-workflows route", () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
|
|||
// Override mockOpen to simulate filling the buffer with file content
|
|||
mockOpen.mockImplementation(async (filePath: string) => { |
|||
const handle = createFileHandle(""); |
|||
// We'll configure per-test below by re-mocking
|
|||
return handle; |
|||
}); |
|||
}); |
|||
|
|||
describe("parameter validation", () => { |
|||
it("should return 400 when path parameter is missing", async () => { |
|||
const response = await GET(createRequest()); |
|||
const data = await response.json(); |
|||
|
|||
expect(response.status).toBe(400); |
|||
expect(data.success).toBe(false); |
|||
expect(data.error).toBe("Path parameter required"); |
|||
}); |
|||
|
|||
it("should return 400 for path traversal attempts", async () => { |
|||
const response = await GET(createRequest("/home/user/../etc/passwd")); |
|||
const data = await response.json(); |
|||
|
|||
expect(response.status).toBe(400); |
|||
expect(data.success).toBe(false); |
|||
expect(data.error).toContain("traversal"); |
|||
}); |
|||
|
|||
it("should return 400 for dangerous system paths", async () => { |
|||
const response = await GET(createRequest("/etc/config")); |
|||
const data = await response.json(); |
|||
|
|||
expect(response.status).toBe(400); |
|||
expect(data.success).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe("directory probing", () => { |
|||
it("should return empty array when parent has no subdirectories", async () => { |
|||
mockReaddir.mockResolvedValue([]); |
|||
|
|||
const response = await GET(createRequest("/home/user/projects")); |
|||
const data = await response.json(); |
|||
|
|||
expect(data.success).toBe(true); |
|||
expect(data.workflows).toEqual([]); |
|||
}); |
|||
|
|||
it("should skip subdirectories without JSON files", async () => { |
|||
mockReaddir.mockResolvedValueOnce([ |
|||
{ name: "empty-dir", isDirectory: () => true }, |
|||
]); |
|||
// readdir for the subdirectory itself returns no files
|
|||
mockReaddir.mockResolvedValueOnce([]); |
|||
|
|||
const response = await GET(createRequest("/home/user/projects")); |
|||
const data = await response.json(); |
|||
|
|||
expect(data.success).toBe(true); |
|||
expect(data.workflows).toEqual([]); |
|||
}); |
|||
|
|||
it("should detect a valid workflow and extract name from header", async () => { |
|||
// Parent dir has one subdirectory
|
|||
mockReaddir.mockResolvedValueOnce([ |
|||
{ name: "my-project", isDirectory: () => true }, |
|||
]); |
|||
// Subdirectory has one JSON file
|
|||
mockReaddir.mockResolvedValueOnce(["workflow.json"]); |
|||
|
|||
const workflowHeader = '{"version":1,"name":"Cool Project","nodes":['; |
|||
const headerBuf = Buffer.from(workflowHeader); |
|||
|
|||
mockOpen.mockResolvedValue({ |
|||
read: vi.fn().mockImplementation(async (buf: Buffer) => { |
|||
headerBuf.copy(buf, 0, 0, Math.min(headerBuf.length, buf.length)); |
|||
return { bytesRead: Math.min(headerBuf.length, buf.length) }; |
|||
}), |
|||
close: vi.fn().mockResolvedValue(undefined), |
|||
}); |
|||
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1700000000000 }); |
|||
|
|||
const response = await GET(createRequest("/home/user/projects")); |
|||
const data = await response.json(); |
|||
|
|||
expect(data.success).toBe(true); |
|||
expect(data.workflows).toHaveLength(1); |
|||
expect(data.workflows[0].name).toBe("Cool Project"); |
|||
expect(data.workflows[0].directoryPath).toBe("/home/user/projects/my-project"); |
|||
expect(data.workflows[0].lastModified).toBe(1700000000000); |
|||
}); |
|||
|
|||
it("should skip JSON files that are not workflow files", async () => { |
|||
mockReaddir.mockResolvedValueOnce([ |
|||
{ name: "my-project", isDirectory: () => true }, |
|||
]); |
|||
mockReaddir.mockResolvedValueOnce(["package.json"]); |
|||
|
|||
// package.json doesn't contain "version" + "nodes"
|
|||
const notWorkflow = '{"name":"my-app","version":"1.0.0","dependencies":{}}'; |
|||
const notBuf = Buffer.from(notWorkflow); |
|||
|
|||
mockOpen.mockResolvedValue({ |
|||
read: vi.fn().mockImplementation(async (buf: Buffer) => { |
|||
notBuf.copy(buf, 0, 0, Math.min(notBuf.length, buf.length)); |
|||
return { bytesRead: Math.min(notBuf.length, buf.length) }; |
|||
}), |
|||
close: vi.fn().mockResolvedValue(undefined), |
|||
}); |
|||
|
|||
const response = await GET(createRequest("/home/user/projects")); |
|||
const data = await response.json(); |
|||
|
|||
expect(data.success).toBe(true); |
|||
expect(data.workflows).toEqual([]); |
|||
}); |
|||
|
|||
it("should fall back to directory name when workflow has no name field", async () => { |
|||
mockReaddir.mockResolvedValueOnce([ |
|||
{ name: "unnamed-project", isDirectory: () => true }, |
|||
]); |
|||
mockReaddir.mockResolvedValueOnce(["workflow.json"]); |
|||
|
|||
// No "name" field but has version + nodes
|
|||
const noNameHeader = '{"version":1,"nodes":[{"id":"1"}],"edges":[]}'; |
|||
const noNameBuf = Buffer.from(noNameHeader); |
|||
|
|||
mockOpen.mockResolvedValue({ |
|||
read: vi.fn().mockImplementation(async (buf: Buffer) => { |
|||
noNameBuf.copy(buf, 0, 0, Math.min(noNameBuf.length, buf.length)); |
|||
return { bytesRead: Math.min(noNameBuf.length, buf.length) }; |
|||
}), |
|||
close: vi.fn().mockResolvedValue(undefined), |
|||
}); |
|||
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1700000000000 }); |
|||
|
|||
const response = await GET(createRequest("/home/user/projects")); |
|||
const data = await response.json(); |
|||
|
|||
expect(data.success).toBe(true); |
|||
expect(data.workflows).toHaveLength(1); |
|||
expect(data.workflows[0].name).toBe("unnamed-project"); |
|||
}); |
|||
|
|||
it("should sort workflows by most recently modified first", async () => { |
|||
mockReaddir.mockResolvedValueOnce([ |
|||
{ name: "old-project", isDirectory: () => true }, |
|||
{ name: "new-project", isDirectory: () => true }, |
|||
]); |
|||
|
|||
// Old project
|
|||
mockReaddir.mockResolvedValueOnce(["workflow.json"]); |
|||
// New project
|
|||
mockReaddir.mockResolvedValueOnce(["workflow.json"]); |
|||
|
|||
const workflowHeader = '{"version":1,"name":"Test","nodes":[]}'; |
|||
const headerBuf = Buffer.from(workflowHeader); |
|||
|
|||
mockOpen.mockResolvedValue({ |
|||
read: vi.fn().mockImplementation(async (buf: Buffer) => { |
|||
headerBuf.copy(buf, 0, 0, Math.min(headerBuf.length, buf.length)); |
|||
return { bytesRead: Math.min(headerBuf.length, buf.length) }; |
|||
}), |
|||
close: vi.fn().mockResolvedValue(undefined), |
|||
}); |
|||
|
|||
// old-project was modified before new-project
|
|||
mockStat |
|||
.mockResolvedValueOnce({ mtimeMs: 1600000000000 }) |
|||
.mockResolvedValueOnce({ mtimeMs: 1700000000000 }); |
|||
|
|||
const response = await GET(createRequest("/home/user/projects")); |
|||
const data = await response.json(); |
|||
|
|||
expect(data.success).toBe(true); |
|||
expect(data.workflows).toHaveLength(2); |
|||
// Newer project should come first
|
|||
expect(data.workflows[0].lastModified).toBe(1700000000000); |
|||
expect(data.workflows[1].lastModified).toBe(1600000000000); |
|||
}); |
|||
}); |
|||
|
|||
describe("error handling", () => { |
|||
it("should return 500 when readdir fails on parent directory", async () => { |
|||
mockReaddir.mockRejectedValue(new Error("ENOENT: no such directory")); |
|||
|
|||
const response = await GET(createRequest("/home/user/nonexistent")); |
|||
const data = await response.json(); |
|||
|
|||
expect(response.status).toBe(500); |
|||
expect(data.success).toBe(false); |
|||
expect(data.error).toContain("ENOENT"); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,115 @@ |
|||
import { describe, it, expect, vi } from "vitest"; |
|||
import { render, screen, fireEvent } from "@testing-library/react"; |
|||
import { WorkflowBrowserModal } from "@/components/WorkflowBrowserModal"; |
|||
|
|||
// Mock WorkflowBrowserView to isolate the modal wrapper
|
|||
vi.mock("@/components/quickstart/WorkflowBrowserView", () => ({ |
|||
WorkflowBrowserView: ({ |
|||
onWorkflowLoaded, |
|||
onClose, |
|||
}: { |
|||
onWorkflowLoaded: (w: unknown, p: string) => void; |
|||
onClose: () => void; |
|||
}) => ( |
|||
<div data-testid="workflow-browser-view"> |
|||
<button data-testid="load-btn" onClick={() => onWorkflowLoaded({ nodes: [], edges: [] }, "/test/path")}> |
|||
Load |
|||
</button> |
|||
<button data-testid="close-btn" onClick={onClose}> |
|||
Close |
|||
</button> |
|||
</div> |
|||
), |
|||
})); |
|||
|
|||
describe("WorkflowBrowserModal", () => { |
|||
it("should not render when isOpen is false", () => { |
|||
render( |
|||
<WorkflowBrowserModal |
|||
isOpen={false} |
|||
onClose={vi.fn()} |
|||
onWorkflowLoaded={vi.fn()} |
|||
/> |
|||
); |
|||
|
|||
expect(screen.queryByTestId("workflow-browser-view")).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render WorkflowBrowserView when isOpen is true", () => { |
|||
render( |
|||
<WorkflowBrowserModal |
|||
isOpen={true} |
|||
onClose={vi.fn()} |
|||
onWorkflowLoaded={vi.fn()} |
|||
/> |
|||
); |
|||
|
|||
expect(screen.getByTestId("workflow-browser-view")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should call onClose when backdrop is clicked", () => { |
|||
const onClose = vi.fn(); |
|||
const { container } = render( |
|||
<WorkflowBrowserModal |
|||
isOpen={true} |
|||
onClose={onClose} |
|||
onWorkflowLoaded={vi.fn()} |
|||
/> |
|||
); |
|||
|
|||
// Click the backdrop (outermost fixed div)
|
|||
const backdrop = container.querySelector(".fixed.inset-0"); |
|||
fireEvent.click(backdrop!); |
|||
|
|||
expect(onClose).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should not call onClose when modal content is clicked", () => { |
|||
const onClose = vi.fn(); |
|||
render( |
|||
<WorkflowBrowserModal |
|||
isOpen={true} |
|||
onClose={onClose} |
|||
onWorkflowLoaded={vi.fn()} |
|||
/> |
|||
); |
|||
|
|||
// Click the inner content area (via the view)
|
|||
fireEvent.click(screen.getByTestId("workflow-browser-view")); |
|||
|
|||
expect(onClose).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should forward onWorkflowLoaded callback", () => { |
|||
const onWorkflowLoaded = vi.fn(); |
|||
render( |
|||
<WorkflowBrowserModal |
|||
isOpen={true} |
|||
onClose={vi.fn()} |
|||
onWorkflowLoaded={onWorkflowLoaded} |
|||
/> |
|||
); |
|||
|
|||
fireEvent.click(screen.getByTestId("load-btn")); |
|||
|
|||
expect(onWorkflowLoaded).toHaveBeenCalledWith( |
|||
{ nodes: [], edges: [] }, |
|||
"/test/path" |
|||
); |
|||
}); |
|||
|
|||
it("should forward onClose callback to WorkflowBrowserView", () => { |
|||
const onClose = vi.fn(); |
|||
render( |
|||
<WorkflowBrowserModal |
|||
isOpen={true} |
|||
onClose={onClose} |
|||
onWorkflowLoaded={vi.fn()} |
|||
/> |
|||
); |
|||
|
|||
fireEvent.click(screen.getByTestId("close-btn")); |
|||
|
|||
expect(onClose).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue