diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index a7312f71bb45..f51d8695bb8a 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -324,6 +324,46 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); + it("reports generic WebSocket construction failures without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + throw new TypeError("constructor failed"); + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + expect(onClose).toHaveBeenCalledWith({ + code: 1006, + reason: "websocket error", + error: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + message: expect.stringContaining("Could not create the Gateway WebSocket"), + details: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + browserErrorName: "TypeError", + browserMessage: "constructor failed", + }), + }), + }); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + it("reports request timing for attributed RPC latency", async () => { const onRequestTiming = vi.fn(); const client = new GatewayBrowserClient({ diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index 219f2dcf5ee5..13212c78f6c2 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -115,6 +115,36 @@ describe("resolveLoginFailureFeedback", () => { expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); }); + it("explains browser WebSocket security failures as insecure context", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: + "Browser refused the Gateway WebSocket for security reasons. Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789.", + lastErrorCode: "BROWSER_WEBSOCKET_SECURITY_ERROR", + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("insecure-context"); + expect(feedback?.rawError).toContain("Use wss://"); + expect(feedback?.rawError).toContain("http://127.0.0.1:18789"); + expect(feedback?.steps.join(" ")).toContain("Tailscale Serve"); + expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); + }); + + it("keeps generic browser WebSocket constructor failures on the network path", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "Could not create the Gateway WebSocket: constructor failed", + lastErrorCode: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("network"); + expect(feedback?.steps.join(" ")).toContain("WebSocket URL"); + }); + it("explains browser origin rejections", () => { const feedback = resolveLoginFailureFeedback({ connected: false, diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index ecc184ef7f7d..d3f759c30da1 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -25,7 +25,10 @@ const AUTH_FAILURE_CODES = new Set([ ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, ]); +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; + const INSECURE_CONTEXT_CODES = new Set([ + BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, ]); diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 33500d1dae63..a806ec56727b 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -4,6 +4,7 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connec import { resolveAuthHintKind, resolvePairingHint, + shouldShowInsecureContextHint, shouldShowPairingHint, } from "./overview-hints.ts"; @@ -107,3 +108,25 @@ describe("resolveAuthHintKind", () => { ).toBe("failed"); }); }); + +describe("shouldShowInsecureContextHint", () => { + it("returns true for browser WebSocket security errors", () => { + expect( + shouldShowInsecureContextHint( + false, + "Browser refused the Gateway WebSocket for security reasons.", + "BROWSER_WEBSOCKET_SECURITY_ERROR", + ), + ).toBe(true); + }); + + it("does not treat generic WebSocket constructor errors as insecure context", () => { + expect( + shouldShowInsecureContextHint( + false, + "Could not create the Gateway WebSocket: constructor failed", + "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + ), + ).toBe(false); + }); +});