package api import ( "net/http" "strings" "testing" "time" "browser.local/platform/domain" "browser.local/platform/dto" "browser.local/platform/repo" "browser.local/platform/service" ) 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.JobCapabilityRemoteRunDBSQLiteQuery) plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}} plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", 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.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 || status.Profiles[0].ProfileKey != "plugin-owned" || 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: "plugin-owned", 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) } } }