功能修改
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-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: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-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)
|
||||
}
|
||||
Reference in New Issue
Block a user