Files
browser/plugins/examples/scum-server-plugin/companion/client_test.go
T
2026-07-20 16:42:33 +08:00

520 lines
21 KiB
Go

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))
}