Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/sh
import Toggle from "@/shared/components/Toggle";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { isPublicDisplayBaseUrl, useDisplayBaseUrl } from "@/shared/hooks";
import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage";
import { useTranslations } from "next-intl";
import A2ADashboardPage from "./components/A2ADashboard";
import McpDashboardPage from "./components/MCPDashboard";
Expand Down Expand Up @@ -463,9 +464,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
return DEFAULT_TUNNEL_VISIBILITY;
};

// Moved below the loader/fetcher declarations it schedules — referencing them from
// an effect declared above their `const` bindings is a TDZ read the compiler rejects
// (react-hooks/immutability).
useEffect(() => {
let mounted = true;

Expand Down Expand Up @@ -708,8 +706,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps

if (!res.ok) {
throw new Error(
data?.error ||
extractApiErrorMessage(
data,
translateOrFallback("cloudflaredRequestFailed", "Failed to update Cloudflare tunnel")
)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// node:test regression for #15294. The dashboard test next to EndpointPageClient
// mounts the page under vitest/jsdom. This file is the runner the fix is
// required to pass with `node --import tsx/esm --test`: the same object body
// the route guard returns, formatted the way the tunnel notice formats it.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { extractApiErrorMessage } from "../../../../../shared/http/apiErrorMessage.ts";

const LOCAL_ONLY_BODY = {
error: {
code: "LOCAL_ONLY",
message: "This endpoint requires localhost access",
correlation_id: "633dd230-033a-40e7-865f-db8048cb12d9",
},
};

const FALLBACK = "Failed to update Cloudflare tunnel";

function tunnelNoticeText(body: unknown): string {
return new Error(extractApiErrorMessage(body, FALLBACK)).message;
}

const source = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), "../EndpointPageClient.tsx"),
"utf8"
);

test("a LOCAL_ONLY object body renders its message, not [object Object] (#15294)", () => {
const before = new Error(LOCAL_ONLY_BODY.error as unknown as string).message;
assert.equal(before, "[object Object]");

const rendered = tunnelNoticeText(LOCAL_ONLY_BODY);
assert.equal(rendered, "This endpoint requires localhost access");
assert.equal(rendered.includes("[object Object]"), false);
});

test("a string error body is still shown as that string (#15294)", () => {
assert.equal(
tunnelNoticeText({ error: "cloudflared is not installed" }),
"cloudflared is not installed"
);
assert.equal(tunnelNoticeText({}), FALLBACK);
assert.equal(tunnelNoticeText(null), FALLBACK);
});

test("the cloudflared action formats the body through extractApiErrorMessage (#15294)", () => {
const start = source.indexOf("const handleCloudflaredAction");
const end = source.indexOf("const handleNgrokAction");
const action = source.slice(start, end);
assert.match(action, /extractApiErrorMessage\(\s*data,/);
assert.doesNotMatch(action, /data\?\.error\s*\|\|/);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
// Regression for #15294. POST /api/tunnels/cloudflared can be rejected by the
// route guard with `{ error: { code, message, correlation_id } }`. The endpoint
// page passed that object into `new Error(...)`, so the red notice rendered
// "[object Object]" instead of the message inside the body.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import EndpointPageClient from "../EndpointPageClient";

const LOCAL_ONLY_BODY = {
error: {
code: "LOCAL_ONLY",
message: "This endpoint requires localhost access",
correlation_id: "633dd230-033a-40e7-865f-db8048cb12d9",
},
};

const STOPPED_STATUS = {
supported: true,
installed: true,
managedInstall: false,
installSource: null,
binaryPath: null,
running: false,
pid: null,
publicUrl: null,
apiUrl: null,
targetUrl: "http://127.0.0.1:20128",
phase: "stopped",
lastError: null,
logPath: "",
};

const roots: Array<{ root: Root; el: HTMLDivElement }> = [];

function requestPath(input: RequestInfo | URL) {
return typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url;
}

async function renderPage(): Promise<HTMLDivElement> {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<EndpointPageClient machineId="" />);
});
roots.push({ root, el });
return el;
}

async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
}

describe("#15294 cloudflare tunnel object error body", () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const store = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = requestPath(input);
if (path === "/api/settings") {
return new Response(
JSON.stringify({
cloudEnabled: false,
cloudConfigured: false,
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: true,
hideEndpointNgrokTunnel: true,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (path === "/api/tunnels/cloudflared" && (!init?.method || init.method === "GET")) {
return new Response(JSON.stringify(STOPPED_STATUS), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (path === "/api/tunnels/cloudflared" && init?.method === "POST") {
return new Response(JSON.stringify(LOCAL_ONLY_BODY), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
if (
path === "/v1/models" ||
path === "/api/mcp/status" ||
path === "/api/a2a/status" ||
path === "/api/search/providers" ||
path === "/api/cli-tools/keys" ||
path === "/api/network/info"
) {
return new Response(JSON.stringify({ data: [], providers: [], online: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", {
status: 200,
headers: { "Content-Type": "application/json" },
});
})
);
});

afterEach(() => {
while (roots.length > 0) {
const entry = roots.pop();
if (!entry) break;
act(() => {
entry.root.unmount();
});
entry.el.remove();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("renders the object error message instead of [object Object]", async () => {
const el = await renderPage();
await flush();

const button = Array.from(el.querySelectorAll("button")).find((node) =>
(node.textContent || "").includes("Enable Tunnel")
);
expect(button).toBeTruthy();

await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();

const text = el.textContent || "";
expect(text).toContain("This endpoint requires localhost access");
expect(text).not.toContain("[object Object]");
});
});
Loading