fix(control-ui): classify websocket security errors

This commit is contained in:
Val Alexander
2026-05-11 06:54:05 -05:00
parent 2cabd37162
commit 1ea05289b1
4 changed files with 96 additions and 0 deletions
+40
View File
@@ -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({
+30
View File
@@ -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,
+3
View File
@@ -25,7 +25,10 @@ const AUTH_FAILURE_CODES = new Set<string>([
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR";
const INSECURE_CONTEXT_CODES = new Set<string>([
BROWSER_WEBSOCKET_SECURITY_ERROR_CODE,
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
+23
View File
@@ -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);
});
});