package api import ( "net/http" "strings" "testing" "time" "browser.local/platform/domain" "browser.local/platform/dto" "browser.local/platform/repo" "browser.local/platform/service" ) type gameClientBridgeCompanionCore struct { service.Core command domain.GameClientBridgeCommand snapshot domain.GameClientBridgeSnapshot } func (core *gameClientBridgeCompanionCore) ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) { return []domain.GameClientBridgeCommand{domain.CopyGameClientBridgeCommand(core.command)}, nil } func (core *gameClientBridgeCompanionCore) AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) { return domain.CopyGameClientBridgeCommand(core.command), nil } func (core *gameClientBridgeCompanionCore) CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) { value := domain.CopyGameClientBridgeCommand(core.command) value.State = domain.GameClientBridgeCommandSucceeded value.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "done", CompletedBy: "internal-session", CompletedAt: time.Now().UTC()} value.CompletedAt = value.Result.CompletedAt return value, nil } func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) { return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil } func TestGameClientBridgeOperatorRoutes(t *testing.T) { store := repo.NewMemoryStore() coreService := service.NewCoreService(store) if err := coreService.SeedLocalPlatformAdmin(); err != nil { t.Fatal(err) } plugin := validGamePluginRequest().ToDomain() plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read") plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery) plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}} plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}} if _, err := coreService.CreateGamePlugin(plugin); err != nil { t.Fatalf("create bridge plugin: %v", err) } endpoint := validRunEndpointRequest().ToDomain() endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery) if _, err := coreService.CreateRunEndpoint(endpoint); err != nil { t.Fatalf("create run endpoint: %v", err) } router := NewTestRouterWithCore(coreService) adminSession := createAdminSession(t, router) if _, err := coreService.CreateServerInstanceForSession(adminSession, domain.ServerInstance{ID: "server-bridge", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Bridge Server"}); err != nil { t.Fatalf("create bridge server: %v", err) } status := getJSONWithAuth[dto.GameClientBridgeStatusResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge", adminSession) if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || status.Available || status.Profiles == nil { t.Fatalf("unexpected bridge status: %#v", status) } if len(status.Profiles) != 1 || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("bridge status did not safely expose query template availability: %#v", status) } queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)} queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession) assertStatus(t, queuedRecorder, http.StatusAccepted) queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder) if queued.ID == "" || queued.CommandType != queue.CommandType || queued.State != "pending" { t.Fatalf("unexpected queued command: %#v", queued) } undeclared := queue undeclared.CommandType = "undeclared.command" undeclared.IdempotencyKey = "undeclared-1" undeclaredRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", undeclared, adminSession) assertErrorResponse(t, undeclaredRecorder, http.StatusBadRequest, errorCodeValidation) commands := getJSONWithAuth[dto.GameClientBridgeCommandListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands?state=pending", adminSession) if commands.Count != 1 || commands.Items[0].ID != queued.ID { t.Fatalf("unexpected command list: %#v", commands) } detail := getJSONWithAuth[dto.GameClientBridgeCommandResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID, adminSession) if detail.ID != queued.ID { t.Fatalf("unexpected command detail: %#v", detail) } cancelRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID+"/cancel", dto.GameClientBridgeCancelRequest{Reason: "operator requested"}, adminSession) assertStatus(t, cancelRecorder, http.StatusOK) cancelled := decodeBody[dto.GameClientBridgeCancelResponse](t, cancelRecorder) if cancelled.State != "cancelled" || cancelled.CommandID != queued.ID { t.Fatalf("unexpected cancellation: %#v", cancelled) } snapshots := getJSONWithAuth[dto.GameClientBridgeSnapshotListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/snapshots?limit=20", adminSession) if snapshots.Count != 0 || snapshots.Items == nil { t.Fatalf("unexpected snapshot list: %#v", snapshots) } for _, serialized := range []string{queued.ResultSummary, status.Reason} { if strings.Contains(strings.ToLower(serialized), "token") || strings.Contains(strings.ToLower(serialized), "secret") { t.Fatalf("safe operator response leaked sensitive text: %q", serialized) } } } func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T) { now := time.Now().UTC() core := &gameClientBridgeCompanionCore{ Core: service.NewCoreService(repo.NewMemoryStore()), command: domain.GameClientBridgeCommand{ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", FencingToken: 9, ClaimedAt: now, LeaseExpiresAt: now.Add(time.Minute)}, ExpiresAt: now.Add(time.Hour)}, snapshot: domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, SourceSessionID: "internal-source-session", CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, } router := NewAuthorizedRouterWithCore(core) claim := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/claim", dto.GameClientBridgeClaimRequest{SessionToken: "component-token", Limit: 5}) assertStatus(t, claim, http.StatusOK) if strings.Contains(claim.Body.String(), "internal-session-secret") || strings.Contains(claim.Body.String(), "internal-installation") { t.Fatalf("claim response leaked component identity: %s", claim.Body.String()) } claimed := decodeBody[dto.GameClientBridgeClaimResponse](t, claim) if claimed.Count != 1 || claimed.Items[0].FencingToken != 9 { t.Fatalf("unexpected claim response: %#v", claimed) } ack := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/ack", dto.GameClientBridgeAckRequest{SessionToken: "component-token", FencingToken: 9}) assertStatus(t, ack, http.StatusOK) result := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/result", dto.GameClientBridgeResultRequest{SessionToken: "component-token", FencingToken: 9, Status: "succeeded", Summary: "done"}) assertStatus(t, result, http.StatusOK) if strings.Contains(result.Body.String(), "internal-session") { t.Fatalf("result response leaked completing session: %s", result.Body.String()) } snapshotRequest := dto.GameClientBridgeSnapshotIngestRequest{SessionToken: "component-token", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"healthy": true}, KeepForSeconds: 3600} snapshot := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/snapshots", snapshotRequest) assertStatus(t, snapshot, http.StatusAccepted) if strings.Contains(snapshot.Body.String(), "internal-source-session") || strings.Contains(snapshot.Body.String(), "component-token") { t.Fatalf("snapshot response leaked component material: %s", snapshot.Body.String()) } diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest) assertStatus(t, diagnostic, http.StatusAccepted) }