Files
fusion/packages/dashboard/src/__tests__/remote-access-routes.test.ts
Fusion e87961825f feat(FN-3314): align auth route assertion in agents routes test
Test FN-3314 completes its final step by refining an auth route assertion in the agents routes test, ensuring the test accurately validates the expected behavior.

Fusion-Task-Id: FN-3314
2026-05-04 15:04:36 -07:00

410 lines
16 KiB
TypeScript

// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
const { mockExecFile } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
execFile: mockExecFile,
};
});
import { createApiRoutes } from "../routes.js";
import { request as performRequest } from "../test-request.js";
function buildRemoteAccessSettings(overrides: Record<string, unknown> = {}) {
return {
activeProvider: "cloudflare" as const,
providers: {
tailscale: {
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 4040,
acceptRoutes: false,
},
cloudflare: {
enabled: true,
quickTunnel: false,
tunnelName: "demo-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://remote.example.com",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: "frt_persistent_token",
},
shortLived: {
enabled: true,
ttlMs: 120000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: true,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
...overrides,
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
updateSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
updateGlobalSettings: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
prepare: vi.fn().mockReturnValue({ run: vi.fn(), get: vi.fn(), all: vi.fn() }),
exec: vi.fn(),
}),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
updateTask: vi.fn(),
moveTask: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createApp(opts: { store?: TaskStore; engine?: Record<string, unknown> } = {}) {
const store = opts.store ?? createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { engine: opts.engine as any }));
return { app, store };
}
async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) {
return performRequest(
app,
method,
path,
body === undefined ? undefined : JSON.stringify(body),
body === undefined ? {} : { "Content-Type": "application/json" },
);
}
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalArchDescriptor = Object.getOwnPropertyDescriptor(process, "arch");
function setProcessRuntime(platform: NodeJS.Platform, arch: string): void {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
Object.defineProperty(process, "arch", { value: arch, configurable: true });
}
afterEach(() => {
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
if (originalArchDescriptor) {
Object.defineProperty(process, "arch", originalArchDescriptor);
}
});
beforeEach(() => {
mockExecFile.mockReset();
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
: maybeCallback;
callback?.(null, command === "where" || command === "which" ? "/usr/local/bin/cloudflared" : "", "");
});
});
describe("remote access provider/lifecycle contracts", () => {
it("switches active provider and rejects invalid provider values", async () => {
const updateGlobalSettings = vi.fn().mockResolvedValue(undefined);
const { app } = createApp({ store: createMockStore({ updateGlobalSettings }) });
const activate = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "tailscale" });
expect(activate.status).toBe(200);
expect(activate.body).toEqual({ activeProvider: "tailscale" });
expect(updateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({
remoteAccess: expect.objectContaining({ activeProvider: "tailscale" }),
}));
const invalid = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "wireguard" });
expect(invalid.status).toBe(400);
expect(invalid.body).toEqual({
error: "Invalid remote provider",
details: { code: "INVALID_PROVIDER" },
});
});
it("seeds defaults when activating a provider on a fresh project", async () => {
const updateGlobalSettings = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({}),
updateGlobalSettings,
});
const { app } = createApp({ store });
const activate = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "cloudflare" });
expect(activate.status).toBe(200);
expect(updateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({
remoteAccess: expect.objectContaining({
activeProvider: "cloudflare",
}),
}));
});
it("returns NO_ACTIVE_PROVIDER when tunnel start is requested without an active provider", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
remoteAccess: buildRemoteAccessSettings({ activeProvider: null }),
}),
});
const { app } = createApp({ store });
const startRes = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
expect(startRes.status).toBe(409);
expect(startRes.body).toEqual({
error: "No active provider configured",
details: { code: "NO_ACTIVE_PROVIDER" },
});
});
it("keeps repeated start/stop requests idempotent when no engine is available", async () => {
const { app } = createApp();
const firstStart = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
const secondStart = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
const firstStop = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
const secondStop = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
for (const response of [firstStart, secondStart]) {
expect(response.status).toBe(200);
expect(response.body).toEqual({ state: "starting", provider: "cloudflare" });
expect(response.body).toEqual(expect.objectContaining({ state: expect.any(String), provider: expect.any(String) }));
}
for (const response of [firstStop, secondStop]) {
expect(response.status).toBe(200);
expect(response.body).toEqual({ state: "stopped", provider: "cloudflare" });
expect(response.body).toEqual(expect.objectContaining({ state: expect.any(String), provider: expect.any(String) }));
}
});
it("returns REMOTE_TUNNEL_PREREQUISITE_MISSING when provider is selected but runtime prerequisites are missing", async () => {
const store = createMockStore();
const engine = {
getTaskStore: vi.fn().mockReturnValue(store),
startRemoteTunnel: vi.fn().mockRejectedValue(new Error("runtime_prerequisite_missing:tailscale CLI unavailable")),
};
const { app } = createApp({ store, engine });
const response = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
expect(response.status).toBe(409);
expect(response.body).toEqual({
error: "tailscale CLI unavailable",
details: { code: "REMOTE_TUNNEL_PREREQUISITE_MISSING" },
});
});
it("includes cloudflaredAvailable in remote status for cloudflare provider", async () => {
const { app } = createApp();
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body).toEqual(expect.objectContaining({
provider: "cloudflare",
cloudflaredAvailable: true,
}));
});
it("returns cloudflaredAvailable false when cloudflared check fails", async () => {
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null) => void
: maybeCallback;
if (command === "which" || command === "where") {
callback?.(new Error("missing"));
return;
}
callback?.(null);
});
const { app } = createApp();
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body).toEqual(expect.objectContaining({ cloudflaredAvailable: false }));
});
it("returns cloudflaredAvailable null for non-cloudflare provider", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
remoteAccess: buildRemoteAccessSettings({ activeProvider: "tailscale" }),
}),
});
const { app } = createApp({ store });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body).toEqual(expect.objectContaining({
provider: "tailscale",
cloudflaredAvailable: null,
}));
});
it("returns 200 for remote status when managed tunnel is stopped", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
remoteAccess: buildRemoteAccessSettings({ activeProvider: "tailscale" }),
}),
});
const { app } = createApp({ store });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body).toEqual(expect.objectContaining({ state: "stopped" }));
});
it("omits externalTunnel detection when managed tunnel is running", async () => {
const engine = {
getRemoteTunnelManager: vi.fn().mockReturnValue({
getStatus: vi.fn().mockReturnValue({ state: "running", provider: "tailscale", url: "https://live.ts.net/", lastError: null }),
}),
getRemoteTunnelRestoreDiagnostics: vi.fn().mockReturnValue(null),
detectExternalTunnel: vi.fn(),
};
const { app } = createApp({ engine });
const status = await REQUEST(app, "GET", "/api/remote/status");
expect(status.status).toBe(200);
expect(status.body.externalTunnel).toBeNull();
expect(engine.detectExternalTunnel).not.toHaveBeenCalled();
});
it("calls engine killExternalTunnel via kill-external endpoint", async () => {
const engine = {
killExternalTunnel: vi.fn().mockResolvedValue(undefined),
};
const { app } = createApp({ engine });
const result = await REQUEST(app, "POST", "/api/remote/tunnel/kill-external", {});
expect(result.status).toBe(200);
expect(result.body).toEqual({ ok: true });
});
it("installs cloudflared via endpoint and returns install command metadata", async () => {
setProcessRuntime("linux", "x64");
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: true,
command: expect.stringContaining("cloudflared-linux-amd64"),
}));
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-amd64"))).toBe(true);
});
it("uses arm64 cloudflared binary on Linux arm64", async () => {
setProcessRuntime("linux", "arm64");
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: true,
command: expect.stringContaining("cloudflared-linux-arm64"),
}));
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-arm64"))).toBe(true);
});
it("falls back to ~/.local/bin when /usr/local/bin move fails with permission error", async () => {
setProcessRuntime("linux", "x64");
mockExecFile.mockImplementation((command: string, args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
: maybeCallback;
if (command === "mv" && args[1] === "/usr/local/bin/cloudflared") {
callback?.(new Error("EPERM"), "", "EPERM");
return;
}
callback?.(null, "", "");
});
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({ success: true }));
expect(mockExecFile.mock.calls.some(([command, args]) => command === "mkdir" && Array.isArray(args) && args[0] === "-p")).toBe(true);
expect(mockExecFile.mock.calls.some(([command, args]) => command === "mv" && Array.isArray(args) && String(args[1]).includes("/.local/bin/cloudflared"))).toBe(true);
});
it("falls back to direct download on macOS when brew is unavailable", async () => {
setProcessRuntime("darwin", "arm64");
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
: maybeCallback;
if (command === "which") {
callback?.(new Error("brew not found"), "", "brew not found");
return;
}
callback?.(null, "", "");
});
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: true,
command: expect.stringContaining("cloudflared-darwin-arm64"),
}));
expect(mockExecFile.mock.calls.some(([command]) => command === "brew")).toBe(false);
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-darwin-arm64"))).toBe(true);
});
it("returns install failure details when cloudflared installation command fails", async () => {
setProcessRuntime("linux", "x64");
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
: maybeCallback;
if (command === "curl") {
callback?.(new Error("Command failed"), "", "Command failed");
return;
}
callback?.(null, "", "");
});
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: false,
command: expect.any(String),
error: expect.stringContaining("Command failed"),
}));
});
});