Files
multica/packages/core/api/ws-client.test.ts
LinYushen b624cd98ad feat: identify clients via X-Client-Platform/Version/OS (#1477)
* feat: identify clients via X-Client-Platform/Version/OS

Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.

- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}

Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).

Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.

CORS allowlist extended for the new headers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: address client-identity PR nits

- Memoize the CoreProvider identity object on Web and Desktop, and key
  WSProvider's effect on identity primitives instead of the object
  reference, so unrelated parent re-renders no longer tear down and
  reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
  clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
  on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
  client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
  and asserts the "websocket connected" log entry surfaces them as
  structured attributes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:36:13 +08:00

73 lines
2.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WSClient } from "./ws-client";
// Capture URL passed to WebSocket so we can assert the connect-time
// query string. We don't simulate the full WS lifecycle here — only the
// upgrade URL construction, which is what carries client identity.
class FakeWebSocket {
static lastUrl: string | null = null;
// Fields read by WSClient.connect()/disconnect(), all no-op here.
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
readyState = 0;
constructor(url: string) {
FakeWebSocket.lastUrl = url;
}
close() {}
send() {}
}
describe("WSClient", () => {
beforeEach(() => {
FakeWebSocket.lastUrl = null;
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("includes client identity in the upgrade URL when configured", () => {
const ws = new WSClient("ws://example.test/ws", {
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
});
ws.setAuth("tok", "acme");
ws.connect();
const url = new URL(FakeWebSocket.lastUrl!);
expect(url.searchParams.get("workspace_slug")).toBe("acme");
expect(url.searchParams.get("client_platform")).toBe("desktop");
expect(url.searchParams.get("client_version")).toBe("1.2.3");
expect(url.searchParams.get("client_os")).toBe("macos");
// Token must never appear in the URL — it is delivered as the first
// WS message in token mode.
expect(url.searchParams.has("token")).toBe(false);
});
it("omits client_* params when identity is not configured", () => {
const ws = new WSClient("ws://example.test/ws");
ws.setAuth("tok", "acme");
ws.connect();
const url = new URL(FakeWebSocket.lastUrl!);
expect(url.searchParams.has("client_platform")).toBe(false);
expect(url.searchParams.has("client_version")).toBe(false);
expect(url.searchParams.has("client_os")).toBe(false);
});
it("only includes the identity fields that are set", () => {
const ws = new WSClient("ws://example.test/ws", {
identity: { platform: "cli" },
});
ws.setAuth("tok", "acme");
ws.connect();
const url = new URL(FakeWebSocket.lastUrl!);
expect(url.searchParams.get("client_platform")).toBe("cli");
expect(url.searchParams.has("client_version")).toBe(false);
expect(url.searchParams.has("client_os")).toBe(false);
});
});