From 8ec79250e626a93b597826a6ac465db897ff5c23 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:56:01 -0700 Subject: [PATCH] Chore: handle cloudflare breaking API change dropping connections (#7217) --- src/widgets/cloudflared/component.jsx | 11 ++- src/widgets/cloudflared/component.test.jsx | 100 ++++++++++++++------- src/widgets/cloudflared/widget.js | 8 +- src/widgets/cloudflared/widget.test.js | 14 ++- 4 files changed, 93 insertions(+), 40 deletions(-) diff --git a/src/widgets/cloudflared/component.jsx b/src/widgets/cloudflared/component.jsx index abae61bb0..89a66ca12 100644 --- a/src/widgets/cloudflared/component.jsx +++ b/src/widgets/cloudflared/component.jsx @@ -6,9 +6,10 @@ export default function Component({ service }) { const { widget } = service; const { data: statsData, error: statsError } = useWidgetAPI(widget, "cfd_tunnel"); + const { data: connectionsData, error: connectionsError } = useWidgetAPI(widget, "connections"); - if (statsError) { - return ; + if (statsError || connectionsError) { + return ; } if (!statsData) { @@ -20,7 +21,9 @@ export default function Component({ service }) { ); } - const originIP = statsData.result.connections?.origin_ip ?? statsData.result.connections[0]?.origin_ip; + const originIP = connectionsData?.result + .flatMap((connector) => connector.conns ?? []) + .find((connection) => connection.origin_ip)?.origin_ip; return ( @@ -28,7 +31,7 @@ export default function Component({ service }) { label="cloudflared.status" value={statsData.result.status.charAt(0).toUpperCase() + statsData.result.status.slice(1)} /> - + ); } diff --git a/src/widgets/cloudflared/component.test.jsx b/src/widgets/cloudflared/component.test.jsx index 9a81a0835..c6f279242 100644 --- a/src/widgets/cloudflared/component.test.jsx +++ b/src/widgets/cloudflared/component.test.jsx @@ -4,59 +4,93 @@ import { screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils/render-with-providers"; -import { expectBlockValue } from "test-utils/widget-assertions"; +import { expectBlockValue, findServiceBlockByLabel } from "test-utils/widget-assertions"; const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); - vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); import Component from "./component"; describe("widgets/cloudflared/component", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.resetAllMocks()); - it("renders placeholders while loading", () => { - useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + function mockResponses(stats, connections) { + useWidgetAPI.mockImplementation((_widget, endpoint) => { + if (endpoint === "cfd_tunnel") return stats; + if (endpoint === "connections") return connections; + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + } - const { container } = renderWithProviders(, { + function renderWidget() { + return renderWithProviders(, { settings: { hideErrors: false }, }); + } - expect(container.querySelectorAll(".service-block")).toHaveLength(2); + it("renders placeholders while both requests are loading", () => { + mockResponses({ data: undefined }, { data: undefined }); + const { container } = renderWidget(); + expect(container.querySelectorAll(".service-block.animate-pulse")).toHaveLength(2); expect(screen.getByText("cloudflared.status")).toBeInTheDocument(); expect(screen.getByText("cloudflared.origin_ip")).toBeInTheDocument(); }); - it("renders status capitalization and origin_ip from nested connections", () => { - useWidgetAPI.mockReturnValue({ - data: { - result: { status: "healthy", connections: { origin_ip: "1.2.3.4" } }, - }, - error: undefined, - }); - - const { container } = renderWithProviders(, { - settings: { hideErrors: false }, - }); - + it("renders status without the retired connections field and reads connector IPs", () => { + mockResponses( + { data: { result: { status: "healthy" } } }, + { data: { result: [{ conns: [{ origin_ip: "1.2.3.4" }] }] } }, + ); + const { container } = renderWidget(); expectBlockValue(container, "cloudflared.status", "Healthy"); expectBlockValue(container, "cloudflared.origin_ip", "1.2.3.4"); }); - it("falls back to origin_ip from first connection entry", () => { - useWidgetAPI.mockReturnValue({ - data: { - result: { status: "down", connections: [{ origin_ip: "5.6.7.8" }] }, - }, - error: undefined, - }); - - const { container } = renderWithProviders(, { - settings: { hideErrors: false }, - }); - + it("finds the first available IP across connectors and connections", () => { + mockResponses( + { data: { result: { status: "degraded", connections: [{ origin_ip: "old-ip" }] } } }, + { data: { result: [{}, { conns: [] }, { conns: [{}, { origin_ip: "5.6.7.8" }, { origin_ip: "9.10.11.12" }] }] } }, + ); + const { container } = renderWidget(); + expectBlockValue(container, "cloudflared.status", "Degraded"); expectBlockValue(container, "cloudflared.origin_ip", "5.6.7.8"); + expect(container.textContent).not.toContain("old-ip"); + }); + + it.each([ + { connectors: [] }, + { connectors: [{}] }, + { connectors: [{ conns: [] }] }, + { connectors: [{ conns: [{}] }] }, + ])("renders a settled placeholder when no connector has an IP ($connectors)", ({ connectors }) => { + mockResponses({ data: { result: { status: "down" } } }, { data: { result: connectors } }); + const { container } = renderWidget(); + expectBlockValue(container, "cloudflared.status", "Down"); + expectBlockValue(container, "cloudflared.origin_ip", "-"); + expect(findServiceBlockByLabel(container, "cloudflared.origin_ip")).not.toHaveClass("animate-pulse"); + }); + + it("renders status while connector details are loading", () => { + mockResponses({ data: { result: { status: "healthy" } } }, { data: undefined }); + const { container } = renderWidget(); + expectBlockValue(container, "cloudflared.status", "Healthy"); + expect(findServiceBlockByLabel(container, "cloudflared.origin_ip")).toHaveClass("animate-pulse"); + }); + + it("keeps placeholders when connectors load before tunnel status", () => { + mockResponses({ data: undefined }, { data: { result: [{ conns: [{ origin_ip: "1.2.3.4" }] }] } }); + const { container } = renderWidget(); + expect(container.querySelectorAll(".service-block.animate-pulse")).toHaveLength(2); + }); + + it.each(["cfd_tunnel", "connections"])("shows errors from the %s request", (failedEndpoint) => { + useWidgetAPI.mockImplementation((_widget, endpoint) => + endpoint === failedEndpoint + ? { error: { message: "Cloudflare request failed" } } + : { data: endpoint === "cfd_tunnel" ? { result: { status: "healthy" } } : { result: [] } }, + ); + const { container } = renderWidget(); + expect(container.querySelectorAll(".service-block")).toHaveLength(0); + expect(screen.getByText(/Cloudflare request failed/)).toBeInTheDocument(); }); }); diff --git a/src/widgets/cloudflared/widget.js b/src/widgets/cloudflared/widget.js index b2019cff0..f9c19ae1a 100644 --- a/src/widgets/cloudflared/widget.js +++ b/src/widgets/cloudflared/widget.js @@ -1,12 +1,16 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; const widget = { - api: "https://api.cloudflare.com/client/v4/accounts/{accountid}/{endpoint}/{tunnelid}", + api: "https://api.cloudflare.com/client/v4/accounts/{accountid}/{endpoint}", proxyHandler: credentialedProxyHandler, mappings: { cfd_tunnel: { - endpoint: "cfd_tunnel", + endpoint: "cfd_tunnel/{tunnelid}", + validate: ["success", "result"], + }, + connections: { + endpoint: "cfd_tunnel/{tunnelid}/connections", validate: ["success", "result"], }, }, diff --git a/src/widgets/cloudflared/widget.test.js b/src/widgets/cloudflared/widget.test.js index cd2f38327..14008dcd4 100644 --- a/src/widgets/cloudflared/widget.test.js +++ b/src/widgets/cloudflared/widget.test.js @@ -1,6 +1,7 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { expectWidgetConfigShape } from "test-utils/widget-config"; +import { formatApiCall } from "utils/proxy/api-helpers"; import widget from "./widget"; @@ -8,4 +9,15 @@ describe("cloudflared widget config", () => { it("exports a valid widget config", () => { expectWidgetConfigShape(widget); }); + + it.each([ + ["cfd_tunnel", "cfd_tunnel/tunnel-id"], + ["connections", "cfd_tunnel/tunnel-id/connections"], + ])("routes %s to the account and tunnel", (endpoint, path) => { + const mapping = widget.mappings[endpoint]; + expect( + formatApiCall(widget.api, { accountid: "account-id", tunnelid: "tunnel-id", endpoint: mapping.endpoint }), + ).toBe(`https://api.cloudflare.com/client/v4/accounts/account-id/${path}`); + expect(mapping.validate).toEqual(["success", "result"]); + }); });