Files
multica/server/internal/daemon/client_test.go
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

98 lines
2.9 KiB
Go

package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"testing"
)
func TestClient_IdentityHeaders_PostJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
t.Errorf("expected X-Client-Platform daemon, got %q", got)
}
if got := r.Header.Get("X-Client-Version"); got != "9.9.9" {
t.Errorf("expected X-Client-Version 9.9.9, got %q", got)
}
if got := r.Header.Get("X-Client-OS"); got != normalizeGOOS(runtime.GOOS) {
t.Errorf("expected X-Client-OS %q, got %q", normalizeGOOS(runtime.GOOS), got)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
t.Errorf("expected Authorization Bearer tok, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "1"})
}))
defer srv.Close()
c := NewClient(srv.URL)
c.SetToken("tok")
c.SetVersion("9.9.9")
if err := c.postJSON(context.Background(), "/api/daemon/test", map[string]any{}, nil); err != nil {
t.Fatalf("postJSON: %v", err)
}
}
func TestClient_IdentityHeaders_GetJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
t.Errorf("expected X-Client-Platform daemon, got %q", got)
}
if got := r.Header.Get("X-Client-Version"); got != "1.2.3" {
t.Errorf("expected X-Client-Version 1.2.3, got %q", got)
}
if got := r.Header.Get("X-Client-OS"); got == "" {
t.Errorf("expected X-Client-OS to be set")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient(srv.URL)
c.SetToken("tok")
c.SetVersion("1.2.3")
var out map[string]any
if err := c.getJSON(context.Background(), "/api/daemon/test", &out); err != nil {
t.Fatalf("getJSON: %v", err)
}
}
func TestClient_VersionOmittedWhenUnset(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
t.Errorf("expected X-Client-Platform daemon, got %q", got)
}
// SetVersion not called → header must be omitted (not "").
if vals := r.Header.Values("X-Client-Version"); len(vals) != 0 {
t.Errorf("expected X-Client-Version absent, got %v", vals)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := NewClient(srv.URL)
if err := c.postJSON(context.Background(), "/api/daemon/test", nil, nil); err != nil {
t.Fatalf("postJSON: %v", err)
}
}
func TestNormalizeGOOS(t *testing.T) {
cases := map[string]string{
"darwin": "macos",
"windows": "windows",
"linux": "linux",
"freebsd": "freebsd",
}
for in, want := range cases {
if got := normalizeGOOS(in); got != want {
t.Errorf("normalizeGOOS(%q) = %q, want %q", in, got, want)
}
}
}