Files
fusion/packages/dashboard/src/server.test.ts
2026-04-14 11:43:52 -07:00

505 lines
16 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import http from "node:http";
import express from "express";
import { createServer, setupTerminalWebSocket } from "./server.js";
import type { TaskStore } from "@fusion/core";
import { get as performGet, request as performRequest } from "./test-request.js";
// Mock terminal-service before any imports that use it
vi.mock("./terminal-service.js", () => {
const mockTerminalService = {
getSession: vi.fn(),
getScrollbackAndClearPending: vi.fn().mockReturnValue(null),
onData: vi.fn().mockReturnValue(() => {}),
onExit: vi.fn().mockReturnValue(() => {}),
write: vi.fn(),
resize: vi.fn(),
evictStaleSessions: vi.fn().mockReturnValue(0),
};
return {
getTerminalService: vi.fn(() => mockTerminalService),
STALE_SESSION_THRESHOLD_MS: 300_000,
__mockTerminalService: mockTerminalService,
};
});
// Access the mock terminal service
const { __mockTerminalService: mockTerminalService } = await import("./terminal-service.js") as any;
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
const res = await performGet(app, path);
return res;
}
async function REQUEST(
app: ReturnType<typeof createServer>,
method: string,
path: string,
body?: string,
headers?: Record<string, string>,
): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
return performRequest(app, method, path, body, headers);
}
describe("createServer health and headless mode", () => {
it("returns liveness payload from /api/health", async () => {
const store = createMockStore();
const app = createServer(store);
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
version: expect.any(String),
uptime: expect.any(Number),
});
});
it("serves API routes but no frontend when headless=true", async () => {
const store = createMockStore();
const app = createServer(store, { headless: true });
const tasksRes = await GET(app, "/api/tasks");
expect(tasksRes.status).toBe(200);
const rootRes = await GET(app, "/");
expect(rootRes.status).toBe(404);
});
});
describe("API Error Handling Middleware", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
describe("404 handler for unmatched API routes", () => {
it("returns JSON 404 for unmatched API routes", async () => {
const app = createServer(store);
const res = await GET(app, "/api/nonexistent/route");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Not found" });
expect(res.headers["content-type"]).toContain("application/json");
});
it("returns JSON 404 for unmatched API paths under known routes", async () => {
const app = createServer(store);
const res = await GET(app, "/api/tasks/nonexistent/path");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Not found" });
expect(res.headers["content-type"]).toContain("application/json");
});
});
describe("Error handler for route failures", () => {
it("returns JSON 500 when a route handler throws an error", async () => {
// Create a store that throws an error for listTasks
const failingStore = createMockStore({
listTasks: vi.fn().mockRejectedValue(new Error("Database connection failed")),
});
const app = createServer(failingStore);
const res = await GET(app, "/api/tasks");
expect(res.status).toBe(500);
// Error handler returns actual error message (may be "Internal server error" or specific message)
expect(res.body).toHaveProperty("error");
expect(res.headers["content-type"]).toContain("application/json");
});
});
describe("SPA fallback behavior", () => {
it("does not return HTML for API 404s", async () => {
const app = createServer(store);
const res = await GET(app, "/api/unknown-endpoint");
// Should NOT get HTML (the SPA fallback returns HTML)
expect(res.status).toBe(404);
expect(typeof res.body).toBe("object"); // JSON object
expect(res.body).toHaveProperty("error");
expect(res.headers["content-type"]).toContain("application/json");
// Verify we didn't get HTML
if (typeof res.body === "string") {
expect(res.body).not.toContain("<!DOCTYPE html>");
expect(res.body).not.toContain("<html");
}
});
});
describe("planning API route content types", () => {
it("returns JSON for all POST planning endpoints instead of falling through to SPA HTML", async () => {
const endpoints = [
"/api/planning/start",
"/api/planning/start-streaming",
"/api/planning/respond",
"/api/planning/cancel",
"/api/planning/create-task",
];
for (const path of endpoints) {
const app = createServer(store);
const res = await REQUEST(app, "POST", path, JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.headers["content-type"]).toContain("application/json");
if (typeof res.body === "string") {
expect(res.body).not.toContain("<!DOCTYPE html>");
expect(res.body).not.toContain("<html");
}
}
});
it("returns JSON 404s for unmatched planning API routes", async () => {
const app = createServer(store);
const res = await REQUEST(app, "POST", "/api/planning/not-a-route", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.headers["content-type"]).toContain("application/json");
expect(res.body).toEqual({ error: "Not found" });
});
});
describe("API rate limiting", () => {
it("does not rate limit general dashboard reads", async () => {
const app = createServer(store);
for (let i = 0; i < 150; i++) {
const res = await GET(app, `/api/nonexistent-read-${i}`);
expect(res.status).toBe(404);
}
const trailingReadRes = await GET(app, "/api/nonexistent-read-final");
expect(trailingReadRes.status).toBe(404);
});
it("allows setup reads after the general read budget is exhausted", async () => {
const app = createServer(store);
for (let i = 0; i < 150; i++) {
const res = await GET(app, `/api/nonexistent-read-${i}`);
expect(res.status).toBe(404);
}
const browseRes = await GET(app, "/api/browse-directory");
expect(browseRes.status).toBe(200);
expect(browseRes.body).toHaveProperty("currentPath");
expect(browseRes.body).toHaveProperty("entries");
});
it("still enforces the mutation rate-limit budget independently", async () => {
const app = createServer(store);
for (let i = 0; i < 30; i++) {
const res = await REQUEST(
app,
"POST",
"/api/planning/not-a-route",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(404);
}
const limitedRes = await REQUEST(
app,
"POST",
"/api/planning/not-a-route",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(limitedRes.status).toBe(429);
expect(limitedRes.body).toEqual({ error: "Too many requests, please try again later." });
});
it("allows project setup mutations after the general mutation budget is exhausted", async () => {
const app = createServer(store);
for (let i = 0; i < 30; i++) {
const res = await REQUEST(
app,
"POST",
"/api/planning/not-a-route",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(404);
}
const createProjectRes = await REQUEST(
app,
"POST",
"/api/projects",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(createProjectRes.status).toBe(400);
expect(createProjectRes.body).toEqual({
error: "name is required and must be a non-empty string",
});
});
});
describe("Log stream project-scoped routing", () => {
it("server is configured with log stream endpoint that accepts projectId", () => {
const store = createMockStore();
const app = createServer(store);
// Verify the server was created successfully
expect(app).toBeDefined();
});
});
});
describe("Terminal WebSocket heartbeat", () => {
let app: ReturnType<typeof express>;
let server: http.Server;
let store: TaskStore;
beforeEach(() => {
app = express();
server = http.createServer(app);
store = createMockStore();
vi.useFakeTimers();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
server.close();
});
/** Create a mock WebSocket that simulates the ws library's WebSocket */
function createMockWs(): any {
const listeners: Record<string, Function[]> = {};
return {
readyState: 1, // OPEN
_listeners: listeners,
on(event: string, handler: Function) {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(handler);
},
emit(event: string, ...args: any[]) {
(listeners[event] || []).forEach((h) => h(...args));
},
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
};
}
/** Create a mock HTTP request with sessionId */
function createMockReq(sessionId: string): any {
return {
url: `/api/terminal/ws?sessionId=${sessionId}`,
headers: { host: "localhost:3000" },
};
}
/** Setup terminal WebSocket and trigger a connection */
function setupAndConnect(ws: any, req: any): void {
const wss = setupTerminalWebSocket(app, server, store);
// The function sets up wss on the server's upgrade event.
// We need to access the WebSocketServer directly to emit a connection.
// setupTerminalWebSocket stores wss on the app
const storedWss = (app as any).terminalWsServer;
if (storedWss) {
storedWss.emit("connection", ws, req);
}
}
it("does NOT terminate connection after 1 missed pong", () => {
const ws = createMockWs();
const req = createMockReq("session-1");
// Setup a mock session
mockTerminalService.getSession.mockReturnValue({
id: "session-1",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: new Date(),
});
setupAndConnect(ws, req);
// First ping interval: mark as not alive
vi.advanceTimersByTime(30000);
// The server sends a ping, ws is marked as not alive
expect(ws.send).toHaveBeenCalled();
// Don't send a pong response — simulate missed pong
// Second ping interval: first missed pong — should NOT terminate
vi.advanceTimersByTime(30000);
// Connection should still be alive after 1 missed pong
expect(ws.terminate).not.toHaveBeenCalled();
});
it("terminates connection after 2 consecutive missed pongs", () => {
const ws = createMockWs();
const req = createMockReq("session-2");
mockTerminalService.getSession.mockReturnValue({
id: "session-2",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: new Date(),
});
setupAndConnect(ws, req);
// First ping interval: mark as not alive (isAlive = false)
vi.advanceTimersByTime(30000);
expect(ws.send).toHaveBeenCalled();
// Don't send pong — missed pong #1
vi.advanceTimersByTime(30000);
expect(ws.terminate).not.toHaveBeenCalled();
// Don't send pong — missed pong #2: should terminate
vi.advanceTimersByTime(30000);
expect(ws.terminate).toHaveBeenCalled();
});
it("resets missed pong counter on successful pong", () => {
const ws = createMockWs();
const req = createMockReq("session-3");
mockTerminalService.getSession.mockReturnValue({
id: "session-3",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: new Date(),
});
setupAndConnect(ws, req);
// First interval: mark as not alive
vi.advanceTimersByTime(30000);
// Miss first pong — interval 2: missedPongs = 1
vi.advanceTimersByTime(30000);
expect(ws.terminate).not.toHaveBeenCalled();
// Now respond with pong (application-level "pong" message)
const msgHandler = ws._listeners["message"]?.[0];
expect(msgHandler).toBeDefined();
msgHandler!(Buffer.from(JSON.stringify({ type: "pong" })));
// Interval 3: isAlive is true again, missedPongs is 0
vi.advanceTimersByTime(30000);
// Still alive — missed pong counter was reset
expect(ws.terminate).not.toHaveBeenCalled();
// Miss 2 more pongs — should still be alive after just 1 more miss
vi.advanceTimersByTime(30000);
expect(ws.terminate).not.toHaveBeenCalled();
});
it("logs warning for stale session reconnect", () => {
const ws = createMockWs();
const req = createMockReq("stale-session");
// Session last active 10 minutes ago (past the 5-minute threshold)
const tenMinutesAgo = new Date(Date.now() - 600_000);
mockTerminalService.getSession.mockReturnValue({
id: "stale-session",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: tenMinutesAgo,
});
setupAndConnect(ws, req);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("stale-session"),
);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("PTY may be stale"),
);
});
it("does not warn for fresh session reconnect", () => {
const ws = createMockWs();
const req = createMockReq("fresh-session");
// Session last active 1 minute ago (under the 5-minute threshold)
mockTerminalService.getSession.mockReturnValue({
id: "fresh-session",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: new Date(Date.now() - 60_000),
});
setupAndConnect(ws, req);
expect(console.warn).not.toHaveBeenCalledWith(
expect.stringContaining("PTY may be stale"),
);
});
});