package api import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "browser.local/platform/config" "browser.local/platform/domain" "browser.local/platform/dto" "browser.local/platform/repo" "browser.local/platform/service" ) func TestCoreAPICreateListDetailWorkflows(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) userResponse := postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-1", DisplayName: "Operator", Email: "operator@example.test", Roles: []string{"admin"}, }, adminSession) if userResponse.Status != domain.UserStatusActive { t.Fatalf("expected active user, got %+v", userResponse) } getJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users/user-1", adminSession) users := getJSONWithAuth[dto.UserListResponse](t, router, "/api/v1/users?status=active", adminSession) assertListCount(t, users.Count, 2) providerResponse := createAIProviderFixture(t, router, adminSession) if !providerResponse.APIKeyConfigured { t.Fatalf("expected AI provider key presence, got %+v", providerResponse) } getJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai", adminSession) providers := getJSONWithAuth[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active", adminSession) assertListCount(t, providers.Count, 1) pluginResponse := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) if pluginResponse.Status != domain.GamePluginStatusInstalled { t.Fatalf("expected installed plugin, got %+v", pluginResponse) } getJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/server.scum") plugins := getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=scum&status=installed") assertListCount(t, plugins.Count, 1) endpointResponse := postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) if endpointResponse.Status != domain.RunEndpointStatusOnline { t.Fatalf("expected online run endpoint, got %+v", endpointResponse) } getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local") endpoints := getJSON[dto.RunEndpointListResponse](t, router, "/api/v1/run/endpoints?status=online") assertListCount(t, endpoints.Count, 1) instanceResponse := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1", }, adminSession) if instanceResponse.State != domain.ServerInstanceStateDraft || instanceResponse.PluginVersion != "1.0.0" { t.Fatalf("expected server defaults, got %+v", instanceResponse) } getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-1", adminSession) instances := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft", adminSession) assertListCount(t, instances.Count, 1) jobResponse := postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ ID: "job-1", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-start", }) if jobResponse.State != domain.JobStateQueued { t.Fatalf("expected queued job, got %+v", jobResponse) } getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1") jobs := getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued") assertListCount(t, jobs.Count, 1) artifactResponse := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ ID: "artifact-1", OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-1", SizeBytes: 128, Checksum: "sha256:abc", }) if artifactResponse.State != domain.ArtifactStateUploading { t.Fatalf("expected uploading artifact, got %+v", artifactResponse) } getJSONWithAuth[dto.ArtifactResponse](t, router, "/api/v1/artifacts/artifact-1", adminSession) artifacts := getJSON[dto.ArtifactListResponse](t, router, "/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading") assertListCount(t, artifacts.Count, 1) streamResponse := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ ID: "log-1", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", }) if streamResponse.StreamKey != "stdout" { t.Fatalf("expected stdout stream, got %+v", streamResponse) } getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1") streams := getJSON[dto.LogStreamListResponse](t, router, "/api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout") if streams.Count < 1 { t.Fatalf("expected stdout streams, got %+v", streams) } foundExplicitStream := false for _, stream := range streams.Items { if stream.ID == "log-1" { foundExplicitStream = true } } if !foundExplicitStream { t.Fatalf("expected explicitly created stream in list, got %+v", streams) } } func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-owner-metrics", DisplayName: "Metrics Owner", Email: "owner-metrics@example.test", Roles: []string{"server-owner"}, Password: "secret-password", }, adminSession) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-other-metrics", DisplayName: "Metrics Other", Email: "other-metrics@example.test", Roles: []string{"server-admin"}, Password: "secret-password", }, adminSession) ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-metrics@example.test", Password: "secret-password"}).SessionID otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) endpointRequest := validRunEndpointRequest() postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-metrics-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Metrics API Server", State: domain.ServerInstanceStateRunning, }, ownerSession) usage := getJSONWithAuth[dto.PlatformResourceUsageResponse](t, router, "/api/v1/metrics/platform", adminSession) if usage.Source != "platform-derived" || usage.CPUPercent < 0 || usage.CPUPercent > 100 || usage.CollectedAt.IsZero() { t.Fatalf("unexpected platform metrics: %+v", usage) } assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/metrics/platform", "", ownerSession), http.StatusForbidden, errorCodeForbidden) ownerMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", ownerSession) if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || ownerMetrics.Items[0].Online || ownerMetrics.Items[0].CPUPercent != nil || ownerMetrics.Items[0].Source != "run-metrics-pending" { t.Fatalf("unexpected owner metrics: %+v", ownerMetrics) } otherMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", otherSession) if otherMetrics.Count != 0 { t.Fatalf("expected no metrics for other user, got %+v", otherMetrics) } _ = instance assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession), http.StatusNotFound) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusNotFound) } func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-owner-config-api", DisplayName: "Config API Owner", Email: "owner-config-api@example.test", Roles: []string{"server-owner"}, Password: "secret-password", }, adminSession) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-other-config-api", DisplayName: "Config API Other", Email: "other-config-api@example.test", Roles: []string{"server-admin"}, Password: "secret-password", }, adminSession) ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-config-api@example.test", Password: "secret-password"}).SessionID otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-config-api@example.test", Password: "secret-password"}).SessionID pluginRequest := validGamePluginRequest() pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityConfigWrite, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-config-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Config API Server", State: domain.ServerInstanceStateRunning, }, ownerSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-config-api/config", "", ownerSession), http.StatusNotFound) assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n"}, ownerSession), http.StatusNotFound) assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n", IdempotencyKey: "idem-config-api"}, otherSession), http.StatusNotFound) assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{ ServerInstanceID: "server-config-api", Operation: domain.FileOperationRead, Key: "/Users/tasia/.ssh/id_rsa", IdempotencyKey: "idem-file-unsafe-api", }, ownerSession), http.StatusBadRequest, errorCodeValidation) fileRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{ ServerInstanceID: "server-config-api", Operation: domain.FileOperationRead, Key: "logs/latest.log", IdempotencyKey: "idem-file-api", }, ownerSession) assertStatus(t, fileRecorder, http.StatusAccepted) fileDispatch := decodeBody[dto.FileOperationDispatchResponse](t, fileRecorder) if fileDispatch.Job.Capability != domain.JobCapabilityFilesRead || fileDispatch.Job.TargetKey != "logs/latest.log" { t.Fatalf("unexpected file dispatch: %+v", fileDispatch) } } func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "file-workspace-other", DisplayName: "File Workspace Other", Email: "file-workspace-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession) otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "file-workspace-other@example.test", Password: "secret-password"}).SessionID pluginRequest := validGamePluginRequest() pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"} pluginRequest.Permissions.Files = true pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{DefaultDirectoryKey: "scum-config", Directories: []dto.PluginLogicalDirectoryBody{{Key: "scum-config", Label: "服务器配置", Scope: "config"}, {Key: "scum-logs", Label: "日志文件", Scope: "logs"}}, Files: []dto.PluginLogicalFileBody{{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}}} postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) endpointRequest := validRunEndpointRequest() endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-file-snapshot-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "File Snapshot API Server", State: domain.ServerInstanceStateRunning, }, adminSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession) workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession) if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || workspace.DeclaredOnly || len(workspace.Directories) != 2 || workspace.Directories[0].Label != "服务器配置" || len(workspace.Files) != 2 { t.Fatalf("unexpected workspace: %+v", workspace) } list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession) if list.State != "declared" || list.DirectoryKey != "scum-config" || len(list.Entries) != 2 || list.Entries[1].Name != "ServerSettings.ini" || !strings.Contains(list.Reason, "服务器文件缓存") { t.Fatalf("expected declared SCUM file list, got %+v", list) } refreshRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/refresh", dto.ServerFileListRequest{DirectoryKey: "scum-config", IdempotencyKey: "api-file-list-refresh"}, adminSession) assertStatus(t, refreshRecorder, http.StatusAccepted) refresh := decodeBody[dto.ServerFileListResponse](t, refreshRecorder) if refresh.State != "pending" || refresh.Job == nil || refresh.Job.Capability != domain.JobCapabilityFilesList { t.Fatalf("expected file list refresh without endpoint declaration gate, got %+v", refresh) } readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession) assertStatus(t, readRecorder, http.StatusAccepted) read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder) if read.Job.Capability != domain.JobCapabilityFilesRead || read.Job.TargetKey != "scum-server-settings" { t.Fatalf("unexpected read dispatch: %+v", read) } snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", adminSession) if snapshot.State != "pending" || snapshot.JobID != read.Job.ID { t.Fatalf("expected pending read snapshot, got %+v", snapshot) } assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden) } func TestCoreAPIServerFileWorkspaceSynthesizesDefaultDirectoryForLegacyPlugin(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) pluginRequest := validGamePluginRequest() pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"} pluginRequest.Permissions.Files = true postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) endpointRequest := validRunEndpointRequest() endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-file-legacy-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Legacy File API Server", State: domain.ServerInstanceStateRunning}, adminSession) workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession) if workspace.DefaultDirectoryKey != "server-root" || workspace.DeclaredOnly || len(workspace.Directories) != 1 || workspace.Directories[0].Label != "服务器根目录" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil { t.Fatalf("expected synthesized non-null workspace, got %+v", workspace) } list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list", adminSession) if list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") { t.Fatalf("expected default file list, got %+v", list) } } func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { releaseBuilds := make(chan struct{}) t.Cleanup(func() { close(releaseBuilds) }) router := newTestRouterWithDistributionBuilder(apiTestDistributionBuilder{release: releaseBuilds}) adminSession := createAdminSession(t, router) serverID := createRuntimeAPIFixtures(t, router, adminSession) actions := getJSONWithAuth[dto.ServerRuntimeActionsResponse](t, router, "/api/v1/server-instances/"+serverID+"/runtime/actions", adminSession) availability := map[string]bool{} for _, action := range actions.Actions { availability[action.Key] = action.Available } for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} { if !availability[key] { t.Fatalf("expected action %q available in %+v", key, actions.Actions) } } runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession) if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) { t.Fatalf("unexpected run distribution: %+v", runDistribution) } runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession) assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound) clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession) if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef { t.Fatalf("unexpected client distribution: %+v", clientDistribution) } clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession) assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound) clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession) lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession) if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil { t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList) } deployRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/deploy", dto.ClientManagerDeployRequest{ProfileKey: "scum-client-manager", DistributionID: clientLinux.ID, IdempotencyKey: "api-client-manager-deploy"}, adminSession) assertErrorResponse(t, deployRecorder, http.StatusBadRequest, errorCodeValidation) detail := getJSONWithAuth[dto.ClientManagerInstallationResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/scum-client-manager", adminSession) if detail.CurrentJobID != "" || detail.KeyGeneration <= 0 { t.Fatalf("unexpected client-manager lifecycle detail: %+v", detail) } unauthorizedLifecycle := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", "") assertErrorResponse(t, unauthorizedLifecycle, http.StatusUnauthorized, errorCodeUnauthorized) dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession) assertStatus(t, dependencyCheckRecorder, http.StatusAccepted) dependencyCheck := decodeBody[dto.JobResponse](t, dependencyCheckRecorder) if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" { t.Fatalf("unexpected dependency check job: %+v", dependencyCheck) } dependencyCatalog := getJSONWithAuth[dto.DependencyCatalogResponse](t, router, "/api/v1/server-instances/"+serverID+"/dependencies", adminSession) if len(dependencyCatalog.Plans) != 1 || dependencyCatalog.Plans[0].Digest == "" { t.Fatalf("expected reviewable dependency plan, got %+v", dependencyCatalog) } dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", PlanDigest: dependencyCatalog.Plans[0].Digest, IdempotencyKey: "api-dependency-install"}, adminSession) assertStatus(t, dependencyInstallRecorder, http.StatusAccepted) dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder) if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" { t.Fatalf("unexpected dependency install job: %+v", dependencyInstall) } unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession) assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation) assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession), http.StatusNotFound) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/logs/live", "", adminSession), http.StatusNotFound) runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession) if runReset.Generation != 2 || runReset.SecretRef == "" { t.Fatalf("unexpected run key reset: %+v", runReset) } clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession) if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef { t.Fatalf("unexpected client key reset: %+v", clientReset) } for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} { for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} { if strings.Contains(body, forbidden) { t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body) } } } } func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-plugin-denied", PluginID: "game.example", RunEndpointID: "run-local", Name: "Plugin Denied", }, adminSession) recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/run/generate", dto.RunDistributionGenerateRequest{ TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "missing-plugin-permission", }, adminSession) assertStatus(t, recorder, http.StatusForbidden) body := decodeBody[dto.ErrorResponse](t, recorder) if body.Code != errorCodeForbidden || body.Message != "plugin does not declare required permission: server.run.distribution" { t.Fatalf("expected plugin permission denial, got %+v", body) } dependenciesRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/dependencies", "", adminSession) assertStatus(t, dependenciesRecorder, http.StatusForbidden) dependenciesBody := decodeBody[dto.ErrorResponse](t, dependenciesRecorder) if dependenciesBody.Code != errorCodeForbidden || dependenciesBody.Message != "plugin does not declare required permission: server.dependencies.manage" { t.Fatalf("expected dependency permission denial, got %+v", dependenciesBody) } } func TestCoreAPIErrorResponses(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) malformed := requestWithAuth(t, router, http.MethodPost, "/api/v1/users", "{", adminSession) assertErrorResponse(t, malformed, http.StatusBadRequest, errorCodeBadRequest) invalid := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/users", dto.UserCreateRequest{}, adminSession) assertErrorResponse(t, invalid, http.StatusBadRequest, errorCodeValidation) created := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/users", dto.UserCreateRequest{ID: "user-1", DisplayName: "Operator"}, adminSession) assertStatus(t, created, http.StatusCreated) duplicate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/users", dto.UserCreateRequest{ID: "user-1", DisplayName: "Operator"}, adminSession) assertErrorResponse(t, duplicate, http.StatusConflict, errorCodeDuplicate) missing := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/missing", "", adminSession) assertErrorResponse(t, missing, http.StatusNotFound, errorCodeNotFound) dependencyFailure := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-missing", PluginID: "server.missing", RunEndpointID: "run-missing", Name: "Missing Dependencies", }, adminSession) assertErrorResponse(t, dependencyFailure, http.StatusNotFound, errorCodeNotFound) rawKey := validAIProviderRequest() rawKey.ID = "ai.raw" rawKey.APIKeyRef = "sk-raw-secret" providerFailure := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey, adminSession) assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation) missingProvider := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "", adminSession) assertErrorResponse(t, missingProvider, http.StatusNotFound, errorCodeNotFound) methodFailure := requestWithAuth(t, router, http.MethodDelete, "/api/v1/users", "", adminSession) assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed) if allow := methodFailure.Header().Get("Allow"); allow != "GET, POST" { t.Fatalf("expected Allow header %q, got %q", "GET, POST", allow) } } func TestAuthSessionAPI(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) created := postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-auth", DisplayName: "Auth User", Email: "auth@example.test", Roles: []string{"platform-admin"}, Password: "secret-password", }, adminSession) if _, exists := anyJSON(t, created)["passwordHash"]; exists { t.Fatalf("user response must not expose passwordHash") } login := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "auth@example.test", Password: "secret-password"}) if login.SessionID == "" || login.Status != "authenticated" || login.User.ID != "user-auth" { t.Fatalf("unexpected login response: %+v", login) } currentRecorder := performRaw(t, router, http.MethodGet, "/api/v1/users/current", "") currentRecorder.Result().Header.Set("unused", "unused") assertErrorResponse(t, currentRecorder, http.StatusUnauthorized, errorCodeUnauthorized) current := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID) assertStatus(t, current, http.StatusOK) currentUser := decodeBody[dto.CurrentUserResponse](t, current) if currentUser.ID != "user-auth" || currentUser.Roles[0] != "platform-admin" { t.Fatalf("unexpected current user: %+v", currentUser) } profile := requestWithAuth(t, router, http.MethodPut, "/api/v1/users/current/profile", `{"displayName":"ignored","phone":"13900000000","contactNote":"primary contact"}`, login.SessionID) assertStatus(t, profile, http.StatusOK) updatedProfile := decodeBody[dto.CurrentUserResponse](t, profile) if updatedProfile.Profile.Phone != "13900000000" || updatedProfile.Profile.ContactNote != "primary contact" { t.Fatalf("unexpected profile update response: %+v", updatedProfile) } theme := requestWithAuth(t, router, http.MethodPut, "/api/v1/users/current/theme", `{"paletteId":"crystal-moonlight","backgroundPresetId":"moon"}`, login.SessionID) assertStatus(t, theme, http.StatusOK) themeResponse := decodeBody[dto.UserThemePreferenceResponse](t, theme) if themeResponse.UserID != "user-auth" || themeResponse.Persistence != "api" { t.Fatalf("unexpected theme response: %+v", themeResponse) } logout := requestWithAuth(t, router, http.MethodPost, "/api/v1/auth/logout", "", login.SessionID) assertStatus(t, logout, http.StatusNoContent) currentAfterLogout := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID) assertErrorResponse(t, currentAfterLogout, http.StatusUnauthorized, errorCodeUnauthorized) } func TestDefaultRouterSeedsLocalPlatformAdmin(t *testing.T) { router, err := NewRouterFromConfig(config.Config{ StorageBackend: "file", MetadataPath: filepath.Join(t.TempDir(), "metadata.json"), LogDir: filepath.Join(t.TempDir(), "logs"), BootstrapAdminEmail: "operator.local@example.test", BootstrapAdminPassword: "operator-local", }) if err != nil { t.Fatalf("create default router: %v", err) } loginRecorder := performJSON(t, router, http.MethodPost, "/api/v1/auth/login", dto.LoginRequest{ Account: "operator.local@example.test", Password: "operator-local", }) assertStatus(t, loginRecorder, http.StatusOK) login := decodeBody[dto.AuthSessionResponse](t, loginRecorder) if login.SessionID != "" || login.Status != "authenticated" || login.User.ID != "user-admin" { t.Fatalf("unexpected default operator login response: %+v", login) } var sessionCookie *http.Cookie for _, cookie := range loginRecorder.Result().Cookies() { if cookie.Name == platformSessionCookieName { sessionCookie = cookie break } } if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode { t.Fatalf("expected strict HttpOnly session cookie, got %+v", sessionCookie) } currentRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users/current", nil) currentRequest.AddCookie(sessionCookie) current := httptest.NewRecorder() router.ServeHTTP(current, currentRequest) assertStatus(t, current, http.StatusOK) currentUser := decodeBody[dto.CurrentUserResponse](t, current) if currentUser.ID != "user-admin" || len(currentUser.Roles) == 0 || currentUser.Roles[0] != "platform-admin" { t.Fatalf("unexpected default current user: %+v", currentUser) } } func TestRouterConfigRejectsMySQLWithoutDSN(t *testing.T) { _, err := NewRouterFromConfig(config.Config{ StorageBackend: "mysql", LogDir: t.TempDir(), }) if err == nil || !strings.Contains(err.Error(), "PLATFORM_MYSQL_DSN") { t.Fatalf("expected missing MySQL DSN error, got %v", err) } } func TestMySQLMetadataDefaultsToFileLogBodyStore(t *testing.T) { logStore, err := logStoreFromConfig(config.Config{ StorageBackend: "mysql", LogDir: t.TempDir(), }) if err != nil { t.Fatalf("create log store: %v", err) } if _, ok := logStore.(*service.FileLogBodyStore); !ok { t.Fatalf("expected MySQL metadata to default to file log body store, got %T", logStore) } } func TestRegisterAPICreatesPendingLowPrivilegeUser(t *testing.T) { router := newTestRouter() registration := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/register", dto.RegisterRequest{ DisplayName: "Pending Player", Email: "pending@example.test", Password: "secret-password", Phone: "13800000000", QQ: "10001", }) if registration.Status != "pending" || registration.SessionID != "" { t.Fatalf("expected pending registration without session, got %+v", registration) } if registration.User.Status != domain.UserStatusPending || registration.User.Roles[0] != "server-admin" { t.Fatalf("expected pending server-admin registration, got %+v", registration.User) } if registration.User.Roles[0] == "platform-admin" || registration.User.Roles[0] == "admin" { t.Fatalf("registration must not grant platform admin: %+v", registration.User) } login := performJSON(t, router, http.MethodPost, "/api/v1/auth/login", dto.LoginRequest{Account: "pending@example.test", Password: "secret-password"}) assertErrorResponse(t, login, http.StatusForbidden, errorCodeForbidden) } func TestRegisterAPIBootstrapsFirstPlatformAdmin(t *testing.T) { router := apiRouterWithoutSeededAdmin() registration := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/register", dto.RegisterRequest{ DisplayName: "Bootstrap Admin", Email: "bootstrap@example.test", Password: "secret-password", }) if registration.Status != "authenticated" || registration.SessionID == "" { t.Fatalf("expected first registration to authenticate, got %+v", registration) } if registration.User.Status != domain.UserStatusActive || len(registration.User.Roles) != 1 || registration.User.Roles[0] != "platform-admin" { t.Fatalf("expected first registration to create platform admin, got %+v", registration.User) } current := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", registration.SessionID) if current.ID != registration.User.ID || current.Roles[0] != "platform-admin" { t.Fatalf("unexpected current bootstrap user: %+v", current) } } func TestUserUpdateAPI(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) created := postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ DisplayName: "Generated API User", Email: "generated-api-user@example.test", Roles: []string{"server-admin"}, }, adminSession) if created.ID != "user-generated-api-user-example-test" { t.Fatalf("expected generated user id, got %+v", created) } postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-update", DisplayName: "User Update", Email: "update@example.test", Roles: []string{"server-admin"}, }, adminSession) status := domain.UserStatusDisabled displayName := "User Updated" updated := putJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users/user-update", dto.UserUpdateRequest{ DisplayName: &displayName, Status: &status, Roles: []string{"server-owner"}, Profile: &dto.UserProfileBody{Phone: "13700000000"}, }, adminSession) if updated.DisplayName != displayName || updated.Status != status || updated.Roles[0] != "server-owner" || updated.Profile.Phone != "13700000000" { t.Fatalf("unexpected user update: %+v", updated) } unauthorized := performJSON(t, router, http.MethodPut, "/api/v1/users/user-update", dto.UserUpdateRequest{Status: &status}) assertErrorResponse(t, unauthorized, http.StatusUnauthorized, errorCodeUnauthorized) } func TestServerLifecycleWorkflowAPI(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ ID: "server-create", PluginID: "server.scum", Name: "SCUM Create", IdempotencyKey: "idem-create", }, adminSession) if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateDraft || created.Job.ID != "" || created.Instance.RunEndpointID != "" { t.Fatalf("expected create workflow response, got %+v", created) } legacyCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", map[string]any{ "id": "server-create-legacy", "pluginId": "server.scum", "runEndpointId": "run-local", "name": "SCUM Legacy Create", "idempotencyKey": "idem-create-legacy", "profileKey": "local", "bindings": map[string]string{"server-root": "runtime.server-root"}, }, adminSession) assertErrorResponse(t, legacyCreate, http.StatusBadRequest, errorCodeBadRequest) ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-ready", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Ready", State: domain.ServerInstanceStateReady, }, adminSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-ready/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession) started := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{ ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-start", }, adminSession) if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart { t.Fatalf("expected start workflow response, got %+v", started) } running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-running", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Running", State: domain.ServerInstanceStateRunning, }, adminSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-running/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession) stopped := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-running/stop", dto.ServerLifecycleCommandRequest{ ExpectedConfigVersion: running.ConfigVersion, IdempotencyKey: "idem-stop", }, adminSession) if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop { t.Fatalf("expected stop workflow response, got %+v", stopped) } stale := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{ ExpectedConfigVersion: ready.ConfigVersion + 1, IdempotencyKey: "idem-stale", }, adminSession) assertErrorResponse(t, stale, http.StatusBadRequest, errorCodeValidation) invalidStop := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-ready/stop", dto.ServerLifecycleCommandRequest{ ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-invalid-stop", }, adminSession) assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation) } func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) endpoint := validRunEndpointRequest() endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint) postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", Name: "Reveal", IdempotencyKey: "deployment-reveal", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession) redactedRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment", "", adminSession) assertStatus(t, redactedRecorder, http.StatusOK) if body := redactedRecorder.Body.String(); strings.Contains(body, "/srv/reveal") || strings.Contains(body, "./start-server") { t.Fatalf("normal deployment view leaked protected inputs: %s", body) } redacted := decodeBody[dto.ServerDeploymentResponse](t, redactedRecorder) if redacted.LatestDispatch != nil { t.Fatalf("create-time deployment settings must not dispatch to Run, got %+v", redacted.LatestDispatch) } revealed := getJSONWithAuth[dto.ServerDeploymentRevealResponse](t, router, "/api/v1/server-instances/deployment-reveal/deployment/reveal", adminSession) if revealed.ServerRoot != "/srv/reveal" || revealed.WorkingDirectory != "/srv/reveal" || revealed.StartCommand != "./start-server" || revealed.InstallCommand != "" { t.Fatalf("unexpected explicitly revealed deployment: %+v", revealed) } postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "deployment-other", DisplayName: "Other", Email: "deployment-other@example.test", Roles: []string{"server-owner"}, Password: "other-password"}, adminSession) other := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "deployment-other@example.test", Password: "other-password"}) denied := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment/reveal", "", other.SessionID) assertErrorResponse(t, denied, http.StatusForbidden, errorCodeForbidden) } func TestServerInstanceManagementAPI(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-management", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Ops", State: domain.ServerInstanceStateReady, }, adminSession) newName := "SCUM Ops Renamed" updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-management", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession) if updated.Name != newName || updated.PluginID != ready.PluginID || updated.RunEndpointID != ready.RunEndpointID { t.Fatalf("unexpected server update: %+v", updated) } running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-running-delete", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Running Delete", State: domain.ServerInstanceStateRunning, }, adminSession) unsafeDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession) assertErrorResponse(t, unsafeDelete, http.StatusBadRequest, errorCodeValidation) deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession) assertStatus(t, deleted, http.StatusNoContent) activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession) for _, item := range activeList.Items { if item.ID == "server-management" { t.Fatalf("deleted server should be hidden from normal list: %+v", activeList) } } deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession) if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted { t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList) } forcedDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local", Force: true, Confirmation: service.ServerDeletionForceConfirmation}), adminSession) assertStatus(t, forcedDelete, http.StatusNoContent) forcedDeleted := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/"+running.ID, adminSession) if forcedDeleted.State != domain.ServerInstanceStateDeleted { t.Fatalf("expected forced deleted running server, got %+v", forcedDeleted) } blank := "" invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-delete", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession) assertErrorResponse(t, invalidUpdate, http.StatusBadRequest, errorCodeValidation) } func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-owner", DisplayName: "Server Owner", Email: "owner@example.test", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, Password: "secret-password", }, adminSession) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-helper", DisplayName: "Server Helper", Email: "helper@example.test", Roles: []string{"server-admin"}, Status: domain.UserStatusActive, Password: "secret-password", }, adminSession) ownerLogin := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner@example.test", Password: "secret-password"}) helperLogin := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "helper@example.test", Password: "secret-password"}) created := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-owned", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Owned Server", State: domain.ServerInstanceStateReady, }, ownerLogin.SessionID) if created.OwnerUserID != "user-owner" || len(created.AdminUserIDs) != 0 { t.Fatalf("expected owner-bound server, got %+v", created) } createdBody := anyJSON(t, created) adminUserIDs, ok := createdBody["adminUserIds"].([]any) if !ok || len(adminUserIDs) != 0 { t.Fatalf("expected adminUserIds to serialize as an empty array, got %+v", createdBody["adminUserIds"]) } adminList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession) assertListCount(t, adminList.Count, 1) ownerList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", ownerLogin.SessionID) assertListCount(t, ownerList.Count, 1) helperListBefore := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", helperLogin.SessionID) assertListCount(t, helperListBefore.Count, 0) candidates := getJSONWithAuth[dto.ServerMemberListResponse](t, router, "/api/v1/server-instances/server-owned/administrators/candidates", ownerLogin.SessionID) if candidates.Count != 1 || candidates.Items[0].ID != "user-helper" { t.Fatalf("expected only non-platform helper candidate, got %+v", candidates) } helperCandidateBody := anyJSON(t, candidates.Items[0]) if _, exists := helperCandidateBody["passwordHash"]; exists { t.Fatalf("server member response must not expose passwordHash: %+v", helperCandidateBody) } added := postOKJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-helper"}, ownerLogin.SessionID) if len(added.AdminUserIDs) != 1 || added.AdminUserIDs[0] != "user-helper" { t.Fatalf("expected helper admin membership, got %+v", added) } helperDetail := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-owned", helperLogin.SessionID) if helperDetail.ID != "server-owned" { t.Fatalf("expected helper to access assigned server, got %+v", helperDetail) } nonOwnerAdd := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-owner"}, helperLogin.SessionID) assertErrorResponse(t, nonOwnerAdd, http.StatusForbidden, errorCodeForbidden) platformAdd := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-admin"}, ownerLogin.SessionID) assertErrorResponse(t, platformAdd, http.StatusForbidden, errorCodeForbidden) removed := requestJSONWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-owned/administrators/user-helper", nil, ownerLogin.SessionID) assertStatus(t, removed, http.StatusOK) removedBody := decodeBody[dto.ServerInstanceResponse](t, removed) if len(removedBody.AdminUserIDs) != 0 { t.Fatalf("expected helper membership removed, got %+v", removedBody) } forbiddenDetail := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-owned", "", helperLogin.SessionID) assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden) } func TestServerInstanceDeleteRequiresOwnershipAndPasswordConfirmation(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-delete-owner-api", DisplayName: "Delete Owner API", Email: "delete-owner-api@example.test", Roles: []string{"server-owner"}, Password: "secret-password", }, adminSession) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-delete-other-api", DisplayName: "Delete Other API", Email: "delete-other-api@example.test", Roles: []string{"server-admin"}, Password: "secret-password", }, adminSession) ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "delete-owner-api@example.test", Password: "secret-password"}).SessionID otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "delete-other-api@example.test", Password: "secret-password"}).SessionID postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-delete-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Delete API Server", State: domain.ServerInstanceStateReady, }, ownerSession) adminTarget := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-delete-admin-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Delete Admin API Server", State: domain.ServerInstanceStateReady, }, ownerSession) missingPassword := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{}), ownerSession) assertErrorResponse(t, missingPassword, http.StatusBadRequest, errorCodeValidation) wrongPassword := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "wrong-password"}), ownerSession) assertErrorResponse(t, wrongPassword, http.StatusForbidden, errorCodeForbidden) forbiddenDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "secret-password"}), otherSession) assertErrorResponse(t, forbiddenDelete, http.StatusForbidden, errorCodeForbidden) adminDeleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+adminTarget.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession) assertStatus(t, adminDeleted, http.StatusNoContent) deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "secret-password"}), ownerSession) assertStatus(t, deleted, http.StatusNoContent) } func TestAIProviderAPIResponseDoesNotExposeRawSecretFields(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession) assertStatus(t, recorder, http.StatusCreated) var body map[string]any if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { t.Fatalf("decode provider response: %v", err) } if _, exists := body["apiKey"]; exists { t.Fatalf("AI provider response must not expose apiKey: %+v", body) } if _, exists := body["rawApiKey"]; exists { t.Fatalf("AI provider response must not expose rawApiKey: %+v", body) } if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true { t.Fatalf("expected API key presence only, got %+v", body) } if _, exists := body["baseUrl"]; exists || body["baseUrlConfigured"] != true { t.Fatalf("expected base URL presence only, got %+v", body) } } func TestAIProviderManagementAPI(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) createAIProviderFixture(t, router, adminSession) update := validAIProviderUpdateRequest() update.BaseURL = "" updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession) assertStatus(t, updatedRecorder, http.StatusOK) updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder) if updated.Name != "OpenAI Relay" || !updated.BaseURLConfigured || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive { t.Fatalf("unexpected updated provider: %+v", updated) } statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession) assertStatus(t, statusRecorder, http.StatusOK) disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder) if disabled.Status != domain.AIProviderStatusDisabled { t.Fatalf("expected disabled provider, got %+v", disabled) } testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession) assertStatus(t, testRecorder, http.StatusOK) testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder) if testResult.Success || testResult.Mode != "provider" { t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult) } models := getJSONWithAuth[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models", adminSession) if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 { t.Fatalf("unexpected models response: %+v", models) } statusRecorder = requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}, adminSession) assertStatus(t, statusRecorder, http.StatusOK) testRecorder = requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession) assertStatus(t, testRecorder, http.StatusOK) testResult = decodeBody[dto.AIProviderTestResponse](t, testRecorder) if !testResult.Success { t.Fatalf("expected metadata test success, got %+v", testResult) } } func TestAIProviderManagementAPIErrors(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) createAIProviderFixture(t, router, adminSession) rawUpdate := validAIProviderUpdateRequest() rawUpdate.APIKeyRef = "sk-raw-secret" rawFailure := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate, adminSession) assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation) invalidStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}, adminSession) assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation) missingUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest(), adminSession) assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound) missingStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession) assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound) missingTest := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "", adminSession) assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound) missingModels := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "", adminSession) assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound) } func TestAIProviderManagementRequiresPlatformAdmin(t *testing.T) { router := newTestRouter() for _, request := range []struct { name string method string path string body any }{ {name: "list", method: http.MethodGet, path: "/api/v1/ai-providers"}, {name: "create", method: http.MethodPost, path: "/api/v1/ai-providers", body: validAIProviderRequest()}, {name: "detail", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai"}, {name: "update", method: http.MethodPut, path: "/api/v1/ai-providers/ai.openai", body: validAIProviderUpdateRequest()}, {name: "status", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/status", body: dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}}, {name: "test", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/test"}, {name: "models", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai/models"}, } { t.Run(request.name, func(t *testing.T) { var recorder *httptest.ResponseRecorder if request.body == nil { recorder = performRaw(t, router, request.method, request.path, "") } else { recorder = performJSON(t, router, request.method, request.path, request.body) } assertErrorResponse(t, recorder, http.StatusUnauthorized, errorCodeUnauthorized) }) } } func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) createAIProviderFixture(t, router, adminSession) registration := validGamePluginManifestRegistrationRequest() registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"} registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)} postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-ai-api", PluginID: "game.example", RunEndpointID: "run-local", Name: "AI API Server", }, adminSession) allowedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/invocations", dto.AIInvocationRequest{ RequestID: "ai-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Purpose: "logs.diagnose", Prompt: "Summarize recent warnings safely", ContextRefs: map[string]string{"server": "server://server-ai-api"}, }, adminSession) assertStatus(t, allowedRecorder, http.StatusOK) allowed := decodeBody[dto.AIInvocationResponse](t, allowedRecorder) if allowed.Status != "ok" || !allowed.Usage.Mocked || allowed.Recommendation == "" { t.Fatalf("expected mocked safe AI response, got %+v", allowed) } denied := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{ RequestID: "ai-denied", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Purpose: "config.suggest", Prompt: "Suggest config", }, adminSession) if denied.Status != "denied" || denied.Error == nil || denied.Error.Code != "permission_denied" { t.Fatalf("expected purpose denial, got %+v", denied) } unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/invocations", dto.AIInvocationRequest{ RequestID: "ai-unsafe", Purpose: "logs.diagnose", Prompt: "use sk-live-raw-secret", }, adminSession) assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation) configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{ ServerInstanceID: instance.ID, Prompt: "Turn off pvp and keep this reviewable", CurrentConfig: "server.name=AI API Server\n", }, adminSession) assertStatus(t, configRecorder, http.StatusOK) config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder) if config.SuggestedConfig == "" || !strings.Contains(config.SuggestedConfig, "ai.recommendation=review-required") { t.Fatalf("expected reviewable config suggestion, got %+v", config) } jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession) if jobs.Count != 0 { t.Fatalf("AI suggestion must not dispatch config writes, got %+v", jobs) } bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-ai-api", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Action: string(domain.PluginBridgeActionAIInvoke), AIPurpose: "logs.diagnose", Payload: map[string]string{"prompt": "Summarize the logs"}, }, adminSession) if bridge.Status != "ok" || bridge.Result["recommendation"] == "" || bridge.Result["mocked"] != "true" { t.Fatalf("expected bridge AI response, got %+v", bridge) } for _, body := range []string{allowedRecorder.Body.String(), configRecorder.Body.String(), mustJSON(t, bridge), mustJSON(t, denied)} { for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} { if strings.Contains(body, forbidden) { t.Fatalf("AI response exposed forbidden fragment %q: %s", forbidden, body) } } } } func TestGamePluginManifestRegistryAPI(t *testing.T) { router := newTestRouter() registration := validGamePluginManifestRegistrationRequest() created := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) if created.ID != "game.example" || created.ServerType != "example" || created.ServerDisplayName != "Example Server" { t.Fatalf("unexpected plugin registry response: %+v", created) } if created.ManifestRef != "artifact://manifests/game.example/0.1.0" || created.CreateFormSchemaRef != "schemas/create-form.schema.json" { t.Fatalf("expected manifest and schema refs, got %+v", created) } if len(created.DeclaredPermissions) != 6 || !created.Permissions.AI || !created.Permissions.Artifacts || !created.Permissions.Jobs { t.Fatalf("expected declared and aggregate permissions, got %+v", created) } if len(created.Pages) != 1 || created.Pages[0].Permissions[0] != "server.logs.read" { t.Fatalf("expected page metadata, got %+v", created.Pages) } if len(created.AIPurposes) != 1 || created.AIPurposes[0] != "logs.diagnose" { t.Fatalf("expected AI purposes, got %+v", created.AIPurposes) } if len(created.BridgeActions) != 4 || created.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) { t.Fatalf("expected bridge actions, got %+v", created.BridgeActions) } listed := getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=example&status=installed") assertListCount(t, listed.Count, 1) detail := getJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/game.example") if detail.ID != created.ID || len(detail.RequiredRunCapabilities) != len(created.RequiredRunCapabilities) { t.Fatalf("unexpected plugin detail: %+v", detail) } registration.Manifest.Version = "0.1.1" registration.Manifest.Description = "Development plugin refreshed" registration.ManifestRef = "artifact://manifests/game.example/0.1.1" refreshed := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) if refreshed.ID != created.ID || refreshed.Version != "0.1.1" || refreshed.ManifestRef != "artifact://manifests/game.example/0.1.1" { t.Fatalf("expected manifest registration to refresh existing plugin, got %+v", refreshed) } listed = getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=example&status=installed") assertListCount(t, listed.Count, 1) } func TestPluginMarketplaceAPIListsDetailsAndChangesStateSafely(t *testing.T) { router := newTestRouter() postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) listed := getJSON[dto.MarketplacePluginListResponse](t, router, "/api/v1/plugin-marketplace/plugins?serverType=example&status=installed&capability=logs.read&keyword=development") if listed.Count != 1 || listed.Items[0].ID != "game.example" || listed.Items[0].Source != "platform-registry" { t.Fatalf("unexpected marketplace list: %+v", listed) } if len(listed.Items[0].Capabilities) == 0 || listed.Items[0].Capabilities[0] != "process.install" || len(listed.Items[0].Pages) != 1 { t.Fatalf("expected manifest-backed marketplace projection, got %+v", listed.Items[0]) } detailRecorder := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins/game.example", "") assertStatus(t, detailRecorder, http.StatusOK) detail := decodeBody[dto.MarketplacePluginResponse](t, detailRecorder) if detail.ID != "game.example" || detail.ManifestRef != "artifact://manifests/game.example/0.1.0" || detail.AIPurposes[0] != "logs.diagnose" { t.Fatalf("unexpected marketplace detail: %+v", detail) } for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} { if strings.Contains(detailRecorder.Body.String(), forbidden) { t.Fatalf("marketplace detail exposed forbidden fragment %q: %s", forbidden, detailRecorder.Body.String()) } } disabled := postOKJSON[dto.MarketplacePluginResponse](t, router, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}) if disabled.Status != domain.GamePluginStatusDisabled { t.Fatalf("expected disabled marketplace plugin, got %+v", disabled) } installed := postOKJSON[dto.MarketplacePluginResponse](t, router, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionInstall}) if installed.Status != domain.GamePluginStatusInstalled { t.Fatalf("expected installed marketplace plugin, got %+v", installed) } empty := getJSON[dto.MarketplacePluginListResponse](t, router, "/api/v1/plugin-marketplace/plugins?keyword=missing") if empty.Count != 0 { t.Fatalf("expected empty marketplace keyword result, got %+v", empty) } missing := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins/missing", "") assertErrorResponse(t, missing, http.StatusNotFound, errorCodeNotFound) unsupported := performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateAction("download")}) assertErrorResponse(t, unsupported, http.StatusBadRequest, errorCodeValidation) unsafeFilter := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins?keyword=sk-raw-secret", "") assertErrorResponse(t, unsafeFilter, http.StatusBadRequest, errorCodeValidation) } func TestPluginBridgeAuthorizeAPI(t *testing.T) { router := newTestRouter() postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) allowed := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ PluginID: "game.example", RouteKey: "logs", Action: string(domain.PluginBridgeActionLogsQuery), }) if !allowed.Allowed || allowed.RequiredPermissions[0] != "server.logs.read" { t.Fatalf("expected allowed logs bridge action, got %+v", allowed) } denied := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ PluginID: "game.example", RouteKey: "logs", Action: string(domain.PluginBridgeActionFilesRequest), }) if denied.Allowed || denied.Reason == "" { t.Fatalf("expected denied files bridge action, got %+v", denied) } aiAllowed := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ PluginID: "game.example", RouteKey: "logs", Action: string(domain.PluginBridgeActionAIInvoke), AIPurpose: "logs.diagnose", }) if !aiAllowed.Allowed { t.Fatalf("expected allowed AI bridge action, got %+v", aiAllowed) } unsupported := performJSON(t, router, http.MethodPost, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{ PluginID: "game.example", RouteKey: "logs", Action: "direct.run.socket", }) assertErrorResponse(t, unsupported, http.StatusBadRequest, errorCodeValidation) } func TestPluginBridgeExecuteAPI(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ ID: "user-bridge-owner", DisplayName: "Bridge Owner", Email: "bridge-owner@example.test", Roles: []string{"server-owner"}, Password: "secret-password", }, adminSession) ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID createAIProviderFixture(t, router, adminSession) registration := validGamePluginManifestRegistrationRequest() registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch)) registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "ai.invoke"} registration.Manifest.Pages[0].BridgeActions = []string{ string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionJobsDispatch), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke), } postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-bridge-api", PluginID: "game.example", RunEndpointID: "run-local", Name: "Bridge API Server", State: domain.ServerInstanceStateRunning, }, ownerSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession) lifecycleInstance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-bridge-lifecycle-api", PluginID: "game.example", RunEndpointID: "run-local", Name: "Bridge Lifecycle API Server", State: domain.ServerInstanceStateReady, }, ownerSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+lifecycleInstance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession) stream := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ ID: "log-bridge-api", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", }) serverRead := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-read-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Action: string(domain.PluginBridgeActionServerInstancesRead), }, ownerSession) if serverRead.Status != "ok" || serverRead.Result["serverInstanceId"] != instance.ID || serverRead.Result["state"] != string(domain.ServerInstanceStateRunning) { t.Fatalf("expected safe server context response, got %+v", serverRead) } logs := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-logs-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Action: string(domain.PluginBridgeActionLogsQuery), Payload: map[string]string{"logStreamId": stream.ID, "limit": "10"}, }, ownerSession) if logs.Status != "ok" || logs.Result["logStreamId"] != stream.ID || logs.Result["entryCount"] != "0" { t.Fatalf("expected safe log query response, got %+v", logs) } lifecycle := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-lifecycle-start-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: lifecycleInstance.ID, Action: string(domain.PluginBridgeActionJobsDispatch), Payload: map[string]string{ "lifecycleAction": "start", "capability": domain.LifecycleCapabilityStart, "expectedConfigVersion": strconv.Itoa(lifecycleInstance.ConfigVersion), "idempotencyKey": "idem-bridge-lifecycle-start", }, }, ownerSession) if lifecycle.Status != "queued" || lifecycle.Result["capability"] != domain.LifecycleCapabilityStart || lifecycle.Result["lifecycleAction"] != string(domain.ServerLifecycleActionStart) || lifecycle.Result["serverInstanceId"] != lifecycleInstance.ID { t.Fatalf("expected platform-mediated lifecycle dispatch response, got %+v", lifecycle) } lifecycleMismatch := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-lifecycle-mismatch-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: lifecycleInstance.ID, Action: string(domain.PluginBridgeActionJobsDispatch), Payload: map[string]string{ "lifecycleAction": "start", "capability": domain.LifecycleCapabilityStop, "expectedConfigVersion": strconv.Itoa(lifecycleInstance.ConfigVersion), "idempotencyKey": "idem-bridge-lifecycle-mismatch", }, }, ownerSession) if lifecycleMismatch.Status != "denied" || lifecycleMismatch.Error == nil || lifecycleMismatch.Error.Code != "capability_denied" { t.Fatalf("expected mismatched lifecycle capability denial, got %+v", lifecycleMismatch) } fileDispatch := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-file-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Action: string(domain.PluginBridgeActionFilesRequest), Payload: map[string]string{"operation": "read", "key": "logs/latest.log", "idempotencyKey": "idem-bridge-file"}, }, ownerSession) if fileDispatch.Status != "queued" || fileDispatch.Result["capability"] != domain.JobCapabilityFilesRead || fileDispatch.Result["targetKey"] != "logs/latest.log" { t.Fatalf("expected safe file dispatch reference, got %+v", fileDispatch) } aiResponse := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-ai-1", PluginID: "game.example", RouteKey: "logs", Action: string(domain.PluginBridgeActionAIInvoke), AIPurpose: "logs.diagnose", }, ownerSession) if aiResponse.Status != "ok" || aiResponse.Result["recommendation"] == "" || aiResponse.Result["mocked"] != "true" { t.Fatalf("expected mediated AI safe response, got %+v", aiResponse) } denied := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-denied-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Action: string(domain.PluginBridgeActionArtifactsOpen), }, ownerSession) if denied.Status != "denied" || denied.Error == nil || denied.Error.Code != "permission_denied" { t.Fatalf("expected permission denied safe envelope, got %+v", denied) } unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{ RequestID: "bridge-unsafe-1", PluginID: "game.example", RouteKey: "logs", ServerInstanceID: instance.ID, Action: string(domain.PluginBridgeActionFilesRequest), Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"}, }, ownerSession) assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation) for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied)} { for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} { if strings.Contains(body, forbidden) { t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body) } } } } func TestGamePluginManifestRegistryAPIRejectsUnsafeManifest(t *testing.T) { router := newTestRouter() registration := validGamePluginManifestRegistrationRequest() registration.Manifest.Description = "requires direct run socket and raw AI key" response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration) assertStatus(t, response, http.StatusBadRequest) errorBody := decodeBody[dto.ErrorResponse](t, response) if errorBody.Code != errorCodeValidation { t.Fatalf("expected validation error, got %+v", errorBody) } joinedDetails := strings.Join(errorBody.Details, ",") if !strings.Contains(joinedDetails, "direct run access") || !strings.Contains(joinedDetails, "raw credential") { t.Fatalf("expected unsafe manifest details, got %+v", errorBody) } } func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) registration := validGamePluginManifestRegistrationRequest() registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "remote.run.rcon.command") registration.Manifest.RuntimeProfiles = dto.GamePluginRuntimeProfilesBody{ Discovery: []dto.RuntimeDiscoveryProbeBody{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true}}, LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}, TransportKeys: []string{"rcon"}}}, TransportProfiles: []dto.RuntimeTransportProfileBody{{Key: "rcon", Kind: "rcon", TargetKey: "rcon.password", Capabilities: []string{"remote.run.rcon.command"}}}, } registered := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration) if len(registered.RuntimeProfiles.LifecycleProfiles) != 1 || registered.RuntimeProfiles.LifecycleProfiles[0].Key != "local" { t.Fatalf("runtime profiles were not projected: %+v", registered.RuntimeProfiles) } endpoint := validRunEndpointRequest() endpoint.Capabilities = append(endpoint.Capabilities, "remote.run.rcon.command") postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint) server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "runtime-binding-api", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Binding API", State: domain.ServerInstanceStateReady}, adminSession) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "runtime-binding-other", DisplayName: "Runtime Binding Other", Email: "runtime-binding-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession) otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "runtime-binding-other@example.test", Password: "secret-password"}).SessionID assertErrorResponse(t, performRequest(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", nil), http.StatusUnauthorized, errorCodeUnauthorized) assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", "", otherSession), http.StatusForbidden, errorCodeForbidden) assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local"}, otherSession), http.StatusForbidden, errorCodeForbidden) unconfigured := getJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", adminSession) if unconfigured.Configured || unconfigured.Reason != "runtime profile is not configured" { t.Fatalf("unexpected unconfigured projection: %+v", unconfigured) } missingStart := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/"+server.ID+"/start", dto.ServerLifecycleCommandRequest{ExpectedConfigVersion: server.ConfigVersion, IdempotencyKey: "api-start-missing-binding"}, adminSession) if !missingStart.Accepted || missingStart.Job.TargetKey != "actions/start.json" { t.Fatalf("expected plugin lifecycle start without manual runtime binding, got %+v", missingStart) } incompleteRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession) assertStatus(t, incompleteRecorder, http.StatusOK) incompleteBody := incompleteRecorder.Body.String() incomplete := decodeBody[dto.RuntimeBindingResponse](t, incompleteRecorder) if incomplete.Status != domain.RuntimeBindingStatusIncomplete || len(incomplete.MissingKeys) != 1 || incomplete.MissingKeys[0] != "rcon.password" { t.Fatalf("unexpected incomplete projection: %+v", incomplete) } if strings.Contains(incompleteBody, "runtime.server-root") { t.Fatalf("binding response exposed stored logical ref: %s", incompleteBody) } completeRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"rcon.password": "secret://runtime-binding-api/rcon"}}, adminSession) assertStatus(t, completeRecorder, http.StatusOK) completeBody := completeRecorder.Body.String() complete := decodeBody[dto.RuntimeBindingResponse](t, completeRecorder) secretFlag := false for _, key := range complete.Keys { if key.Key == "rcon.password" { secretFlag = key.Configured && key.Secret } } if complete.Status != domain.RuntimeBindingStatusComplete || !secretFlag { t.Fatalf("unexpected complete projection: %+v", complete) } for _, forbidden := range []string{"secret://runtime-binding-api/rcon", "runtime.server-root", "/srv/game", "unix://", "tcp://", "password="} { if strings.Contains(completeBody, forbidden) { t.Fatalf("runtime binding response exposed %q: %s", forbidden, completeBody) } } unsafe := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "/srv/game"}}, adminSession) assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation) undeclared := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"host.socket": "runtime.socket"}}, adminSession) assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation) legacyCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", map[string]any{"id": "runtime-create-complete", "pluginId": registration.Manifest.ID, "runEndpointId": "run-local", "name": "Runtime Create Complete", "idempotencyKey": "runtime-create-complete", "profileKey": "local", "bindings": map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession) assertErrorResponse(t, legacyCreate, http.StatusBadRequest, errorCodeBadRequest) incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", map[string]any{"id": "runtime-create-incomplete", "pluginId": registration.Manifest.ID, "runEndpointId": "run-local", "name": "Runtime Create Incomplete", "idempotencyKey": "runtime-create-incomplete", "profileKey": "local", "bindings": map[string]string{"server-root": "runtime.server-root"}}, adminSession) assertErrorResponse(t, incompleteCreate, http.StatusBadRequest, errorCodeBadRequest) missingServer := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/runtime-create-incomplete", "", adminSession) assertErrorResponse(t, missingServer, http.StatusNotFound, errorCodeNotFound) } func TestGamePluginManifestAPISafelyProjectsDLLReleaseDeclaration(t *testing.T) { router := newTestRouter() registration := validGamePluginManifestRegistrationRequest() registration.Manifest.RuntimeProfiles = dto.GamePluginRuntimeProfilesBody{DLLExtensions: []dto.RuntimeDLLExtensionProfileBody{{ Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready", ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024, TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015, }}} recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration) assertStatus(t, recorder, http.StatusCreated) body := recorder.Body.String() response := decodeBody[dto.GamePluginResponse](t, recorder) if len(response.RuntimeProfiles.DLLExtensions) != 1 { t.Fatalf("expected DLL declaration projection, got %+v", response.RuntimeProfiles) } projected := response.RuntimeProfiles.DLLExtensions[0] if projected.ReleaseHost != "cdn.npc0.com" || projected.ReleaseFilename != "scum_simple_rcon_ue4s.dll" { t.Fatalf("expected safe DLL release projection, got %+v", projected) } for _, unsafe := range []string{"https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", "ue4ss/Mods/", "targetKey", "modKey", "rconPort"} { if strings.Contains(body, unsafe) { t.Fatalf("browser projection exposed DLL deployment internals %q: %s", unsafe, body) } } } func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) { router := newTestRouter() recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) assertStatus(t, recorder, http.StatusCreated) var body map[string]any if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { t.Fatalf("decode plugin response: %v", err) } for _, forbidden := range []string{"apiKey", "rawApiKey", "hostPath", "runSocket", "runCredential"} { if _, exists := body[forbidden]; exists { t.Fatalf("game plugin response must not expose %s: %+v", forbidden, body) } } } func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) serverID := createRuntimeAPIFixtures(t, router, adminSession) createAIProviderFixture(t, router, adminSession) lifecycleRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install"}, adminSession) assertStatus(t, lifecycleRecorder, http.StatusAccepted) lifecycle := decodeBody[dto.PluginLifecycleActionResponse](t, lifecycleRecorder) if lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" { t.Fatalf("expected queued plugin lifecycle job, got %+v", lifecycle) } lifecycles := getJSONWithAuth[dto.PluginLifecycleListResponse](t, router, "/api/v1/plugin-lifecycles?serverInstanceId="+serverID, adminSession) if lifecycles.Count != 1 || lifecycles.Items[0].JobID != lifecycle.Job.ID { t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles) } invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession) if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" { t.Fatalf("expected persisted AI config diff, got %+v", invocation) } diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession) if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) { t.Fatalf("expected one pending AI diff, got %+v", diffs) } approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession) assertStatus(t, approvalRecorder, http.StatusAccepted) approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder) if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite { t.Fatalf("expected approved diff with config write job, got %+v", approval) } evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval) for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} { if strings.Contains(evidence, forbidden) { t.Fatalf("plugin operations response leaked forbidden fragment %q: %s", forbidden, evidence) } } } type apiTestDistributionBuilder struct { release <-chan struct{} } func (builder apiTestDistributionBuilder) Readiness() (bool, string) { return true, "" } func (builder apiTestDistributionBuilder) Build(input domain.DistributionBuildInput) ([]byte, error) { if builder.release != nil { <-builder.release } return []byte("api-platform-built-distribution:" + input.JobID), nil } func newTestRouterWithDistributionBuilder(builder service.DistributionBuilder) http.Handler { core := service.NewCoreService(repo.NewMemoryStore()) core.ConfigureDistributionBuilder(builder) if err := core.SeedLocalPlatformAdmin(); err != nil { panic(err) } return NewTestRouterWithCore(core) } func newTestRouter() http.Handler { return newTestRouterWithDistributionBuilder(nil) } func apiRouterWithoutSeededAdmin() http.Handler { return NewTestRouterWithCore(service.NewCoreService(repo.NewMemoryStore())) } func postJSON[T any](t *testing.T, router http.Handler, path string, body any) T { t.Helper() recorder := performJSON(t, router, http.MethodPost, path, body) assertStatus(t, recorder, http.StatusCreated) return decodeBody[T](t, recorder) } func postOKJSON[T any](t *testing.T, router http.Handler, path string, body any) T { t.Helper() recorder := performJSON(t, router, http.MethodPost, path, body) assertStatus(t, recorder, http.StatusOK) return decodeBody[T](t, recorder) } func postOKJSONWithAuth[T any](t *testing.T, router http.Handler, path string, body any, sessionID string) T { t.Helper() recorder := requestJSONWithAuth(t, router, http.MethodPost, path, body, sessionID) assertStatus(t, recorder, http.StatusOK) return decodeBody[T](t, recorder) } func postJSONWithAuth[T any](t *testing.T, router http.Handler, path string, body any, sessionID string) T { t.Helper() recorder := requestJSONWithAuth(t, router, http.MethodPost, path, body, sessionID) assertStatus(t, recorder, http.StatusCreated) return decodeBody[T](t, recorder) } func putJSON[T any](t *testing.T, router http.Handler, path string, body any) T { t.Helper() recorder := performJSON(t, router, http.MethodPut, path, body) assertStatus(t, recorder, http.StatusOK) return decodeBody[T](t, recorder) } func putJSONWithAuth[T any](t *testing.T, router http.Handler, path string, body any, sessionID string) T { t.Helper() recorder := requestJSONWithAuth(t, router, http.MethodPut, path, body, sessionID) assertStatus(t, recorder, http.StatusOK) return decodeBody[T](t, recorder) } func getJSON[T any](t *testing.T, router http.Handler, path string) T { t.Helper() recorder := performRaw(t, router, http.MethodGet, path, "") assertStatus(t, recorder, http.StatusOK) return decodeBody[T](t, recorder) } func getJSONWithAuth[T any](t *testing.T, router http.Handler, path string, sessionID string) T { t.Helper() recorder := requestWithAuth(t, router, http.MethodGet, path, "", sessionID) assertStatus(t, recorder, http.StatusOK) return decodeBody[T](t, recorder) } func performJSON(t *testing.T, router http.Handler, method string, path string, body any) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { t.Fatalf("encode request body: %v", err) } return performRequest(t, router, method, path, &buf) } func requestJSONWithAuth(t *testing.T, router http.Handler, method string, path string, body any, sessionID string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { t.Fatalf("encode request body: %v", err) } return requestWithReaderAndAuth(t, router, method, path, &buf, sessionID) } func performRaw(t *testing.T, router http.Handler, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() if body == "" { return performRequest(t, router, method, path, nil) } return performRequest(t, router, method, path, bytes.NewBufferString(body)) } func performRequest(t *testing.T, router http.Handler, method string, path string, body io.Reader) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, body) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func requestWithAuth(t *testing.T, router http.Handler, method string, path string, body string, sessionID string) *httptest.ResponseRecorder { t.Helper() var reader io.Reader if body != "" { reader = bytes.NewBufferString(body) } return requestWithReaderAndAuth(t, router, method, path, reader, sessionID) } func requestWithReaderAndAuth(t *testing.T, router http.Handler, method string, path string, body io.Reader, sessionID string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+sessionID) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func createAdminSession(t *testing.T, router http.Handler) string { t.Helper() session := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{ Account: "operator.local@example.test", Password: "operator-local", }) if session.SessionID == "" { t.Fatalf("expected admin session token") } return session.SessionID } func createAIProviderFixture(t *testing.T, router http.Handler, adminSession string) dto.AIProviderResponse { t.Helper() return postJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest(), adminSession) } func decodeBody[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { t.Helper() var body T if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { t.Fatalf("decode response body: %v", err) } return body } func mustJSON(t *testing.T, value any) string { t.Helper() encoded, err := json.Marshal(value) if err != nil { t.Fatalf("marshal response body: %v", err) } return string(encoded) } func assertStatus(t *testing.T, recorder *httptest.ResponseRecorder, want int) { t.Helper() if recorder.Code != want { t.Fatalf("expected status %d, got %d body=%s", want, recorder.Code, recorder.Body.String()) } } func assertErrorResponse(t *testing.T, recorder *httptest.ResponseRecorder, status int, code string) { t.Helper() assertStatus(t, recorder, status) response := decodeBody[dto.ErrorResponse](t, recorder) if response.Code != code || response.Message == "" { t.Fatalf("expected error code %q with message, got %+v", code, response) } } func assertListCount(t *testing.T, got int, want int) { t.Helper() if got != want { t.Fatalf("expected list count %d, got %d", want, got) } } func anyJSON(t *testing.T, value any) map[string]any { t.Helper() payload, err := json.Marshal(value) if err != nil { t.Fatalf("marshal json: %v", err) } var body map[string]any if err := json.Unmarshal(payload, &body); err != nil { t.Fatalf("unmarshal json: %v", err) } return body } func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession string) string { t.Helper() pluginRequest := validGamePluginRequest() pluginRequest.ID = "server.runtime" pluginRequest.Name = "Runtime Test Plugin" pluginRequest.ServerType = "runtime-test" pluginRequest.SupportedOS = []string{"linux", "windows"} pluginRequest.RequiredRunCapabilities = []string{ "process.install", "process.start", "process.stop", "logs.read", domain.JobCapabilityConfigWrite, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, } pluginRequest.DeclaredPermissions = []string{ "server.read", "server.logs.read", "server.run.distribution", "server.client-manager.manage", "server.dependencies.manage", "server.artifacts.read", } pluginRequest.BridgeActions = []string{ string(domain.PluginBridgeActionRunDistribution), string(domain.PluginBridgeActionClientManager), string(domain.PluginBridgeActionDependenciesRequest), string(domain.PluginBridgeActionLogsBackfillRequest), } pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}} pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}} pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}} pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) endpoint := validRunEndpointRequest() endpoint.ID = "run-runtime" endpoint.Platform = "linux" endpoint.Architecture = "amd64" endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDistributionBuild, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, ) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint) server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-runtime-api", PluginID: "server.runtime", RunEndpointID: "run-runtime", Name: "Runtime API Server", State: domain.ServerInstanceStateReady, }, adminSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"logs/latest": "runtime.logs.latest"}}, adminSession) postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ ID: "log-runtime-api", ServerInstanceID: server.ID, Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", }) return server.ID } func validAIProviderRequest() dto.AIProviderCreateRequest { return dto.AIProviderCreateRequest{ ID: "ai.openai", Name: "OpenAI", Kind: domain.AIProviderKindOpenAI, BaseURL: "https://api.openai.com/v1", APIKeyRef: "secret://providers/openai", Models: []string{"gpt-4.1", "gpt-4.1-mini"}, DefaultModel: "gpt-4.1", RelayMode: domain.AIRelayModeDirect, TimeoutMS: 30000, RedactionPolicy: "default", } } func validAIProviderUpdateRequest() dto.AIProviderUpdateRequest { return dto.AIProviderUpdateRequest{ Name: "OpenAI Relay", Kind: domain.AIProviderKindOpenAI, BaseURL: "https://relay.example.test/v1", APIKeyRef: "vault://providers/openai", Models: []string{"gpt-4.1", "gpt-4.1-mini"}, DefaultModel: "gpt-4.1-mini", RelayMode: domain.AIRelayModeRelay, TimeoutMS: 45000, RedactionPolicy: "default", } } func validGamePluginRequest() dto.GamePluginCreateRequest { return dto.GamePluginCreateRequest{ ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: []string{"process.install", "process.start", "process.stop", "logs.read"}, Permissions: dto.PluginPermissionsResponse{ Logs: true, Jobs: true, }, LifecycleActions: dto.PluginLifecycleActionsBody{ Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", }, RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, } } func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistrationRequest { return dto.GamePluginManifestRegistrationRequest{ ManifestRef: "artifact://manifests/game.example/0.1.0", Manifest: dto.GamePluginManifestBody{ ID: "game.example", Name: "Example Server", Description: "Development plugin", Version: "0.1.0", Kind: "game-plugin", Tags: []string{"example", "development"}, Server: dto.GamePluginManifestServerBody{ Type: "example", DisplayName: "Example Server", SupportedOS: []string{"linux", "darwin"}, CreateFormSchema: "schemas/create-form.schema.json", }, Bridge: dto.GamePluginBridgeBody{ Actions: []string{ string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke), }, }, Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}, Permissions: []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"}, Actions: dto.PluginLifecycleActionsBody{ Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Restart: "actions/restart.json", }, Pages: []dto.GamePluginPageBody{ { Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read", "ai.invoke"}, BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)}, }, }, AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"}, ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"}, RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, }, } } func validRunEndpointRequest() dto.RunEndpointCreateRequest { return dto.RunEndpointCreateRequest{ ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Capabilities: []string{"process.install", "process.start", "process.stop", "logs.read", "config.write", "files.read", "files.write", "artifacts.read", "ai.invoke"}, Capacity: dto.RunCapacityResponse{ MaxJobs: 4, }, } }