package companion import ( "context" "crypto/hmac" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "sort" "strconv" "strings" "testing" "time" ) const testProof = "fixture-component-proof-material" type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) } func TestLoadConfigKeepsProofOutOfConfig(t *testing.T) { file, err := os.Open("config.yaml.example") if err != nil { t.Fatalf("open config fixture: %v", err) } defer file.Close() config, err := LoadConfig(file) if err != nil { t.Fatalf("load config fixture: %v", err) } encoded, err := json.Marshal(config) if err != nil { t.Fatalf("marshal config fixture: %v", err) } serialized := string(encoded) for _, forbidden := range []string{testProof, "scum_client_credential", "sessionToken", "/api/v1/scum-clients/", "InsecureSkipVerify"} { if strings.Contains(serialized, forbidden) { t.Fatalf("config exposed forbidden content %q: %s", forbidden, serialized) } } t.Setenv(ProofEnvironment, "") if _, err := NewClient(config, Options{}); err == nil { t.Fatal("expected missing proof environment to be rejected") } t.Setenv(ProofEnvironment, testProof) if _, err := NewClient(config, Options{}); err != nil { t.Fatalf("create client from proof environment: %v", err) } } func TestLoadConfigRejectsLegacySharedCredentialFields(t *testing.T) { fixture, err := os.ReadFile("config.yaml.example") if err != nil { t.Fatalf("read config fixture: %v", err) } for name, legacy := range map[string]string{ "legacy endpoint": "server_url: https://legacy.example.test\n", "shared credential": "scum_client_credential: legacy-shared-value\n", } { t.Run(name, func(t *testing.T) { if _, err := LoadConfig(strings.NewReader(string(fixture) + legacy)); err == nil { t.Fatalf("expected legacy config field to be rejected: %s", legacy) } }) } } func TestNewClientRejectsHTTPPlatformBaseURL(t *testing.T) { config := loadTestConfig(t) config.Platform.BaseURL = "http://platform.example.test" t.Setenv(ProofEnvironment, testProof) if _, err := NewClient(config, Options{}); err == nil || !strings.Contains(err.Error(), "HTTPS") { t.Fatalf("expected HTTP platform origin to be rejected, got %v", err) } } func TestNewClientCanonicalizesPlatformOriginAndRejectsInvalidPorts(t *testing.T) { t.Setenv(ProofEnvironment, testProof) config := loadTestConfig(t) config.Platform.BaseURL = " https://platform.example.test:8443/ " client, err := NewClient(config, Options{}) if err != nil { t.Fatalf("create client: %v", err) } if client.config.Platform.BaseURL != "https://platform.example.test:8443" { t.Fatalf("platform origin was not canonicalized: %q", client.config.Platform.BaseURL) } for _, baseURL := range []string{"https://:443", "https://platform.example.test:", "https://platform.example.test:65536"} { config.Platform.BaseURL = baseURL if _, err := NewClient(config, Options{}); err == nil { t.Fatalf("expected invalid platform origin to be rejected: %q", baseURL) } } } func TestNewClientUsesBoundedConfiguredRequestTimeout(t *testing.T) { config := loadTestConfig(t) config.Timing.RequestTimeoutSeconds = 7 t.Setenv(ProofEnvironment, testProof) client, err := NewClient(config, Options{}) if err != nil { t.Fatalf("create client: %v", err) } if client.httpClient.Timeout != 7*time.Second { t.Fatalf("unexpected request timeout: %s", client.httpClient.Timeout) } } func TestNewClientDefaultTransportVerifiesSystemRootsWithTLS12Minimum(t *testing.T) { config := loadTestConfig(t) t.Setenv(ProofEnvironment, testProof) client, err := NewClient(config, Options{}) if err != nil { t.Fatalf("create client: %v", err) } transport, ok := client.httpClient.Transport.(*http.Transport) if !ok { t.Fatalf("unexpected transport type: %T", client.httpClient.Transport) } if transport == http.DefaultTransport { t.Fatal("client must own its transport") } if transport.TLSClientConfig == nil { t.Fatal("client transport must declare an explicit TLS policy") } if transport.TLSClientConfig.InsecureSkipVerify { t.Fatal("client transport must verify server certificates") } if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 { t.Fatalf("client transport allows TLS below 1.2: %x", transport.TLSClientConfig.MinVersion) } if transport.TLSClientConfig.RootCAs != nil { t.Fatal("nil RootCAs must select the host system root pool") } if client.httpClient.CheckRedirect == nil { t.Fatal("client must explicitly reject redirects") } } func TestNewClientRejectsUntrustedTLSServerByDefault(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{}`) })) defer server.Close() config := loadTestConfig(t) config.Platform.BaseURL = server.URL t.Setenv(ProofEnvironment, testProof) client, err := NewClient(config, Options{ Now: func() time.Time { return time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) }, Nonce: func() (string, error) { return "nonce-fixture-untrusted-tls", nil }, }) if err != nil { t.Fatalf("create client: %v", err) } _, err = client.Register(context.Background()) var verificationError *tls.CertificateVerificationError if !errors.As(err, &verificationError) { t.Fatalf("expected untrusted TLS certificate verification failure, got %v", err) } } func TestClientRejectsRedirectWithoutReplayingComponentSession(t *testing.T) { stamp := time.Date(2026, 7, 20, 9, 30, 0, 0, time.UTC) config := loadTestConfig(t) requests := 0 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { requests++ switch requests { case 1: assertPlatformRequest(t, request, registerPath) return jsonHTTPResponse(http.StatusOK, registerResponse{ Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp, }), nil case 2: assertPlatformRequest(t, request, claimPath) var body claimRequest decodeRequest(t, request, &body) if body.SessionToken != "component-session-one" { t.Fatalf("claim did not carry the component session: %+v", body) } response := jsonHTTPResponse(http.StatusTemporaryRedirect, nil) response.Header.Set("Location", "https://redirect.example.test/capture") return response, nil default: t.Fatalf("redirect replayed component material to %s", request.URL.String()) return nil, fmt.Errorf("unexpected redirected request") } }) client := newTestClient(t, config, transport, stamp) if _, err := client.Register(context.Background()); err != nil { t.Fatalf("register client: %v", err) } _, err := client.ClaimCommands(context.Background(), 1) var statusError HTTPError if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusTemporaryRedirect { t.Fatalf("expected redirect response to be rejected, got %v", err) } if requests != 2 { t.Fatalf("redirect unexpectedly triggered %d requests", requests) } } func TestRegisterUsesExactCanonicalHMAC(t *testing.T) { stamp := time.Date(2026, 7, 20, 5, 4, 3, 123456789, time.UTC) config := loadTestConfig(t) for left, right := 0, len(config.Capabilities)-1; left < right; left, right = left+1, right-1 { config.Capabilities[left], config.Capabilities[right] = config.Capabilities[right], config.Capabilities[left] } var captured registerRequest transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { assertPlatformRequest(t, request, registerPath) decodeRequest(t, request, &captured) if strings.Contains(captured.Signature, testProof) { t.Fatal("registration signature contains raw proof") } return jsonHTTPResponse(http.StatusOK, registerResponse{ Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp, }), nil }) client := newTestClient(t, config, transport, stamp) registration, err := client.Register(context.Background()) if err != nil { t.Fatalf("register client: %v", err) } if registration.HeartbeatEverySeconds != 30 || !registration.ExpiresAt.Equal(stamp.Add(15*time.Minute)) { t.Fatalf("unexpected registration projection: %+v", registration) } if captured.Timestamp.Format(time.RFC3339Nano) != "2026-07-20T05:04:03.123456789Z" { t.Fatalf("registration timestamp was not RFC3339Nano UTC: %s", captured.Timestamp.Format(time.RFC3339Nano)) } expected := independentRegistrationSignature(testProof, captured) if captured.Signature != expected { t.Fatalf("unexpected registration signature: got %s want %s", captured.Signature, expected) } if captured.Nonce != "nonce-fixture-registration-0001" { t.Fatalf("unexpected registration nonce: %s", captured.Nonce) } } func TestSnapshotSequenceContinuesAcrossComponentSessions(t *testing.T) { stamp := time.Date(2026, 7, 20, 7, 0, 0, 0, time.UTC) config := loadTestConfig(t) step := 0 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { switch step { case 0, 2: assertPlatformRequest(t, request, registerPath) step++ session := "component-session-one" if step == 3 { session = "component-session-two" } return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: session, ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil case 1, 3: assertPlatformRequest(t, request, snapshotPath) var body snapshotRequest decodeRequest(t, request, &body) expectedSession := "component-session-one" expectedSequence := uint64(10) if step == 3 { expectedSession = "component-session-two" expectedSequence = 11 } if body.SessionToken != expectedSession || body.Sequence != expectedSequence { t.Fatalf("snapshot did not continue across component sessions: %+v", body) } step++ return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: fmt.Sprintf("snapshot-%d", body.Sequence), ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil default: return nil, fmt.Errorf("unexpected request %s", request.URL.Path) } }) t.Setenv(ProofEnvironment, testProof) nonce := 0 client, err := NewClient(config, Options{ Now: func() time.Time { return stamp }, Nonce: func() (string, error) { nonce++ return fmt.Sprintf("nonce-fixture-session-%04d", nonce), nil }, }) if err != nil { t.Fatalf("create client: %v", err) } client.httpClient.Transport = transport upload := func(sequence uint64) error { _, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000}) return err } if _, err := client.Register(context.Background()); err != nil { t.Fatalf("register first session: %v", err) } if err := upload(10); err != nil { t.Fatalf("upload first session snapshot: %v", err) } if _, err := client.Register(context.Background()); err != nil { t.Fatalf("register replacement session: %v", err) } if err := upload(10); err == nil || !strings.Contains(err.Error(), "sequence") { t.Fatalf("expected stale sequence to be rejected before transport, got %v", err) } if err := upload(11); err != nil { t.Fatalf("upload replacement session snapshot: %v", err) } if step != 4 { t.Fatalf("expected four transport requests, got %d", step) } } func TestSnapshotRequiresHTTPAccepted(t *testing.T) { stamp := time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC) config := loadTestConfig(t) step := 0 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { step++ if step == 1 { return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil } return jsonHTTPResponse(http.StatusOK, AcceptedSnapshot{}), nil }) client := newTestClient(t, config, transport, stamp) if _, err := client.Register(context.Background()); err != nil { t.Fatalf("register client: %v", err) } _, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000}) var statusError HTTPError if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusOK || statusError.ExpectedStatus != http.StatusAccepted { t.Fatalf("expected exact 202 enforcement, got %v", err) } } func TestComponentSessionHeartbeatAndBridgeTransport(t *testing.T) { stamp := time.Date(2026, 7, 20, 6, 0, 0, 0, time.UTC) config := loadTestConfig(t) sessionExpiry := stamp.Add(15 * time.Minute) step := 0 transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { if strings.Contains(request.URL.Path, "/api/v1/scum-clients/") { t.Fatalf("legacy endpoint used: %s", request.URL.Path) } switch step { case 0: assertPlatformRequest(t, request, registerPath) var body registerRequest decodeRequest(t, request, &body) step++ return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil case 1: assertPlatformRequest(t, request, heartbeatPath) var body heartbeatRequest decodeRequest(t, request, &body) if body.SessionToken != "component-session-one" || body.Sequence != 1 || body.Health != "healthy" || body.HealthReason != "bridge ready" { t.Fatalf("unexpected heartbeat body: %+v", body) } step++ return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil case 2: assertPlatformRequest(t, request, claimPath) var body claimRequest decodeRequest(t, request, &body) if body.SessionToken != "component-session-one" || body.Limit != 5 { t.Fatalf("unexpected claim body: %+v", body) } step++ return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "command-1", ProfileKey: ProfileKey, CommandType: "companion.diagnostics", Payload: map[string]any{"includeWindowState": true}, FencingToken: 9, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil case 3: assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/ack") var body ackRequest decodeRequest(t, request, &body) if body.SessionToken != "component-session-one" || body.FencingToken != 9 { t.Fatalf("unexpected ack body: %+v", body) } step++ return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "command-1", State: "claimed", FencingToken: 9, AcknowledgedAt: stamp}), nil case 4: assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/result") var body resultRequest decodeRequest(t, request, &body) if body.SessionToken != "component-session-one" || body.FencingToken != 9 || body.Status != "succeeded" || body.Summary != "diagnostics collected" || body.Payload["entries"] != float64(3) { t.Fatalf("unexpected result body: %+v", body) } step++ return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "command-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "diagnostics collected", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil case 5: assertPlatformRequest(t, request, snapshotPath) var body snapshotRequest decodeRequest(t, request, &body) if body.SessionToken != "component-session-one" || body.Type != "companion.health" || body.SchemaVersion != "1" || body.StreamKey != "current" || body.Sequence != 1 || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 { t.Fatalf("unexpected snapshot body: %+v", body) } step++ return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil default: return nil, fmt.Errorf("unexpected request %s", request.URL.Path) } }) client := newTestClient(t, config, transport, stamp) if _, err := client.Register(context.Background()); err != nil { t.Fatalf("register client: %v", err) } if _, err := client.Heartbeat(context.Background(), HealthReport{Status: "healthy", Reason: "bridge ready"}); err != nil { t.Fatalf("heartbeat: %v", err) } commands, err := client.ClaimCommands(context.Background(), 5) if err != nil || len(commands) != 1 { t.Fatalf("claim commands: commands=%+v err=%v", commands, err) } if _, err := client.AckCommand(context.Background(), commands[0].ID, commands[0].FencingToken); err != nil { t.Fatalf("ack command: %v", err) } if _, err := client.CompleteCommand(context.Background(), commands[0].ID, commands[0].FencingToken, CommandResult{Status: "succeeded", Summary: "diagnostics collected", Payload: map[string]any{"entries": 3}}); err != nil { t.Fatalf("complete command: %v", err) } snapshot := Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000} accepted, err := client.UploadSnapshot(context.Background(), snapshot) if err != nil || accepted.SnapshotID != "snapshot-1" { t.Fatalf("upload snapshot: accepted=%+v err=%v", accepted, err) } if step != 6 { t.Fatalf("expected six fixed API requests, got %d", step) } } func loadTestConfig(t *testing.T) Config { t.Helper() file, err := os.Open("config.yaml.example") if err != nil { t.Fatalf("open config fixture: %v", err) } defer file.Close() config, err := LoadConfig(file) if err != nil { t.Fatalf("load config fixture: %v", err) } return config } func newTestClient(t *testing.T, config Config, transport http.RoundTripper, stamp time.Time) *Client { t.Helper() t.Setenv(ProofEnvironment, testProof) client, err := NewClient(config, Options{ Now: func() time.Time { return stamp }, Nonce: func() (string, error) { return "nonce-fixture-registration-0001", nil }, }) if err != nil { t.Fatalf("create test client: %v", err) } client.httpClient.Transport = transport return client } func assertPlatformRequest(t *testing.T, request *http.Request, expectedPath string) { t.Helper() if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != expectedPath { t.Fatalf("unexpected platform request: %s %s", request.Method, request.URL.String()) } if request.Header.Get("Authorization") != "" { t.Fatalf("component session must be carried in the typed JSON body, got Authorization header") } if request.Header.Get("Content-Type") != "application/json" || request.Header.Get("Accept") != "application/json" { t.Fatalf("unexpected request headers: %+v", request.Header) } } func decodeRequest(t *testing.T, request *http.Request, target any) { t.Helper() decoder := json.NewDecoder(request.Body) if err := decoder.Decode(target); err != nil { t.Fatalf("decode request body: %v", err) } } func jsonHTTPResponse(status int, payload any) *http.Response { encoded, _ := json.Marshal(payload) return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))} } func independentRegistrationSignature(proof string, request registerRequest) string { capabilities := append([]string(nil), request.Capabilities...) sort.Strings(capabilities) canonical := strings.Join([]string{ request.InstallationID, request.ServerInstanceID, request.ProfileKey, request.ArtifactID, request.Version, request.SourceRevision, request.TargetOS, request.TargetArch, strconv.Itoa(request.KeyGeneration), strconv.Itoa(request.DeploymentGeneration), request.Timestamp.UTC().Format(time.RFC3339Nano), request.Nonce, strings.Join(capabilities, ","), }, "\n") mac := hmac.New(sha256.New, []byte(proof)) _, _ = mac.Write([]byte(canonical)) return "sha256:" + hex.EncodeToString(mac.Sum(nil)) }