test(FN-2531): expand headless remote access test coverage
- Add serve command assertions for headless remote-access provider and lifecycle route behavior - Add auth middleware integration coverage for hybrid login-url and remote-login token scenarios - Add dedicated remote-access routes tests to verify route parity in headless mode - Update dashboard route and server tests to validate remote auth and access flow expectations
This commit is contained in:
@@ -728,6 +728,22 @@ describe("runServe", () => {
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("preserves remote-capable headless wiring when daemon auth is enabled", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(0, { daemon: true });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOptions = createServer.mock.calls[0][1];
|
||||
expect(serverOptions).toMatchObject({ headless: true, daemon: { token: expect.any(String) } });
|
||||
expect(serverOptions.daemon.token.length).toBeGreaterThan(0);
|
||||
expect(serverOptions.engine).toBeDefined();
|
||||
expect(typeof serverOptions.engine.startRemoteTunnel).toBe("function");
|
||||
expect(typeof serverOptions.engine.stopRemoteTunnel).toBe("function");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("sets enginePaused when started with paused=true", async () => {
|
||||
await runServe(0, { paused: true });
|
||||
|
||||
|
||||
@@ -281,4 +281,108 @@ describe("Auth middleware integration with createServer", () => {
|
||||
expect(healthResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote login hybrid token validation", () => {
|
||||
function buildRemoteAccessSettings() {
|
||||
return {
|
||||
enabled: true,
|
||||
activeProvider: "cloudflare",
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: false,
|
||||
hostname: "tail.example.ts.net",
|
||||
targetPort: 4040,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
tunnelName: "demo-tunnel",
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("returns fully-qualified login-url payloads and consistent invalid token errors", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
|
||||
|
||||
const store = new MockStore() as unknown as TaskStore & { getSettings: ReturnType<typeof vi.fn> };
|
||||
store.getSettings = vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() });
|
||||
|
||||
const app = createServer(store as unknown as TaskStore, { daemon: { token: "fn_daemon_token" } });
|
||||
|
||||
const persistentIssue = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "persistent" }),
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer fn_daemon_token",
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistentIssue.status).toBe(200);
|
||||
expect(persistentIssue.body).toMatchObject({ tokenType: "persistent" });
|
||||
const persistentUrl = new URL(String((persistentIssue.body as Record<string, unknown>).loginUrl));
|
||||
expect(persistentUrl.protocol).toBe("https:");
|
||||
expect(persistentUrl.host).toBe("remote.example.com");
|
||||
expect(persistentUrl.pathname).toBe("/remote-login");
|
||||
expect(persistentUrl.searchParams.get("rt")).toMatch(/^frt_[A-Za-z0-9_-]+$/);
|
||||
|
||||
const shortIssue = 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(shortIssue.status).toBe(200);
|
||||
expect(shortIssue.body).toMatchObject({ tokenType: "short-lived", expiresAt: expect.any(String) });
|
||||
|
||||
const shortUrl = new URL(String((shortIssue.body as Record<string, unknown>).loginUrl));
|
||||
const shortToken = shortUrl.searchParams.get("rt");
|
||||
expect(shortToken).toMatch(/^frt_[A-Za-z0-9_-]+$/);
|
||||
|
||||
const invalid = await request(app, "GET", "/remote-login?rt=frt_wrong");
|
||||
const missing = await request(app, "GET", "/remote-login");
|
||||
|
||||
vi.advanceTimersByTime(121000);
|
||||
const expired = await request(app, "GET", `/remote-login?rt=${shortToken}`);
|
||||
|
||||
expect(invalid.status).toBe(401);
|
||||
expect(missing.status).toBe(401);
|
||||
expect(expired.status).toBe(401);
|
||||
|
||||
expect(invalid.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" });
|
||||
expect(missing.body).toEqual({ error: "Unauthorized", code: "remote_token_missing" });
|
||||
expect(expired.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
161
packages/dashboard/src/__tests__/remote-access-routes.test.ts
Normal file
161
packages/dashboard/src/__tests__/remote-access-routes.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
function buildRemoteAccessSettings(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
activeProvider: "cloudflare" as const,
|
||||
providers: {
|
||||
tailscale: {
|
||||
enabled: true,
|
||||
hostname: "tail.example.ts.net",
|
||||
targetPort: 4040,
|
||||
acceptRoutes: false,
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
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),
|
||||
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" },
|
||||
);
|
||||
}
|
||||
|
||||
describe("remote access provider/lifecycle contracts", () => {
|
||||
it("switches active provider and rejects invalid provider values", async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const { app } = createApp({ store: createMockStore({ updateSettings }) });
|
||||
|
||||
const activate = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "tailscale" });
|
||||
expect(activate.status).toBe(200);
|
||||
expect(activate.body).toEqual({ activeProvider: "tailscale" });
|
||||
expect(updateSettings).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("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("maps runtime prerequisite failures to a structured conflict response", 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17926,7 +17926,12 @@ describe("remote access auth login-url endpoints", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokenType).toBe("persistent");
|
||||
expect(res.body.loginUrl).toContain("https://remote.example.com/remote-login?rt=");
|
||||
const persistentLoginUrl = new URL(String(res.body.loginUrl));
|
||||
expect(persistentLoginUrl.protocol).toBe("https:");
|
||||
expect(persistentLoginUrl.host).toBe("remote.example.com");
|
||||
expect(persistentLoginUrl.pathname).toBe("/remote-login");
|
||||
expect(persistentLoginUrl.searchParams.get("rt")).toMatch(/^frt_[A-Za-z0-9_-]+$/);
|
||||
expect(res.body.expiresAt).toBeUndefined();
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
remoteAccess: expect.objectContaining({
|
||||
tokenStrategy: expect.objectContaining({
|
||||
@@ -17965,7 +17970,12 @@ describe("remote access auth login-url endpoints", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokenType).toBe("short-lived");
|
||||
expect(res.body.expiresAt).toEqual(expect.any(String));
|
||||
expect(res.body.loginUrl).toContain("/remote-login?rt=");
|
||||
const shortLivedUrl = new URL(String(res.body.loginUrl));
|
||||
expect(shortLivedUrl.protocol).toBe("https:");
|
||||
expect(shortLivedUrl.host).toBe("remote.example.com");
|
||||
expect(shortLivedUrl.pathname).toBe("/remote-login");
|
||||
expect(shortLivedUrl.searchParams.get("rt")).toMatch(/^frt_[A-Za-z0-9_-]+$/);
|
||||
expect(Date.parse(String(res.body.expiresAt))).not.toBeNaN();
|
||||
expect(JSON.stringify(res.body)).not.toContain("cf-secret");
|
||||
expect(JSON.stringify(res.body)).not.toContain("frt_persistent");
|
||||
});
|
||||
@@ -17981,5 +17991,6 @@ describe("remote access auth login-url endpoints", () => {
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("mode must be");
|
||||
expect(res.body.details).toEqual({ code: "INVALID_REMOTE_AUTH_MODE" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,19 +327,35 @@ describe("createServer health and headless mode", () => {
|
||||
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] = await Promise.all([
|
||||
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(String((dashLoginUrl.body as Record<string, unknown>).loginUrl)).toContain("/remote-login?rt=");
|
||||
expect(String((headlessLoginUrl.body as Record<string, unknown>).loginUrl)).toContain("/remote-login?rt=");
|
||||
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(["lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(headlessStatusBody).sort()).toEqual(["lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(headlessRoot.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user