fix(FN-2525): harden remote access auth and tunnel regression coverage
- Add regression tests across CLI, core, dashboard, and engine for remote access auth, settings parity, and serve/TUI callback wiring - Expand dashboard route and modal coverage for remote settings/auth flows including node environment behaviors - Redact provider-switch failure details in tunnel process manager to avoid leaking sensitive provider diagnostics - Update route registration and engine lifecycle tests to lock in remote-access behavior under real execution paths
This commit is contained in:
@@ -360,6 +360,16 @@ const mocks = vi.hoisted(() => {
|
||||
});
|
||||
const notifier = notifierCtor();
|
||||
|
||||
const remoteStatus = {
|
||||
provider: "cloudflare" as const,
|
||||
state: "running" as const,
|
||||
pid: 1234,
|
||||
startedAt: new Date().toISOString(),
|
||||
stoppedAt: null,
|
||||
url: "https://remote.example.com",
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const engine = {
|
||||
start: vi.fn(async () => {
|
||||
await store.init();
|
||||
@@ -412,6 +422,15 @@ const mocks = vi.hoisted(() => {
|
||||
getMissionAutopilot: () => missionAutopilot,
|
||||
getMissionExecutionLoop: () => missionExecutionLoop,
|
||||
})),
|
||||
getRemoteTunnelManager: vi.fn(() => ({ getStatus: vi.fn(() => remoteStatus) })),
|
||||
getRemoteTunnelRestoreDiagnostics: vi.fn(() => ({
|
||||
outcome: "skipped",
|
||||
reason: "not_attempted",
|
||||
at: new Date().toISOString(),
|
||||
provider: null,
|
||||
})),
|
||||
startRemoteTunnel: vi.fn(async () => remoteStatus),
|
||||
stopRemoteTunnel: vi.fn(async () => ({ ...remoteStatus, state: "stopped" as const, provider: null, pid: null, url: null })),
|
||||
onMerge: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
projectEngineInstances.push(engine);
|
||||
@@ -693,6 +712,22 @@ describe("runServe", () => {
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("passes remote-capable engine hooks into headless createServer for fn serve parity", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(0, {});
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOptions = createServer.mock.calls[0][1];
|
||||
expect(serverOptions).toMatchObject({ headless: true });
|
||||
expect(serverOptions.engine).toBeDefined();
|
||||
expect(typeof serverOptions.engine.startRemoteTunnel).toBe("function");
|
||||
expect(typeof serverOptions.engine.stopRemoteTunnel).toBe("function");
|
||||
expect(typeof serverOptions.engine.getRemoteTunnelManager).toBe("function");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("sets enginePaused when started with paused=true", async () => {
|
||||
await runServe(0, { paused: true });
|
||||
|
||||
|
||||
@@ -471,6 +471,44 @@ describe("Settings view", () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("wires P/U remote actions to persistent token refresh and URL handoff state", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
const regeneratePersistentToken = vi.fn(async () => ({
|
||||
maskedToken: "tok_****",
|
||||
tokenType: "persistent" as const,
|
||||
expiresAt: null,
|
||||
}));
|
||||
const getRemoteUrl = vi.fn(async () => ({
|
||||
url: "https://remote.example.com/remote-login?rt=masked",
|
||||
tokenType: "persistent" as const,
|
||||
expiresAt: null,
|
||||
}));
|
||||
|
||||
controller.setInteractiveData(makeInteractiveData({
|
||||
remote: {
|
||||
regeneratePersistentToken,
|
||||
getRemoteUrl,
|
||||
},
|
||||
}));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("settings");
|
||||
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
stdin.write("\t");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
stdin.write("P");
|
||||
await waitForFrameContains(lastFrame, "Persistent token: tok_****");
|
||||
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
stdin.write("U");
|
||||
await waitForFrameContains(lastFrame, "Auth URL: https://remote.example.com/remote-login?rt=masked");
|
||||
expect(getRemoteUrl).toHaveBeenCalledWith("persistent", undefined);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("keeps global shortcuts inactive during TTL input", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
|
||||
@@ -59,6 +59,16 @@ describe("settings key parity", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps remoteAccess scoped to project settings only", () => {
|
||||
const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
|
||||
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
|
||||
|
||||
expect(projectKeys).toContain("remoteAccess");
|
||||
expect(globalKeys).not.toContain("remoteAccess");
|
||||
expect(DEFAULT_PROJECT_SETTINGS.remoteAccess).toBeDefined();
|
||||
expect((DEFAULT_GLOBAL_SETTINGS as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||
});
|
||||
|
||||
it("No key appears in both GLOBAL_SETTINGS_KEYS and PROJECT_SETTINGS_KEYS", () => {
|
||||
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
|
||||
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
|
||||
|
||||
@@ -2656,6 +2656,24 @@ describe("TaskStore", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("round-trips nested remoteAccess settings with both providers, token strategy, and lifecycle", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.remoteAccess).toEqual(baseRemoteAccess);
|
||||
|
||||
const { project, global } = await store.getSettingsByScope();
|
||||
expect(project.remoteAccess).toEqual(baseRemoteAccess);
|
||||
expect((global as Record<string, unknown>).remoteAccess).toBeUndefined();
|
||||
|
||||
store.close();
|
||||
store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
|
||||
const reloaded = await store.getSettings();
|
||||
expect(reloaded.remoteAccess).toEqual(baseRemoteAccess);
|
||||
});
|
||||
|
||||
it("patching remoteAccess.providers.tailscale preserves providers.cloudflare", async () => {
|
||||
await store.updateSettings({ remoteAccess: baseRemoteAccess });
|
||||
|
||||
|
||||
@@ -1363,5 +1363,76 @@ describe("SettingsModal", () => {
|
||||
expect(await screen.findByRole("img", { name: "Remote access QR code" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Scan this QR code on your phone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves both provider configs and preserves inactive provider values when switching active provider", async () => {
|
||||
const addToast = vi.fn();
|
||||
renderModal({ addToast });
|
||||
await waitForSettingsModalReady();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
|
||||
await userEvent.clear(screen.getByLabelText("Hostname label"));
|
||||
await userEvent.type(screen.getByLabelText("Hostname label"), "tail-new.ts.net");
|
||||
await userEvent.clear(screen.getByLabelText("Tunnel name"));
|
||||
await userEvent.type(screen.getByLabelText("Tunnel name"), "cf-preserved");
|
||||
await userEvent.clear(screen.getByLabelText("Ingress URL"));
|
||||
await userEvent.type(screen.getByLabelText("Ingress URL"), "https://cf-preserved.example.com");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteTailscaleHostname: "tail-new.ts.net",
|
||||
remoteCloudflareTunnelName: "cf-preserved",
|
||||
remoteCloudflareIngressUrl: "https://cf-preserved.example.com",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(addToast).toHaveBeenCalledWith("Remote settings saved", "success");
|
||||
});
|
||||
|
||||
it("handles tunnel lifecycle and token action errors without exposing raw token values", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchRemoteStatus
|
||||
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
|
||||
.mockResolvedValueOnce({ provider: "tailscale", state: "starting", url: null, lastError: null })
|
||||
.mockResolvedValue({ provider: "tailscale", state: "stopped", url: null, lastError: null });
|
||||
mockGenerateShortLivedRemoteToken.mockRejectedValueOnce(new Error("TTL must be between 60000 and 86400000ms"));
|
||||
|
||||
renderModal({ addToast });
|
||||
await waitForSettingsModalReady();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Start tunnel" }));
|
||||
await waitFor(() => {
|
||||
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Remote tunnel start requested", "success");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Stop tunnel" }));
|
||||
await waitFor(() => {
|
||||
expect(mockStopRemoteTunnel).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Remote tunnel stopped", "success");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Short-lived TTL (ms)"), { target: { value: "1000" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Generate short-lived token" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateShortLivedRemoteToken).toHaveBeenCalledWith(1000, undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("TTL must be between 60000 and 86400000ms", "error");
|
||||
expect(addToast.mock.calls.flat().join(" ")).not.toContain("frt_");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Regenerate persistent token" }));
|
||||
await waitFor(() => {
|
||||
expect(mockRegenerateRemotePersistentToken).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { createAuthMiddleware, isDaemonAuthActive } from "../auth-middleware.js";
|
||||
@@ -123,6 +125,16 @@ describe("createAuthMiddleware", () => {
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
expect(nextFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not accept remote-login rt query tokens for non-remote API auth", () => {
|
||||
mockReq.url = "/api/tasks?rt=frt_persistent_token";
|
||||
|
||||
const middleware = createAuthMiddleware("fn_abc123def456789");
|
||||
middleware(mockReq as Request, mockRes as Response, nextFn);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
expect(nextFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDaemonAuthActive", () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { RemoteAccessProjectSettings } from "@fusion/core";
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { RemoteAccessProjectSettings, TaskStore } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { request as performRequest, get as performGet } from "../test-request.js";
|
||||
import {
|
||||
__resetRemoteAuthStateForTests,
|
||||
constantTimeEqual,
|
||||
@@ -46,6 +50,33 @@ function createRemoteSettings(overrides: Partial<RemoteAccessProjectSettings> =
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: createRemoteSettings() }),
|
||||
updateSettings: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
logEntry: vi.fn(),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
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([]) }),
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describe("remote-auth", () => {
|
||||
beforeEach(() => {
|
||||
__resetRemoteAuthStateForTests();
|
||||
@@ -144,3 +175,85 @@ describe("remote-auth", () => {
|
||||
expect(maskRemoteToken("frt_abcdefghijklmnop")).toBe("frt_…mnop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote auth route contracts", () => {
|
||||
beforeEach(() => {
|
||||
__resetRemoteAuthStateForTests();
|
||||
});
|
||||
|
||||
it("returns login-url payload with token shape for both persistent and short-lived modes", async () => {
|
||||
const app = createServer(createMockStore(), { noAuth: true });
|
||||
|
||||
const persistent = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "persistent" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(persistent.status).toBe(200);
|
||||
expect(persistent.body).toMatchObject({
|
||||
tokenType: "persistent",
|
||||
loginUrl: expect.stringContaining("/remote-login?rt="),
|
||||
});
|
||||
|
||||
const shortLived = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "short-lived" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(shortLived.status).toBe(200);
|
||||
expect(shortLived.body).toMatchObject({
|
||||
tokenType: "short-lived",
|
||||
loginUrl: expect.stringContaining("/remote-login?rt="),
|
||||
expiresAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("validates /remote-login?rt= for persistent, short-lived, expired, missing, and malformed tokens", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
|
||||
const app = createServer(createMockStore(), { daemon: { token: "fn_daemon_token" } });
|
||||
|
||||
const persistentValid = await performGet(app, "/remote-login?rt=frt_persistent_token");
|
||||
expect(persistentValid.status).toBe(302);
|
||||
expect(persistentValid.headers.location).toBe("/?token=fn_daemon_token");
|
||||
|
||||
const issueShortLived = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/remote-access/auth/login-url",
|
||||
JSON.stringify({ mode: "short-lived" }),
|
||||
{ "Content-Type": "application/json", Authorization: "Bearer fn_daemon_token" },
|
||||
);
|
||||
expect(issueShortLived.status).toBe(200);
|
||||
const issued = new URL(String((issueShortLived.body as Record<string, unknown>).loginUrl));
|
||||
const shortToken = issued.searchParams.get("rt");
|
||||
expect(shortToken).toBeTruthy();
|
||||
|
||||
const shortValid = await performGet(app, `/remote-login?rt=${shortToken}`);
|
||||
expect(shortValid.status).toBe(302);
|
||||
|
||||
vi.advanceTimersByTime(121000);
|
||||
|
||||
const shortExpired = await performGet(app, `/remote-login?rt=${shortToken}`);
|
||||
expect(shortExpired.status).toBe(401);
|
||||
expect(shortExpired.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" });
|
||||
|
||||
const missing = await performGet(app, "/remote-login");
|
||||
expect(missing.status).toBe(401);
|
||||
expect(missing.body).toEqual({ error: "Unauthorized", code: "remote_token_missing" });
|
||||
|
||||
const malformed = await performGet(app, "/remote-login?rt=not-a-valid-token");
|
||||
expect(malformed.status).toBe(401);
|
||||
expect(malformed.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
214
packages/dashboard/src/__tests__/routes-remote-access.test.ts
Normal file
214
packages/dashboard/src/__tests__/routes-remote-access.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// @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() {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 API route contracts", () => {
|
||||
it("supports GET and PUT /api/remote/settings", async () => {
|
||||
const store = createMockStore({
|
||||
updateSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
|
||||
getSettings: vi.fn()
|
||||
.mockResolvedValueOnce({ remoteAccess: buildRemoteAccessSettings() })
|
||||
.mockResolvedValueOnce({ remoteAccess: { ...buildRemoteAccessSettings(), activeProvider: "tailscale" } }),
|
||||
});
|
||||
const { app } = createApp({ store });
|
||||
|
||||
const getRes = await REQUEST(app, "GET", "/api/remote/settings");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toMatchObject({
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "cloudflare",
|
||||
}),
|
||||
});
|
||||
|
||||
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 180000,
|
||||
});
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body).toMatchObject({
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 180000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("supports provider activation and tunnel lifecycle endpoints", async () => {
|
||||
const engine = {
|
||||
startRemoteTunnel: vi.fn().mockResolvedValue({
|
||||
state: "running",
|
||||
provider: "cloudflare",
|
||||
url: "https://remote.example.com",
|
||||
lastError: null,
|
||||
}),
|
||||
stopRemoteTunnel: vi.fn().mockResolvedValue({
|
||||
state: "stopped",
|
||||
provider: null,
|
||||
url: null,
|
||||
lastError: null,
|
||||
}),
|
||||
};
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createMockStore({ updateSettings });
|
||||
const { app } = createApp({ store, engine });
|
||||
|
||||
const activateRes = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "tailscale" });
|
||||
expect(activateRes.status).toBe(200);
|
||||
expect(activateRes.body).toEqual({ activeProvider: "tailscale" });
|
||||
|
||||
const startRes = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
|
||||
expect(startRes.status).toBe(200);
|
||||
expect(["starting", "running"]).toContain(startRes.body.state);
|
||||
expect(startRes.body.provider).toBe("cloudflare");
|
||||
|
||||
const stopRes = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
|
||||
expect(stopRes.status).toBe(200);
|
||||
expect(stopRes.body.state).toBe("stopped");
|
||||
expect(stopRes.body.provider).toBe("cloudflare");
|
||||
|
||||
expect(engine.startRemoteTunnel.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
expect(engine.stopRemoteTunnel.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
remoteAccess: expect.objectContaining({ activeProvider: "tailscale" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("supports persistent and short-lived token endpoints plus URL/QR contracts", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
const persistent = await REQUEST(app, "POST", "/api/remote/token/persistent/regenerate", {});
|
||||
expect(persistent.status).toBe(200);
|
||||
expect(persistent.body).toMatchObject({
|
||||
token: expect.stringMatching(/^frt_[A-Za-z0-9_-]+$/),
|
||||
maskedToken: expect.any(String),
|
||||
});
|
||||
|
||||
const shortLived = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 120000 });
|
||||
expect(shortLived.status).toBe(200);
|
||||
expect(shortLived.body).toMatchObject({
|
||||
token: expect.stringMatching(/^frt_[A-Za-z0-9_-]+$/),
|
||||
expiresAt: expect.any(String),
|
||||
});
|
||||
expect(shortLived.body.ttlMs).toBeGreaterThanOrEqual(119000);
|
||||
expect(shortLived.body.ttlMs).toBeLessThanOrEqual(120000);
|
||||
|
||||
const shortLivedBounded = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 1000 });
|
||||
expect(shortLivedBounded.status).toBe(200);
|
||||
expect(shortLivedBounded.body.ttlMs).toBeGreaterThanOrEqual(60000);
|
||||
|
||||
const shortLivedMaxBounded = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 200000000 });
|
||||
expect(shortLivedMaxBounded.status).toBe(200);
|
||||
expect(shortLivedMaxBounded.body.ttlMs).toBeLessThanOrEqual(86400000);
|
||||
|
||||
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=short-lived");
|
||||
expect(urlRes.status).toBe(200);
|
||||
expect(urlRes.body).toMatchObject({
|
||||
url: expect.stringContaining("/remote-login?rt="),
|
||||
tokenType: "short-lived",
|
||||
expiresAt: expect.any(String),
|
||||
});
|
||||
|
||||
const qrText = await REQUEST(app, "GET", "/api/remote/qr?format=text&tokenType=persistent");
|
||||
expect(qrText.status).toBe(200);
|
||||
expect(qrText.body).toMatchObject({
|
||||
format: "text",
|
||||
data: expect.stringContaining("/remote-login?rt="),
|
||||
tokenType: "persistent",
|
||||
});
|
||||
|
||||
const qrSvg = await REQUEST(app, "GET", "/api/remote/qr?format=image/svg&tokenType=short-lived");
|
||||
expect(qrSvg.status).toBe(200);
|
||||
expect(qrSvg.body).toMatchObject({
|
||||
format: "image/svg",
|
||||
data: expect.stringContaining("<svg"),
|
||||
tokenType: "short-lived",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -308,6 +308,39 @@ describe("createServer health and headless mode", () => {
|
||||
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, 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] = 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" }),
|
||||
]);
|
||||
|
||||
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=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Error Handling Middleware", () => {
|
||||
|
||||
@@ -263,30 +263,113 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
// ── Remote Access Routes ────────────────────────────────────────────
|
||||
|
||||
function toRemoteSettingsPayload(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>) {
|
||||
const persistentToken = remoteAccess.tokenStrategy.persistent.token?.trim() ?? "";
|
||||
return {
|
||||
remoteEnabled: Boolean(remoteAccess.enabled),
|
||||
remoteActiveProvider: remoteAccess.activeProvider ?? null,
|
||||
remoteTailscaleEnabled: Boolean(remoteAccess.providers.tailscale.enabled),
|
||||
remoteTailscaleHostname: remoteAccess.providers.tailscale.hostname,
|
||||
remoteTailscaleTargetPort: Number(remoteAccess.providers.tailscale.targetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(remoteAccess.providers.tailscale.acceptRoutes),
|
||||
remoteCloudflareEnabled: Boolean(remoteAccess.providers.cloudflare.enabled),
|
||||
remoteCloudflareTunnelName: remoteAccess.providers.cloudflare.tunnelName,
|
||||
remoteCloudflareTunnelToken: remoteAccess.providers.cloudflare.tunnelToken,
|
||||
remoteCloudflareIngressUrl: remoteAccess.providers.cloudflare.ingressUrl,
|
||||
remoteShortLivedEnabled: Boolean(remoteAccess.tokenStrategy.shortLived.enabled),
|
||||
remoteShortLivedTtlMs: Number(remoteAccess.tokenStrategy.shortLived.ttlMs ?? 900_000),
|
||||
remoteShortLivedMaxTtlMs: Number(remoteAccess.tokenStrategy.shortLived.maxTtlMs ?? 86_400_000),
|
||||
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
|
||||
remoteRememberLastRunning: Boolean(remoteAccess.lifecycle.rememberLastRunning),
|
||||
remoteWasRunningOnShutdown: Boolean(remoteAccess.lifecycle.wasRunningOnShutdown),
|
||||
remoteLastStartedProvider: remoteAccess.lifecycle.lastRunningProvider ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/remote/settings", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
const persistentToken = remoteAccess?.tokenStrategy.persistent.token?.trim() ?? "";
|
||||
|
||||
res.json({
|
||||
settings: {
|
||||
remoteEnabled: Boolean(remoteAccess?.enabled),
|
||||
remoteActiveProvider: remoteAccess?.activeProvider ?? null,
|
||||
remoteTailscaleEnabled: Boolean(remoteAccess?.providers.tailscale.enabled),
|
||||
remoteCloudflareEnabled: Boolean(remoteAccess?.providers.cloudflare.enabled),
|
||||
remoteShortLivedEnabled: Boolean(remoteAccess?.tokenStrategy.shortLived.enabled),
|
||||
remoteShortLivedTtlMs: Number(remoteAccess?.tokenStrategy.shortLived.ttlMs ?? 900_000),
|
||||
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
|
||||
},
|
||||
});
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to load remote settings");
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/remote/settings", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const nextRemoteAccess = {
|
||||
...remoteAccess,
|
||||
enabled: body.remoteEnabled === undefined ? remoteAccess.enabled : Boolean(body.remoteEnabled),
|
||||
activeProvider: body.remoteActiveProvider === undefined
|
||||
? remoteAccess.activeProvider
|
||||
: (body.remoteActiveProvider as "tailscale" | "cloudflare" | null),
|
||||
providers: {
|
||||
tailscale: {
|
||||
...remoteAccess.providers.tailscale,
|
||||
enabled: body.remoteTailscaleEnabled === undefined ? remoteAccess.providers.tailscale.enabled : Boolean(body.remoteTailscaleEnabled),
|
||||
hostname: body.remoteTailscaleHostname === undefined ? remoteAccess.providers.tailscale.hostname : String(body.remoteTailscaleHostname ?? ""),
|
||||
targetPort: body.remoteTailscaleTargetPort === undefined ? remoteAccess.providers.tailscale.targetPort : Number(body.remoteTailscaleTargetPort ?? 4040),
|
||||
acceptRoutes: body.remoteTailscaleAcceptRoutes === undefined ? remoteAccess.providers.tailscale.acceptRoutes : Boolean(body.remoteTailscaleAcceptRoutes),
|
||||
},
|
||||
cloudflare: {
|
||||
...remoteAccess.providers.cloudflare,
|
||||
enabled: body.remoteCloudflareEnabled === undefined ? remoteAccess.providers.cloudflare.enabled : Boolean(body.remoteCloudflareEnabled),
|
||||
tunnelName: body.remoteCloudflareTunnelName === undefined ? remoteAccess.providers.cloudflare.tunnelName : String(body.remoteCloudflareTunnelName ?? ""),
|
||||
tunnelToken: body.remoteCloudflareTunnelToken === undefined
|
||||
? remoteAccess.providers.cloudflare.tunnelToken
|
||||
: (body.remoteCloudflareTunnelToken ? String(body.remoteCloudflareTunnelToken) : null),
|
||||
ingressUrl: body.remoteCloudflareIngressUrl === undefined ? remoteAccess.providers.cloudflare.ingressUrl : String(body.remoteCloudflareIngressUrl ?? ""),
|
||||
},
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: {
|
||||
...remoteAccess.tokenStrategy.persistent,
|
||||
enabled: body.remotePersistentEnabled === undefined ? remoteAccess.tokenStrategy.persistent.enabled : Boolean(body.remotePersistentEnabled),
|
||||
token: body.remotePersistentToken === undefined
|
||||
? remoteAccess.tokenStrategy.persistent.token
|
||||
: (body.remotePersistentToken ? String(body.remotePersistentToken) : null),
|
||||
},
|
||||
shortLived: {
|
||||
...remoteAccess.tokenStrategy.shortLived,
|
||||
enabled: body.remoteShortLivedEnabled === undefined ? remoteAccess.tokenStrategy.shortLived.enabled : Boolean(body.remoteShortLivedEnabled),
|
||||
ttlMs: body.remoteShortLivedTtlMs === undefined ? remoteAccess.tokenStrategy.shortLived.ttlMs : Number(body.remoteShortLivedTtlMs ?? 900_000),
|
||||
maxTtlMs: body.remoteShortLivedMaxTtlMs === undefined ? remoteAccess.tokenStrategy.shortLived.maxTtlMs : Number(body.remoteShortLivedMaxTtlMs ?? 86_400_000),
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
...remoteAccess.lifecycle,
|
||||
rememberLastRunning: body.remoteRememberLastRunning === undefined ? remoteAccess.lifecycle.rememberLastRunning : Boolean(body.remoteRememberLastRunning),
|
||||
wasRunningOnShutdown: body.remoteWasRunningOnShutdown === undefined ? remoteAccess.lifecycle.wasRunningOnShutdown : Boolean(body.remoteWasRunningOnShutdown),
|
||||
lastRunningProvider: body.remoteLastStartedProvider === undefined
|
||||
? remoteAccess.lifecycle.lastRunningProvider
|
||||
: (body.remoteLastStartedProvider as "tailscale" | "cloudflare" | null),
|
||||
},
|
||||
};
|
||||
|
||||
await scopedStore.updateSettings({ remoteAccess: nextRemoteAccess });
|
||||
res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to update remote settings");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/remote/status", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
@@ -452,7 +535,10 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
: remoteAccess;
|
||||
|
||||
const issued = issueRemoteAuthToken("short-lived", modeSettings);
|
||||
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: modeSettings.tokenStrategy.shortLived.ttlMs });
|
||||
const effectiveTtlMs = issued.expiresAt
|
||||
? Math.max(0, Date.parse(issued.expiresAt) - Date.now())
|
||||
: modeSettings.tokenStrategy.shortLived.ttlMs;
|
||||
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: effectiveTtlMs });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate short-lived token");
|
||||
|
||||
@@ -486,6 +486,75 @@ describe("ProjectEngine remote lifecycle restore policy", () => {
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists shutdown lifecycle markers and deterministically restores on next engine start", async () => {
|
||||
const restoreSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
activeProvider: "cloudflare" as const,
|
||||
lifecycle: {
|
||||
...baseRemoteAccess.lifecycle,
|
||||
rememberLastRunning: true,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(restoreSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined);
|
||||
const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValue(undefined);
|
||||
const getStatusSpy = vi.spyOn(TunnelProcessManager.prototype, "getStatus")
|
||||
.mockReturnValueOnce({
|
||||
provider: "cloudflare",
|
||||
state: "running",
|
||||
pid: 4321,
|
||||
startedAt: "2026-04-26T12:00:00.000Z",
|
||||
stoppedAt: null,
|
||||
url: "https://remote.example.com",
|
||||
lastError: null,
|
||||
})
|
||||
.mockReturnValue({
|
||||
provider: null,
|
||||
state: "stopped",
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
stoppedAt: "2026-04-26T12:05:00.000Z",
|
||||
url: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const firstEngine = createEngine();
|
||||
await firstEngine.start();
|
||||
await firstEngine.stop();
|
||||
|
||||
const persistedSettings = mockStore.getCurrentSettings() as {
|
||||
remoteAccess?: { lifecycle?: { wasRunningOnShutdown?: boolean; lastRunningProvider?: string | null } };
|
||||
};
|
||||
expect(persistedSettings.remoteAccess?.lifecycle).toMatchObject({
|
||||
wasRunningOnShutdown: true,
|
||||
lastRunningProvider: "cloudflare",
|
||||
});
|
||||
|
||||
const secondEngine = createEngine();
|
||||
await secondEngine.start();
|
||||
|
||||
expect(startSpy).toHaveBeenCalled();
|
||||
expect(secondEngine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({
|
||||
outcome: "applied",
|
||||
reason: "restore_started",
|
||||
provider: "cloudflare",
|
||||
});
|
||||
|
||||
await secondEngine.stop();
|
||||
expect(stopSpy).toHaveBeenCalled();
|
||||
|
||||
startSpy.mockRestore();
|
||||
stopSpy.mockRestore();
|
||||
getStatusSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reconciles stale persisted running marker to avoid restore loops", async () => {
|
||||
const restoreSettings = {
|
||||
...baseSettings,
|
||||
|
||||
@@ -107,6 +107,44 @@ describe("TunnelProcessManager", () => {
|
||||
expect(allLogs).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("transitions start→running and stop→stopped, with idempotent repeated stop", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
|
||||
|
||||
await manager.start("cloudflare", cloudflareConfig());
|
||||
const child = [...children.values()][0];
|
||||
child.emitStdout("Tunnel ready https://demo.trycloudflare.com");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getStatus().state).toBe("running");
|
||||
});
|
||||
|
||||
await manager.stop();
|
||||
expect(manager.getStatus().state).toBe("stopped");
|
||||
|
||||
// Idempotent: repeated stop keeps manager in a deterministic stopped state.
|
||||
await manager.stop();
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
provider: null,
|
||||
state: "stopped",
|
||||
pid: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
expect(states).toContain("starting");
|
||||
expect(states).toContain("running");
|
||||
expect(states).toContain("stopping");
|
||||
expect(states).toContain("stopped");
|
||||
});
|
||||
|
||||
it("falls back to SIGKILL when graceful stop times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -146,7 +184,47 @@ describe("TunnelProcessManager", () => {
|
||||
expect(manager.getStatus().state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("switchProvider stops active provider before emitting switch_failed on target start failure", async () => {
|
||||
it("switchProvider stops active provider before starting target provider", async () => {
|
||||
const order: string[] = [];
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
order.push("spawn");
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
manager.subscribeStatus((snapshot) => order.push(`state:${snapshot.state}`));
|
||||
|
||||
await manager.start("tailscale", {
|
||||
provider: "tailscale",
|
||||
executablePath: "tailscale",
|
||||
args: ["serve", "status"],
|
||||
});
|
||||
const initialChild = [...children.values()][0];
|
||||
initialChild.emitStdout("Serve started https://machine.ts.net");
|
||||
await vi.waitFor(() => expect(manager.getStatus().state).toBe("running"));
|
||||
|
||||
const switchPromise = manager.switchProvider("cloudflare", cloudflareConfig());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(processKillSpy).toHaveBeenCalledWith(expect.any(Number), "SIGTERM");
|
||||
});
|
||||
|
||||
const cloudflareChild = [...children.values()].at(-1);
|
||||
cloudflareChild?.emitStdout("Connected https://demo.trycloudflare.com");
|
||||
await switchPromise;
|
||||
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
state: "running",
|
||||
provider: "cloudflare",
|
||||
url: "https://demo.trycloudflare.com",
|
||||
});
|
||||
expect(order.indexOf("state:stopping")).toBeLessThan(order.lastIndexOf("spawn"));
|
||||
});
|
||||
|
||||
it("switchProvider failure is rollback-safe and never leaks raw token values", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: vi
|
||||
.fn()
|
||||
@@ -156,12 +234,14 @@ describe("TunnelProcessManager", () => {
|
||||
return child as never;
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("cloudflare launcher boom");
|
||||
throw new Error("cloudflare launcher boom token=secret-token");
|
||||
}),
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
const logs: string[] = [];
|
||||
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
|
||||
manager.subscribeLogs((entry) => logs.push(entry.message));
|
||||
|
||||
await manager.start("tailscale", {
|
||||
provider: "tailscale",
|
||||
@@ -182,5 +262,8 @@ describe("TunnelProcessManager", () => {
|
||||
expect(finalStatus.provider).toBe("cloudflare");
|
||||
expect(states).toContain("stopping");
|
||||
expect(states).toContain("stopped");
|
||||
|
||||
const logText = logs.join("\n");
|
||||
expect(logText).not.toContain("secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,7 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
|
||||
await this.startInternal(target, config);
|
||||
} catch (error) {
|
||||
const stateError = toStateError("switch_failed", error);
|
||||
const redactedMessage = this.redactForProviderConfig(target, config, stateError.message);
|
||||
this.updateStatus({
|
||||
provider: target,
|
||||
state: "failed",
|
||||
@@ -178,14 +179,27 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
|
||||
startedAt: null,
|
||||
stoppedAt: nowIso(),
|
||||
url: null,
|
||||
lastError: stateError,
|
||||
lastError: {
|
||||
...stateError,
|
||||
message: redactedMessage,
|
||||
},
|
||||
});
|
||||
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${stateError.message}`);
|
||||
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${redactedMessage}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private redactForProviderConfig(provider: TunnelProvider, config: TunnelProviderConfig, message: string): string {
|
||||
try {
|
||||
const adapter = getTunnelProviderAdapter(provider);
|
||||
const command = adapter.buildCommand(config);
|
||||
return redactTunnelText(message, command.sensitiveValues);
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive(operation: () => Promise<void>): Promise<void> {
|
||||
const next = this.operationChain.then(operation);
|
||||
this.operationChain = next.catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user