Files
fusion/packages/dashboard/src/__tests__/server.test.ts
Fusion afa2240fd6 feat(FN-4029): expose database health in API and store contracts
Adds database health endpoints to the API layer (FN-4029), exposing a new store health accessor through `api-node.ts` and `api/legacy.ts`, with aligned auth middleware integration tests and updated architecture documentation.

Fusion-Task-Id: FN-4029
2026-05-12 07:47:10 -07:00

2438 lines
87 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import { createServer, setupTerminalWebSocket } from "../server.js";
import { toSessionTag } from "../terminal-websocket-diagnostics.js";
import { RATE_LIMITS } from "../rate-limit.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;
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PACKAGE_VERSION = (() => {
const packageJsonPath = join(__dirname, "..", "..", "..", "cli", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
return typeof packageJson.version === "string" ? packageJson.version : "";
})();
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([]) }),
}),
getDatabaseHealth: vi.fn().mockReturnValue({
healthy: true,
isRunning: false,
lastCheckedAt: null,
}),
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;
}
function createTerminalLoggerHarness() {
const terminalLogger = {
scope: "server:terminal",
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
};
terminalLogger.child.mockReturnValue(terminalLogger);
const runtimeLogger = {
scope: "server",
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
};
runtimeLogger.child.mockImplementation((scope: string) => {
if (scope === "terminal") {
return terminalLogger;
}
return runtimeLogger;
});
return { runtimeLogger, terminalLogger };
}
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);
}
async function flushStartupCleanupTasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe("createServer AI session startup cleanup diagnostics", () => {
const originalNodeEnv = process.env.NODE_ENV;
beforeEach(() => {
vi.useFakeTimers();
process.env.NODE_ENV = "development";
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
vi.useRealTimers();
vi.restoreAllMocks();
});
function createRuntimeLoggerMock() {
const logger = {
scope: "server",
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
};
logger.child.mockReturnValue(logger);
return logger;
}
function createAiSessionStoreMock(overrides: Record<string, unknown> = {}) {
return {
on: vi.fn(),
off: vi.fn(),
recoverStaleSessions: vi.fn(),
listRecoverable: vi.fn().mockReturnValue([]),
cleanupStaleSessions: vi.fn().mockReturnValue({
terminalDeleted: 0,
orphanedDeleted: 0,
totalDeleted: 0,
}),
stopScheduledCleanup: vi.fn(),
...overrides,
};
}
it("logs structured startup cleanup summary on success", async () => {
const runtimeLogger = createRuntimeLoggerMock();
const aiSessionStore = createAiSessionStoreMock({
cleanupStaleSessions: vi.fn().mockReturnValue({
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
}),
});
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
aiSessionTtlMs: 700_000,
aiSessionCleanupIntervalMs: 120_000,
}),
});
createServer(store, {
runtimeLogger: runtimeLogger as any,
aiSessionStore: aiSessionStore as any,
headless: true,
});
await flushStartupCleanupTasks();
expect(aiSessionStore.cleanupStaleSessions).toHaveBeenCalledWith(700_000);
expect(runtimeLogger.info).toHaveBeenCalledWith(
"AI session cleanup summary",
expect.objectContaining({
message: "Removed stale AI sessions",
source: "initial",
ttlMs: 700_000,
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
}),
);
});
it("logs structured startup cleanup failure and keeps server creation non-fatal", async () => {
const runtimeLogger = createRuntimeLoggerMock();
const aiSessionStore = createAiSessionStoreMock({
cleanupStaleSessions: vi.fn().mockImplementation(() => {
throw new Error("cleanup exploded");
}),
});
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
aiSessionTtlMs: 900_000,
aiSessionCleanupIntervalMs: 180_000,
}),
});
const app = createServer(store, {
runtimeLogger: runtimeLogger as any,
aiSessionStore: aiSessionStore as any,
headless: true,
});
await flushStartupCleanupTasks();
expect(app).toBeDefined();
expect(runtimeLogger.error).toHaveBeenCalledWith(
"AI session cleanup failed",
expect.objectContaining({
message: "Initial AI session cleanup failed",
source: "initial",
ttlMs: 900_000,
errorName: "Error",
errorMessage: "cleanup exploded",
error: "cleanup exploded",
}),
);
});
it("logs structured settings fallback warning and continues with default cleanup values", async () => {
const runtimeLogger = createRuntimeLoggerMock();
const aiSessionStore = createAiSessionStoreMock({
cleanupStaleSessions: vi.fn().mockReturnValue({
terminalDeleted: 1,
orphanedDeleted: 0,
totalDeleted: 1,
}),
});
const store = createMockStore({
getSettings: vi.fn().mockRejectedValue(new Error("settings unavailable")),
});
const app = createServer(store, {
runtimeLogger: runtimeLogger as any,
aiSessionStore: aiSessionStore as any,
headless: true,
});
await flushStartupCleanupTasks();
expect(app).toBeDefined();
expect(runtimeLogger.warn).toHaveBeenCalledWith(
"AI session cleanup settings fallback",
expect.objectContaining({
message: "Failed to load settings for AI session cleanup; using defaults",
fallbackTtlMs: 7 * 24 * 60 * 60 * 1000,
fallbackCleanupIntervalMs: 6 * 60 * 60 * 1000,
errorName: "Error",
errorMessage: "settings unavailable",
error: "settings unavailable",
}),
);
expect(aiSessionStore.cleanupStaleSessions).toHaveBeenCalledWith(7 * 24 * 60 * 60 * 1000);
});
});
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: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
database: {
healthy: true,
isRunning: false,
lastCheckedAt: null,
},
});
});
it("reports degraded status when database corruption is detected", async () => {
const store = createMockStore({
getDatabaseHealth: vi.fn().mockReturnValue({
healthy: false,
isRunning: false,
lastCheckedAt: new Date("2026-05-11T10:00:00.000Z"),
}),
});
const app = createServer(store);
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "degraded",
version: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
database: {
healthy: false,
isRunning: false,
lastCheckedAt: "2026-05-11T10:00:00.000Z",
},
});
});
it("does not return stale hardcoded 0.4.0 unless package version is 0.4.0", async () => {
const store = createMockStore();
const app = createServer(store);
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
if (CLI_PACKAGE_VERSION === "0.4.0") {
expect(res.body.version).toBe("0.4.0");
return;
}
expect(res.body.version).not.toBe("0.4.0");
});
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);
});
it("wires remote settings/auth routes in both dashboard and headless modes", async () => {
const remoteAccess = {
enabled: true,
activeProvider: "cloudflare",
providers: {
tailscale: { enabled: false, hostname: "", targetPort: 4040, acceptRoutes: false },
cloudflare: { enabled: true, quickTunnel: false, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
},
tokenStrategy: {
persistent: { enabled: true, token: "frt_persistent_token" },
shortLived: { enabled: true, ttlMs: 120000, maxTtlMs: 86400000 },
},
lifecycle: { rememberLastRunning: false, wasRunningOnShutdown: false, lastRunningProvider: null },
};
const dashboard = createServer(createMockStore({ getSettings: vi.fn().mockResolvedValue({ remoteAccess }) }));
const headless = createServer(createMockStore({ getSettings: vi.fn().mockResolvedValue({ remoteAccess }) }), { headless: true });
const [dashSettings, headlessSettings, dashLoginUrl, headlessLoginUrl, dashStatus, headlessStatus, headlessRoot] = await Promise.all([
GET(dashboard, "/api/remote/settings"),
GET(headless, "/api/remote/settings"),
REQUEST(headless, "POST", "/api/remote-access/auth/login-url", JSON.stringify({ mode: "persistent" }), { "Content-Type": "application/json" }),
REQUEST(dashboard, "POST", "/api/remote-access/auth/login-url", JSON.stringify({ mode: "persistent" }), { "Content-Type": "application/json" }),
GET(dashboard, "/api/remote/status"),
GET(headless, "/api/remote/status"),
GET(headless, "/"),
]);
expect(dashSettings.status).toBe(200);
expect(headlessSettings.status).toBe(200);
expect(dashLoginUrl.status).toBe(200);
expect(headlessLoginUrl.status).toBe(200);
expect(dashStatus.status).toBe(200);
expect(headlessStatus.status).toBe(200);
const dashLoginBody = dashLoginUrl.body as Record<string, unknown>;
const headlessLoginBody = headlessLoginUrl.body as Record<string, unknown>;
expect(Object.keys(dashLoginBody).sort()).toEqual(["loginUrl", "tokenType"]);
expect(Object.keys(headlessLoginBody).sort()).toEqual(["loginUrl", "tokenType"]);
expect(String(dashLoginBody.loginUrl)).toContain("/remote-login?rt=");
expect(String(headlessLoginBody.loginUrl)).toContain("/remote-login?rt=");
const dashStatusBody = dashStatus.body as Record<string, unknown>;
const headlessStatusBody = headlessStatus.body as Record<string, unknown>;
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
expect(headlessRoot.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");
}
});
it("redirects /tasks/:id to canonical ?task query", async () => {
const app = createServer(store);
const res = await GET(app, "/tasks/FN-9999");
expect(res.status).toBe(301);
expect(res.headers.location).toBe("/?task=FN-9999");
});
it("preserves project query when redirecting /tasks/:id", async () => {
const app = createServer(store);
const res = await GET(app, "/tasks/FN-9999?project=demo");
expect(res.status).toBe(301);
expect(res.headers.location).toBe("/?task=FN-9999&project=demo");
});
it("does not redirect invalid /tasks/:id", async () => {
const app = createServer(store, { headless: true });
const res = await GET(app, "/tasks/not-a-task");
expect(res.status).toBe(404);
expect(res.headers.location).toBeUndefined();
});
it("keeps canonical query deep-link behavior unchanged", async () => {
const previousClientDir = process.env.FUSION_CLIENT_DIR;
process.env.FUSION_CLIENT_DIR = join(__dirname, "..", "..", "app");
try {
const app = createServer(store);
const res = await GET(app, "/?task=FN-9999");
expect(res.status).toBe(200);
expect(typeof res.body).toBe("string");
expect(res.body).toContain("<div id=\"root\"></div>");
} finally {
process.env.FUSION_CLIENT_DIR = previousClientDir;
}
});
});
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 < RATE_LIMITS.mutation.max; 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 < RATE_LIMITS.mutation.max; 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;
let runtimeLogger: ReturnType<typeof createTerminalLoggerHarness>["runtimeLogger"];
let terminalLogger: ReturnType<typeof createTerminalLoggerHarness>["terminalLogger"];
beforeEach(() => {
app = express();
server = http.createServer(app);
store = createMockStore();
vi.useFakeTimers();
const loggerHarness = createTerminalLoggerHarness();
runtimeLogger = loggerHarness.runtimeLogger;
terminalLogger = loggerHarness.terminalLogger;
});
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, options: Record<string, unknown> = {}): void {
setupTerminalWebSocket(app, server, store, {
runtimeLogger: runtimeLogger as any,
...options,
});
// 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();
expect(terminalLogger.info).toHaveBeenCalledWith(
"Missed terminal websocket pong",
expect.objectContaining({
sessionTag: toSessionTag("session-2"),
missedPongs: 1,
maxMissedPongs: 2,
}),
);
// Don't send pong — missed pong #2: should terminate
vi.advanceTimersByTime(30000);
expect(ws.terminate).toHaveBeenCalled();
expect(terminalLogger.warn).toHaveBeenCalledWith(
"Terminating terminal websocket after missed pong threshold",
expect.objectContaining({
sessionTag: toSessionTag("session-2"),
missedPongs: 2,
maxMissedPongs: 2,
}),
);
});
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 structured error and closes 4510 when scoped store resolution fails", async () => {
const ws = createMockWs();
const req = {
url: "/api/terminal/ws?sessionId=session-4510&projectId=proj-a",
headers: { host: "localhost:3000" },
};
const engineManager = {
getEngine: vi.fn(() => {
throw new Error("scope lookup failed");
}),
};
setupAndConnect(ws, req, { engineManager });
await Promise.resolve();
expect(ws.close).toHaveBeenCalledWith(4510, "Failed to resolve project scope");
expect(terminalLogger.error).toHaveBeenCalledWith(
"Failed to resolve project scope",
expect.objectContaining({
projectId: "proj-a",
error: "scope lookup failed",
}),
);
});
it("logs redacted cwd mismatch context and closes 4503", () => {
const ws = createMockWs();
const req = createMockReq("cross-project-session-1");
mockTerminalService.getSession.mockReturnValue({
id: "cross-project-session-1",
shell: "/bin/bash",
cwd: "/Users/alice/private/other-project",
lastActivityAt: new Date(),
});
setupAndConnect(ws, req);
expect(ws.close).toHaveBeenCalledWith(4503, "Session does not belong to this project");
expect(terminalLogger.warn).toHaveBeenCalledWith(
"Rejected terminal session outside scoped project root",
expect.objectContaining({
sessionTag: toSessionTag("cross-project-session-1"),
sessionCwdHint: expect.stringContaining("<redacted>/"),
scopedRootHint: expect.stringContaining("<redacted>/"),
}),
);
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ sessionCwd: "/Users/alice/private/other-project" }),
);
});
it("logs structured warning for stale session reconnect with bounded context", () => {
const ws = createMockWs();
const req = createMockReq("stale-session-123456");
// 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-123456",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: tenMinutesAgo,
});
setupAndConnect(ws, req);
expect(terminalLogger.warn).toHaveBeenCalledWith(
"Terminal reconnect may target stale PTY session",
expect.objectContaining({
sessionTag: toSessionTag("stale-session-123456"),
idleMs: 600_000,
}),
);
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ sessionId: "stale-session-123456" }),
);
});
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(terminalLogger.warn).not.toHaveBeenCalledWith(
"Terminal reconnect may target stale PTY session",
expect.anything(),
);
});
});
describe("Terminal stale-session eviction", () => {
let app: ReturnType<typeof express>;
let server: http.Server;
let store: TaskStore;
let runtimeLogger: ReturnType<typeof createTerminalLoggerHarness>["runtimeLogger"];
let terminalLogger: ReturnType<typeof createTerminalLoggerHarness>["terminalLogger"];
beforeEach(() => {
app = express();
server = http.createServer(app);
store = createMockStore();
vi.useFakeTimers();
mockTerminalService.evictStaleSessions.mockReset().mockReturnValue(0);
const loggerHarness = createTerminalLoggerHarness();
runtimeLogger = loggerHarness.runtimeLogger;
terminalLogger = loggerHarness.terminalLogger;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
server.close();
});
it("calls evictStaleSessions on each 60s interval tick", () => {
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
vi.advanceTimersByTime(60_000);
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60_000);
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(2);
expect(terminalLogger.error).not.toHaveBeenCalledWith(
"Stale session eviction failed",
expect.anything(),
);
});
it("logs structured error and continues when evictStaleSessions throws", () => {
mockTerminalService.evictStaleSessions.mockImplementation(() => {
throw new Error("simulated eviction failure");
});
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
vi.advanceTimersByTime(60_000);
expect(terminalLogger.error).toHaveBeenCalledWith(
"Stale session eviction failed",
expect.objectContaining({
error: "simulated eviction failure",
errorName: "Error",
errorMessage: "simulated eviction failure",
}),
);
vi.advanceTimersByTime(60_000);
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(2);
});
it("stops eviction interval when server closes", () => {
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
server.emit("close");
vi.advanceTimersByTime(120_000);
expect(mockTerminalService.evictStaleSessions).not.toHaveBeenCalled();
});
});
/**
* Scoped Scheduling Resolver Regression Tests
* ===========================================
*
* These tests verify the scoped scheduling resolver invariants across automation and routine routes.
*
* Scope Resolution Precedence:
* 1. scope=global → Uses the default AutomationStore/RoutineStore from options (process-level)
* 2. scope=project → Uses the same store with scope filtering at query time
* 3. Omitted scope (legacy) → Defaults to project for POST, returns all for GET
*
* Error Contracts:
* - 400: Invalid scope value ("invalid" is not "global" or "project")
* - 503: Store unavailable when scope is specified
* - 200: Empty array when store is unavailable (legacy fallback for backward compatibility)
*
* Cross-Project Isolation:
* - scope=project requests never leak global-scoped items into results
* - scope=global requests never include project-scoped items
* - No opportunistic lane hopping: when scope=project has no results, it returns empty,
* NOT the global lane results
*
* Fallback Behavior:
* - When no AutomationStore/RoutineStore is configured, GET endpoints return []
* (This is a legacy backward-compatible behavior)
* - POST endpoints requiring a store will throw if no store is configured
*/
describe("createServer scoped scheduling resolver regressions", () => {
// ── Mock factory helpers ─────────────────────────────────────────
function createMockAutomationStore(name = "mock-automation-store") {
return {
listSchedules: vi.fn().mockResolvedValue([]),
getSchedule: vi.fn(),
createSchedule: vi.fn(),
updateSchedule: vi.fn(),
deleteSchedule: vi.fn(),
recordRun: vi.fn(),
reorderSteps: vi.fn(),
isValidCron: vi.fn().mockReturnValue(true),
};
}
function createMockRoutineStore(name = "mock-routine-store") {
return {
listRoutines: vi.fn().mockResolvedValue([]),
getRoutine: vi.fn(),
createRoutine: vi.fn(),
updateRoutine: vi.fn(),
deleteRoutine: vi.fn(),
isValidCron: vi.fn().mockReturnValue(true),
};
}
function createMockRoutineRunner() {
return {
triggerManual: vi.fn().mockResolvedValue({ success: true }),
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
};
}
// ── Mock ProjectEngineManager ───────────────────────────────────
function createMockEngineManager() {
return {
getEngine: vi.fn(),
ensureEngine: vi.fn(),
startReconciliation: vi.fn(),
};
}
// ── Test fixtures ───────────────────────────────────────────────
const FAKE_GLOBAL_SCHEDULE = {
id: "sched-global-1",
name: "Global Schedule",
scope: "global" as const,
scheduleType: "hourly" as const,
command: "echo global",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJECT_SCHEDULE = {
id: "sched-proj-a-1",
name: "Project A Schedule",
scope: "project" as const,
scheduleType: "daily" as const,
command: "echo proj-a",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_GLOBAL_ROUTINE = {
id: "routine-global-1",
name: "Global Routine",
scope: "global" as const,
trigger: { type: "manual" as const },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJECT_ROUTINE = {
id: "routine-proj-a-1",
name: "Project A Routine",
scope: "project" as const,
trigger: { type: "manual" as const },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
// ── Automation lane selection tests ──────────────────────────────────────
//
// Automation lane selection: The automation store resolves based on scope parameter.
// Precedence: global lane uses process-level store, project lane uses same store
// with scope filtering. No lane switching occurs — scope=project with no project
// schedules returns empty array, NOT global schedules.
describe("Automation lane selection", () => {
it("GET /api/automations?scope=global calls only global automation store", async () => {
const globalStore = createMockAutomationStore("global");
const projectStore = createMockAutomationStore("project");
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE]);
const engineManager = createMockEngineManager();
// engineManager returns undefined for all projects (no engine available)
engineManager.getEngine.mockReturnValue(undefined);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].scope).toBe("global");
expect(globalStore.listSchedules).toHaveBeenCalledTimes(1);
});
it("GET /api/automations?scope=global returns only global schedules (no project leakage)", async () => {
const globalStore = createMockAutomationStore("global");
const projectStore = createMockAutomationStore("project");
// Global store returns mixed results, but route filters by scope
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// Route should filter by scope so only global schedules are returned
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
});
it("GET /api/automations?scope=project returns only project-scoped schedules", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Route should filter by scope so only project schedules are returned
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
});
it("POST /api/automations with scope=global creates in global lane", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE, scope: "global" });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test Global Schedule",
command: "echo test",
scheduleType: "hourly",
scope: "global",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "global" }),
);
});
it("POST /api/automations with scope=project creates in project lane", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJECT_SCHEDULE, scope: "project" });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test Project Schedule",
command: "echo test",
scheduleType: "daily",
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("GET /api/automations?scope=invalid returns 400 when automation store is configured", async () => {
const globalStore = createMockAutomationStore("global");
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations?scope=invalid");
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("GET /api/automations?scope=invalid returns empty array when no automation store configured (early exit)", async () => {
const store = createMockStore();
// When no automationStore is configured, route returns empty array BEFORE scope validation
const app = createServer(store);
const res = await GET(app, "/api/automations?scope=invalid");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("GET /api/automations without scope returns all (legacy default)", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it("GET /api/automations when no automation store configured returns empty array", async () => {
const store = createMockStore();
const app = createServer(store); // No automationStore option
const res = await GET(app, "/api/automations");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("POST /api/automations/:id/run with scope=global runs global schedule", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.getSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE });
globalStore.recordRun.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations/sched-global-1/run?scope=global");
expect(res.status).toBe(200);
expect(res.body.schedule).toBeDefined();
expect(res.body.result).toBeDefined();
expect(globalStore.getSchedule).toHaveBeenCalledWith("sched-global-1");
});
it("POST /api/automations/:id/run with scope=project for global schedule returns 404", async () => {
const globalStore = createMockAutomationStore("global");
// Schedule is global-scoped
globalStore.getSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request with scope=project but schedule is global
const res = await REQUEST(app, "POST", "/api/automations/sched-global-1/run?scope=project");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("POST /api/automations/:id/toggle with scope=global toggles global schedule", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.getSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE, enabled: true });
globalStore.updateSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE, enabled: false });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations/sched-global-1/toggle?scope=global");
expect(res.status).toBe(200);
expect(globalStore.updateSchedule).toHaveBeenCalled();
});
it("Cross-project isolation: request for proj-a never touches proj-b dependencies", async () => {
const globalStore = createMockAutomationStore("global");
// Returns only project A's schedule
globalStore.listSchedules.mockResolvedValue([FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request for project scope (would be proj-a in real scenario)
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// All returned schedules should be project-scoped
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
});
});
// ── Routine lane selection tests ────────────────────────────────────────
//
// Routine lane selection: The routine store and routine runner resolve based on scope.
// Precedence: global lane uses process-level store/runner, project lane uses same
// with scope filtering. RoutineRunner is invoked for /run and /trigger endpoints
// when scope matches and routine is enabled.
describe("Routine lane selection", () => {
it("GET /api/routines?scope=global returns only global routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_GLOBAL_ROUTINE, FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
expect(res.body.every((r: any) => r.scope === "global")).toBe(true);
});
it("GET /api/routines?scope=project returns only project-scoped routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_GLOBAL_ROUTINE, FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
});
it("POST /api/routines with scope=global creates in global lane", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test Global Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "global" }),
);
});
it("POST /api/routines with scope=project creates in project lane", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJECT_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test Project Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("GET /api/routines?scope=invalid returns 400 when routine store is configured", async () => {
const globalStore = createMockRoutineStore("global");
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines?scope=invalid");
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("GET /api/routines?scope=invalid returns empty array when no routine store configured (early exit)", async () => {
const store = createMockStore();
// When no routineStore is configured, route returns empty array BEFORE scope validation
const app = createServer(store);
const res = await GET(app, "/api/routines?scope=invalid");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("GET /api/routines without scope returns all (legacy default)", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_GLOBAL_ROUTINE, FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it("GET /api/routines when no routine store configured returns empty array", async () => {
const store = createMockStore();
const app = createServer(store); // No routineStore option
const res = await GET(app, "/api/routines");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("POST /api/routines/:id/run with scope=global runs global routine via RoutineRunner", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
expect(res.status).toBe(200);
expect(res.body.routine).toBeDefined();
expect(res.body.result).toBeDefined();
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-global-1");
});
it("POST /api/routines/:id/run with scope=project for global routine returns 404", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const routineRunner = createMockRoutineRunner();
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
// Request with scope=project but routine is global
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=project");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
// RoutineRunner should NOT be called for mismatched scope
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("POST /api/routines/:id/trigger with scope=global triggers global routine", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/trigger?scope=global");
expect(res.status).toBe(200);
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-global-1");
});
it("GET /api/routines/:id/runs with scope=global returns runs for global routine", async () => {
const globalStore = createMockRoutineStore("global");
const runHistory = [
{ routineId: "routine-global-1", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:01:00.000Z", success: true, output: "Done" },
];
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE, runHistory });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines/routine-global-1/runs?scope=global");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
it("Cross-project isolation: request for proj-a never touches proj-b dependencies", async () => {
const globalStore = createMockRoutineStore("global");
// Returns only project-scoped routines (would be proj-b in real multi-project scenario)
globalStore.listRoutines.mockResolvedValue([FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// Request for project scope
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// All returned routines should be project-scoped
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
});
it("POST /api/routines/:id/webhook is scope-independent (uses routine's own scope)", async () => {
const secret = "test-secret-test-secret";
const payload = JSON.stringify({});
const signature =
"sha256=" +
createHmac("sha256", secret).update(payload).digest("hex");
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({
...FAKE_PROJECT_ROUTINE,
trigger: { type: "webhook" as const, webhookPath: "/trigger/test", secret },
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
// Webhook without scope param - should work regardless of scope
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a-1/webhook", payload, {
"Content-Type": "application/json",
"x-hub-signature-256": signature,
});
expect(res.status).toBe(200);
expect(routineRunner.triggerWebhook).toHaveBeenCalled();
});
it("POST /api/routines/:id/run when routine is disabled returns 400", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE, enabled: false });
const routineRunner = createMockRoutineRunner();
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
expect(res.status).toBe(400);
expect(res.body.error).toContain("disabled");
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("POST /api/routines/:id/run when no RoutineRunner returns 503", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
// No routineRunner configured
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
expect(res.status).toBe(503);
expect(res.body.error).toContain("not available");
});
});
// ── Fallback and error contract tests ────────────────────────────────
//
// Backward-compatible defaults: omitted scope defaults to "project" for mutations.
// This preserves existing behavior while adding explicit scope selection.
// Store unavailability: when no store is configured, GET returns [] for
// backward compatibility (legacy behavior).
describe("Fallback and error contracts", () => {
it("omitted scope defaults to project for backward compatibility (POST automations)", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJECT_SCHEDULE, scope: "project" });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// No scope specified - should default to "project"
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test Schedule",
command: "echo test",
scheduleType: "hourly",
// scope omitted
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("omitted scope defaults to project for backward compatibility (POST routines)", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJECT_ROUTINE, scope: "project" });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// No scope specified - should default to "project"
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
// scope omitted
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("scope=project with no automation store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No automationStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("scope=global with no automation store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No automationStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("scope=project with no routine store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No routineStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("scope=global with no routine store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No routineStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
// ── No-opportunistic-lane-hopping assertions ───────────────────────────
//
// Critical invariant: scope selection is deterministic and deterministic.
// When scope=project returns no results, the resolver must NOT fall back to
// global lane results. This prevents cross-project data leakage and ensures
// automation/routine isolation between global and project contexts.
describe("No opportunistic lane hopping", () => {
it("scope=project request never falls back to global when engine is unavailable", async () => {
const globalStore = createMockAutomationStore("global");
const engineManager = createMockEngineManager();
// No engine available for any project
engineManager.getEngine.mockReturnValue(undefined);
// Only global-scoped schedules exist
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
// Request for project scope - should filter by scope, NOT switch to global
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Should return empty (no project-scoped schedules) not global schedules
expect(res.body).toHaveLength(0);
});
it("scope=global request never switches to project lane", async () => {
const globalStore = createMockAutomationStore("global");
const engineManager = createMockEngineManager();
// Engine IS available for some project
const mockEngine = { getTaskStore: vi.fn() };
engineManager.getEngine.mockReturnValue(mockEngine as any);
// Both global and project schedules exist
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
// Request for global scope - should only return global schedules
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// All results should be global-scoped
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
// Should not have fallen back to project
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
});
});
// ── Cross-project isolation integration tests (FN-1743) ─────────────────────
//
// These tests verify end-to-end cross-project isolation for scoped scheduling routes.
// They ensure that:
// 1. Requests with projectId=proj-a never touch proj-b stores
// 2. Requests with projectId=proj-b never touch proj-a stores
// 3. Fallback paths are deterministic and do not opportunistically hop lanes
// 4. Scope filtering in routes ensures only project-scoped items are returned
//
// NOTE: The current implementation uses the same automation/routine store for both
// global and project scopes, with scope filtering applied at query time. This means
// engineManager.getEngine is not used for scope resolution - the store itself
// handles scope filtering through getDueSchedules(scope) or listSchedules filtering.
describe("cross-project isolation integration", () => {
// Test fixtures for multi-project scenarios
const FAKE_PROJ_A_SCHEDULE = {
id: "sched-proj-a",
name: "Project A Schedule",
scope: "project" as const,
scheduleType: "daily" as const,
command: "echo proj-a",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJ_B_SCHEDULE = {
id: "sched-proj-b",
name: "Project B Schedule",
scope: "project" as const,
scheduleType: "daily" as const,
command: "echo proj-b",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJ_A_ROUTINE = {
id: "routine-proj-a",
name: "Project A Routine",
scope: "project" as const,
trigger: { type: "manual" as const },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJ_B_ROUTINE = {
id: "routine-proj-b",
name: "Project B Routine",
scope: "project" as const,
trigger: { type: "manual" as const },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("GET /api/automations?scope=project filters to only project-scoped schedules", async () => {
const globalStore = createMockAutomationStore("global");
// Store returns both global and project schedules
globalStore.listSchedules.mockResolvedValue([
FAKE_GLOBAL_SCHEDULE,
FAKE_PROJ_A_SCHEDULE,
FAKE_PROJ_B_SCHEDULE,
]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request with scope=project
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Route filters by scope, so only project-scoped schedules are returned
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
// Global schedules should not be in the response
expect(res.body.some((s: any) => s.scope === "global")).toBe(false);
});
it("GET /api/automations?scope=global filters to only global-scoped schedules", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([
FAKE_GLOBAL_SCHEDULE,
FAKE_PROJ_A_SCHEDULE,
FAKE_PROJ_B_SCHEDULE,
]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request with scope=global
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// Route filters by scope, so only global-scoped schedules are returned
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
// Project schedules should not be in the response
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
});
it("GET /api/routines?scope=project filters to only project-scoped routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([
FAKE_GLOBAL_ROUTINE,
FAKE_PROJ_A_ROUTINE,
FAKE_PROJ_B_ROUTINE,
]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// Request with scope=project
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// Route filters by scope
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
expect(res.body.some((r: any) => r.scope === "global")).toBe(false);
});
it("GET /api/routines?scope=global filters to only global-scoped routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([
FAKE_GLOBAL_ROUTINE,
FAKE_PROJ_A_ROUTINE,
FAKE_PROJ_B_ROUTINE,
]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// Request with scope=global
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
// Route filters by scope
expect(res.body.every((r: any) => r.scope === "global")).toBe(true);
expect(res.body.some((r: any) => r.scope === "project")).toBe(false);
});
it("POST /api/routines/:id/run with scope=global executes global routine", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
expect(res.status).toBe(200);
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-global-1");
});
it("POST /api/routines/:id/run with scope=project for global routine returns 404", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
// Routine is global-scoped
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
// Request with scope=project but routine is global
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=project");
expect(res.status).toBe(404);
// RoutineRunner should NOT be called - scope mismatch
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("fallback to global automation store is deterministic (no lane hopping)", async () => {
const globalStore = createMockAutomationStore("global");
// Only project-scoped schedules exist in the store
globalStore.listSchedules.mockResolvedValue([FAKE_PROJ_A_SCHEDULE, FAKE_PROJ_B_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// scope=global should return empty (no global schedules exist)
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// Should NOT have hopped to project lane - should return empty
expect(res.body).toHaveLength(0);
});
it("fallback to global routine store is deterministic (no lane hopping)", async () => {
const globalStore = createMockRoutineStore("global");
// Only project-scoped routines exist
globalStore.listRoutines.mockResolvedValue([FAKE_PROJ_A_ROUTINE, FAKE_PROJ_B_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// scope=global should return empty (no global routines exist)
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
// Should NOT have hopped to project lane - should return empty
expect(res.body).toHaveLength(0);
});
it("POST /api/automations with scope=project creates in project lane", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJ_A_SCHEDULE });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "New Project Schedule",
command: "echo test",
scheduleType: "daily",
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
// Verify the schedule was created with project scope
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("POST /api/routines with scope=project creates in project lane", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJ_A_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "New Project Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
// Verify the routine was created with project scope
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
// ── engineManager integration tests ─────────────────────────────────────────────
//
// These tests verify that engineManager.getEngine(projectId) is called for
// project-scoped automation/routine routes when projectId is provided.
it("GET /api/automations?scope=project&projectId=proj-a calls engineManager.getEngine(proj-a)", async () => {
const globalStore = createMockAutomationStore("global");
// Engine A's store has unique data
const projAStore = {
listSchedules: vi.fn().mockResolvedValue([FAKE_PROJ_A_SCHEDULE]),
};
const projAEngine = { getAutomationStore: vi.fn().mockReturnValue(projAStore) };
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/automations?scope=project&projectId=proj-a");
expect(res.status).toBe(200);
// Verify engine was consulted
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
// Verify engine's store was used
expect(projAStore.listSchedules).toHaveBeenCalled();
// Default store should NOT have been called
expect(globalStore.listSchedules).not.toHaveBeenCalled();
});
it("GET /api/automations?scope=project&projectId=proj-a never touches proj-b engine", async () => {
const globalStore = createMockAutomationStore("global");
const projAStore = { listSchedules: vi.fn().mockResolvedValue([FAKE_PROJ_A_SCHEDULE]) };
const projAEngine = { getAutomationStore: vi.fn().mockReturnValue(projAStore) };
const projBEngine = { getAutomationStore: vi.fn() }; // Should never be accessed
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
await GET(app, "/api/automations?scope=project&projectId=proj-a");
// proj-b engine should NEVER be accessed
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-b");
});
it("GET /api/routines?scope=project&projectId=proj-b calls engineManager.getEngine(proj-b)", async () => {
const globalStore = createMockRoutineStore("global");
const projBStore = {
listRoutines: vi.fn().mockResolvedValue([FAKE_PROJ_B_ROUTINE]),
};
const projBEngine = { getRoutineStore: vi.fn().mockReturnValue(projBStore) };
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/routines?scope=project&projectId=proj-b");
expect(res.status).toBe(200);
// Verify engine was consulted
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
// Verify engine's store was used
expect(projBStore.listRoutines).toHaveBeenCalled();
// Default store should NOT have been called
expect(globalStore.listRoutines).not.toHaveBeenCalled();
});
it("GET /api/routines?scope=project&projectId=proj-b never touches proj-a engine", async () => {
const globalStore = createMockRoutineStore("global");
const projAEngine = { getRoutineStore: vi.fn() }; // Should never be accessed
const projBStore = { listRoutines: vi.fn().mockResolvedValue([FAKE_PROJ_B_ROUTINE]) };
const projBEngine = { getRoutineStore: vi.fn().mockReturnValue(projBStore) };
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
await GET(app, "/api/routines?scope=project&projectId=proj-b");
// proj-a engine should NEVER be accessed
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-a");
});
it("POST /api/routines/:id/run?scope=project&projectId=proj-a uses engine's RoutineRunner", async () => {
const globalStore = createMockRoutineStore("global");
const projARoutineRunner = {
triggerManual: vi.fn().mockResolvedValue({ success: true }),
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
};
const projAEngine = {
getRoutineStore: vi.fn().mockReturnValue({
getRoutine: vi.fn().mockResolvedValue({ ...FAKE_PROJ_A_ROUTINE }),
}),
getRoutineRunner: vi.fn().mockReturnValue(projARoutineRunner),
};
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a/run?scope=project&projectId=proj-a");
expect(res.status).toBe(200);
// Verify engine was consulted
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
// Verify engine's runner was used
expect(projARoutineRunner.triggerManual).toHaveBeenCalledWith("routine-proj-a");
});
it("POST /api/routines/:id/run?scope=project&projectId=proj-b never uses proj-a engine", async () => {
const globalRoutineRunner = createMockRoutineRunner();
const projAEngine = { getRoutineRunner: vi.fn() }; // Should never be accessed
const projBEngine = {
getRoutineStore: vi.fn().mockReturnValue({
getRoutine: vi.fn().mockResolvedValue({ ...FAKE_PROJ_B_ROUTINE }),
}),
getRoutineRunner: vi.fn().mockReturnValue({
triggerManual: vi.fn().mockResolvedValue({ success: true }),
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
}),
};
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: createMockRoutineStore("global") as any,
routineRunner: globalRoutineRunner as any,
engineManager: engineManager as any,
});
await REQUEST(app, "POST", "/api/routines/routine-proj-b/run?scope=project&projectId=proj-b");
// proj-a engine should NEVER be accessed
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-a");
});
it("GET /api/automations?scope=project without projectId falls back to default store", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([FAKE_PROJ_A_SCHEDULE]);
const engineManager = createMockEngineManager();
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Engine should NOT have been consulted (no projectId)
expect(engineManager.getEngine).not.toHaveBeenCalled();
// Default store should be used
expect(globalStore.listSchedules).toHaveBeenCalled();
});
it("GET /api/routines?scope=project without projectId falls back to default store", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_PROJ_A_ROUTINE]);
const engineManager = createMockEngineManager();
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// Engine should NOT have been consulted (no projectId)
expect(engineManager.getEngine).not.toHaveBeenCalled();
// Default store should be used
expect(globalStore.listRoutines).toHaveBeenCalled();
});
});
});
describe("GET /remote-login", () => {
const originalDaemonToken = process.env.FUSION_DAEMON_TOKEN;
beforeEach(() => {
delete process.env.FUSION_DAEMON_TOKEN;
});
afterEach(() => {
if (originalDaemonToken === undefined) {
delete process.env.FUSION_DAEMON_TOKEN;
} else {
process.env.FUSION_DAEMON_TOKEN = originalDaemonToken;
}
});
function buildRemoteAccessSettings() {
return {
enabled: true,
activeProvider: "cloudflare",
providers: {
tailscale: {
enabled: false,
hostname: "tail.example.ts.net",
targetPort: 4040,
acceptRoutes: false,
},
cloudflare: {
enabled: true,
quickTunnel: false,
tunnelName: "tunnel",
tunnelToken: "secret",
ingressUrl: "https://remote.example.com",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: "frt_persistent_token",
},
shortLived: {
enabled: true,
ttlMs: 120000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: false,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
};
}
it("redirects valid token to dashboard with daemon token handoff when daemon auth is enabled", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
});
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
const res = await GET(app, "/remote-login?rt=frt_persistent_token");
expect(res.status).toBe(302);
expect(res.headers.location).toBe("/?token=fn_daemon_token");
});
it("redirects valid token to root when daemon auth is disabled", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
});
const app = createServer(store, { noAuth: true });
const res = await GET(app, "/remote-login?rt=frt_persistent_token");
expect(res.status).toBe(302);
expect(res.headers.location).toBe("/");
});
it("returns 401 for invalid and missing remote token", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
});
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
const invalid = await GET(app, "/remote-login?rt=frt_wrong");
expect(invalid.status).toBe(401);
expect(invalid.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" });
const missing = await GET(app, "/remote-login");
expect(missing.status).toBe(401);
expect(missing.body).toEqual({ error: "Unauthorized", code: "remote_token_missing" });
});
it("issues short-lived login URL and expires remote-login handoff after TTL", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
remoteAccess: {
...buildRemoteAccessSettings(),
tokenStrategy: {
persistent: { enabled: true, token: "frt_persistent_token" },
shortLived: { enabled: true, ttlMs: 120000, maxTtlMs: 86400000 },
},
},
}),
});
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
const issue = await REQUEST(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "short-lived" }),
{
"Content-Type": "application/json",
Authorization: "Bearer fn_daemon_token",
},
);
expect(issue.status).toBe(200);
expect(typeof issue.body === "object" ? (issue.body as Record<string, unknown>).loginUrl : "").toEqual(expect.any(String));
const issuedLoginUrl = new URL(String((issue.body as Record<string, unknown>).loginUrl));
const shortLivedToken = issuedLoginUrl.searchParams.get("rt");
expect(shortLivedToken).toBeTruthy();
const beforeExpiry = await GET(app, `/remote-login?rt=${shortLivedToken}`);
expect(beforeExpiry.status).toBe(302);
expect(beforeExpiry.headers.location).toBe("/?token=fn_daemon_token");
vi.advanceTimersByTime(121000);
const afterExpiry = await GET(app, `/remote-login?rt=${shortLivedToken}`);
expect(afterExpiry.status).toBe(401);
expect(afterExpiry.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" });
} finally {
vi.useRealTimers();
}
});
it("does not accept remote rt query tokens as API auth", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
});
const app = createServer(store, { daemon: { token: "fn_daemon_token" } });
const res = await GET(app, "/api/tasks?rt=frt_persistent_token");
expect(res.status).toBe(401);
expect(res.body).toEqual({
error: "Unauthorized",
message: "Valid bearer token required",
});
});
});