mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* 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>
98 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|