package api import ( "bytes" "encoding/json" "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 := postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) if providerResponse.APIKeyRef != "secret://providers/openai" { t.Fatalf("expected AI provider key reference, got %+v", providerResponse) } getJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai") providers := getJSON[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active") 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") assertListCount(t, streams.Count, 1) auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{ ID: "audit-1", ActorID: "user-1", Action: "server.create", ResourceKind: "server-instance", ResourceID: "server-1", Result: domain.AuditResultSuccess, Summary: "created server instance", }) if auditResponse.Result != domain.AuditResultSuccess { t.Fatalf("expected successful audit event, got %+v", auditResponse) } getJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events/audit-1") auditEvents := getJSON[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success") assertListCount(t, auditEvents.Count, 1) } 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()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) 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 { 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) } configRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession) assertStatus(t, configRecorder, http.StatusOK) config := decodeBody[dto.ServerConfigResponse](t, configRecorder) if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics API Server") { t.Fatalf("unexpected config response: %+v", config) } for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { if strings.Contains(configRecorder.Body.String(), forbidden) { t.Fatalf("config response exposed forbidden fragment %q: %s", forbidden, configRecorder.Body.String()) } } assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusForbidden, errorCodeForbidden) } 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) config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession) proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1) previewRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ ExpectedConfigVersion: config.ConfigVersion, Key: config.Key, ProposedContent: proposed, }, ownerSession) assertStatus(t, previewRecorder, http.StatusOK) preview := decodeBody[dto.ServerConfigDiffPreviewResponse](t, previewRecorder) if !preview.HasChanges || preview.Source != "platform-review" || preview.ServerInstanceID != instance.ID { t.Fatalf("unexpected preview: %+v", preview) } jobsAfterPreview := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-config-api", ownerSession) if jobsAfterPreview.Count != 0 { t.Fatalf("preview must not create jobs: %+v", jobsAfterPreview) } approveRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ ExpectedConfigVersion: config.ConfigVersion, Key: config.Key, ProposedContent: proposed, IdempotencyKey: "idem-config-api", }, ownerSession) assertStatus(t, approveRecorder, http.StatusAccepted) dispatch := decodeBody[dto.ServerConfigWriteDispatchResponse](t, approveRecorder) if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != config.Key || dispatch.Job.InputRef == "" { t.Fatalf("unexpected approval dispatch: %+v", dispatch) } for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} { if strings.Contains(approveRecorder.Body.String(), forbidden) { t.Fatalf("approval response exposed forbidden fragment %q: %s", forbidden, approveRecorder.Body.String()) } } assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ ExpectedConfigVersion: config.ConfigVersion + 1, Key: config.Key, ProposedContent: proposed, }, ownerSession), http.StatusBadRequest, errorCodeValidation) assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ ExpectedConfigVersion: config.ConfigVersion, Key: config.Key, ProposedContent: proposed, IdempotencyKey: "idem-forbidden-api", }, otherSession), http.StatusForbidden, errorCodeForbidden) 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 TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { router := newTestRouter() 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", "historical-logs"} { 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.KeyGeneration != 1 || runDistribution.SecretRef == "" { t.Fatalf("unexpected run distribution: %+v", runDistribution) } runDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession) if runDownload.ArtifactID != runDistribution.ArtifactID || runDownload.DownloadURL == "" { t.Fatalf("unexpected run download: %+v", runDownload) } updateRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/update", dto.RunUpdateRequest{ArtifactID: runDistribution.ArtifactID, Checksum: runDistribution.Checksum, IdempotencyKey: "api-run-update"}, adminSession) assertStatus(t, updateRecorder, http.StatusAccepted) update := decodeBody[dto.RunUpdateJobResponse](t, updateRecorder) if update.JobID == "" || update.ArtifactID != runDistribution.ArtifactID || update.Status != string(domain.DistributionJobStatusQueued) { t.Fatalf("unexpected run update job: %+v", update) } 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) } clientDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession) if clientDownload.ArtifactID != clientDistribution.ArtifactID { t.Fatalf("unexpected client download: %+v", clientDownload) } 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) } dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", 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) backfillRecorder := 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) assertStatus(t, backfillRecorder, http.StatusAccepted) backfill := decodeBody[dto.JobResponse](t, backfillRecorder) if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" { t.Fatalf("unexpected log backfill job: %+v", backfill) } liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession) if liveLogs.Count != 1 || liveLogs.Items[0].StreamKey != "stdout" { t.Fatalf("unexpected live logs: %+v", liveLogs) } 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, runDownload), mustJSON(t, clientDownload), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} { 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) } } } audits := getJSONWithAuth[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?resourceId="+serverID, adminSession) auditActions := map[string]bool{} for _, audit := range audits.Items { auditActions[audit.Action] = true } for _, action := range []string{"run.generate", "run.download", "run.update", "client-manager.build", "client-manager.download", "dependency.install", "logs.backfill", "runtime-key.reset"} { if !auditActions[action] { t.Fatalf("expected audit action %q in %+v", action, audits.Items) } } } 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 := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey) assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation) missingProvider := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "") 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"), }) if err != nil { t.Fatalf("create default router: %v", err) } login := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{ Account: "operator.local@example.test", Password: "operator-local", }) if login.SessionID == "" || login.Status != "authenticated" || login.User.ID != "user-admin" { t.Fatalf("unexpected default operator login response: %+v", login) } 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-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", RunEndpointID: "run-local", Name: "SCUM Create", IdempotencyKey: "idem-create", }, adminSession) if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall { t.Fatalf("expected create workflow response, got %+v", created) } 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) 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) 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 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-archive", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Running Archive", State: domain.ServerInstanceStateRunning, }, adminSession) unsafeArchive := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, "", adminSession) assertErrorResponse(t, unsafeArchive, http.StatusBadRequest, errorCodeValidation) archived := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", "", adminSession) assertStatus(t, archived, 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("archived 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 archived server, got %+v", deletedList) } blank := "" invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-archive", 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 TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) { router := newTestRouter() recorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest()) 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 body["apiKeyRef"] != "secret://providers/openai" { t.Fatalf("expected apiKeyRef only, got %+v", body) } } func TestAIProviderManagementAPI(t *testing.T) { router := newTestRouter() postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) update := validAIProviderUpdateRequest() updatedRecorder := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update) assertStatus(t, updatedRecorder, http.StatusOK) updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder) if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive { t.Fatalf("unexpected updated provider: %+v", updated) } statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}) 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 := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "") assertStatus(t, testRecorder, http.StatusOK) testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder) if testResult.Success || testResult.Mode != "metadata" { t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult) } models := getJSON[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models") if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 { t.Fatalf("unexpected models response: %+v", models) } statusRecorder = performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}) assertStatus(t, statusRecorder, http.StatusOK) testRecorder = performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "") 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() postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) rawUpdate := validAIProviderUpdateRequest() rawUpdate.APIKeyRef = "sk-raw-secret" rawFailure := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate) assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation) invalidStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}) assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation) missingUpdate := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest()) assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound) missingStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}) assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound) missingTest := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "") assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound) missingModels := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "") assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound) } func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) 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) } duplicate := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration) assertErrorResponse(t, duplicate, http.StatusConflict, errorCodeDuplicate) } 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 postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) 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) 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) 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 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 newTestRouter() http.Handler { core := service.NewCoreService(repo.NewMemoryStore()) if err := core.SeedLocalPlatformAdmin(); err != nil { panic(err) } return NewRouterWithCore(core) } func apiRouterWithoutSeededAdmin() http.Handler { return NewRouterWithCore(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 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.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, } 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), } postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) endpoint := validRunEndpointRequest() endpoint.ID = "run-runtime" endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, ) 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) 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", }, } } 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"}}, }, } } 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, }, } }