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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user