Files
browser/platform/api/resource_handlers_test.go
T

2017 lines
112 KiB
Go

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-ops",
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-ops", 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-ops", 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-ops" {
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-ops" || 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,
},
}
}