Browse Source

test: add tests for WorkflowBrowserModal and list-workflows API route

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
shrimbly 4 months ago
parent
commit
8ca2bc48a3
  1. 241
      src/app/api/list-workflows/__tests__/route.test.ts
  2. 115
      src/components/__tests__/WorkflowBrowserModal.test.tsx

241
src/app/api/list-workflows/__tests__/route.test.ts

@ -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");
});
});
});

115
src/components/__tests__/WorkflowBrowserModal.test.tsx

@ -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…
Cancel
Save