2147 lines
116 KiB
Go
2147 lines
116 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)
|
|
}
|
|
|
|
auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{
|
|
ID: "audit-1",
|
|
ActorID: "user-1",
|
|
Action: "server.create",
|
|
ResourceKind: "server-instance",
|
|
ResourceID: "server-1",
|
|
Result: domain.AuditResultSuccess,
|
|
Summary: "created server instance",
|
|
})
|
|
if auditResponse.Result != domain.AuditResultSuccess {
|
|
t.Fatalf("expected successful audit event, got %+v", auditResponse)
|
|
}
|
|
getJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events/audit-1")
|
|
auditEvents := getJSON[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success")
|
|
assertListCount(t, auditEvents.Count, 1)
|
|
}
|
|
|
|
func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-owner-metrics",
|
|
DisplayName: "Metrics Owner",
|
|
Email: "owner-metrics@example.test",
|
|
Roles: []string{"server-owner"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-other-metrics",
|
|
DisplayName: "Metrics Other",
|
|
Email: "other-metrics@example.test",
|
|
Roles: []string{"server-admin"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-metrics@example.test", Password: "secret-password"}).SessionID
|
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID
|
|
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-metrics-api",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "Metrics API Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
}, ownerSession)
|
|
|
|
usage := getJSONWithAuth[dto.PlatformResourceUsageResponse](t, router, "/api/v1/metrics/platform", adminSession)
|
|
if usage.Source != "platform-derived" || usage.CPUPercent < 0 || usage.CPUPercent > 100 || usage.CollectedAt.IsZero() {
|
|
t.Fatalf("unexpected platform metrics: %+v", usage)
|
|
}
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/metrics/platform", "", ownerSession), http.StatusForbidden, errorCodeForbidden)
|
|
|
|
ownerMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", ownerSession)
|
|
if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || !ownerMetrics.Items[0].Online || 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)
|
|
}
|
|
|
|
configRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession)
|
|
assertStatus(t, configRecorder, http.StatusOK)
|
|
config := decodeBody[dto.ServerConfigResponse](t, configRecorder)
|
|
if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics API Server") {
|
|
t.Fatalf("unexpected config response: %+v", config)
|
|
}
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
|
|
if strings.Contains(configRecorder.Body.String(), forbidden) {
|
|
t.Fatalf("config response exposed forbidden fragment %q: %s", forbidden, configRecorder.Body.String())
|
|
}
|
|
}
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
}
|
|
|
|
func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-owner-config-api",
|
|
DisplayName: "Config API Owner",
|
|
Email: "owner-config-api@example.test",
|
|
Roles: []string{"server-owner"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-other-config-api",
|
|
DisplayName: "Config API Other",
|
|
Email: "other-config-api@example.test",
|
|
Roles: []string{"server-admin"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-config-api@example.test", Password: "secret-password"}).SessionID
|
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-config-api@example.test", Password: "secret-password"}).SessionID
|
|
|
|
pluginRequest := validGamePluginRequest()
|
|
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityConfigWrite, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-config-api",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "Config API Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
}, ownerSession)
|
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
|
config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession)
|
|
proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1)
|
|
|
|
previewRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{
|
|
ExpectedConfigVersion: config.ConfigVersion,
|
|
Key: config.Key,
|
|
ProposedContent: proposed,
|
|
}, ownerSession)
|
|
assertStatus(t, previewRecorder, http.StatusOK)
|
|
preview := decodeBody[dto.ServerConfigDiffPreviewResponse](t, previewRecorder)
|
|
if !preview.HasChanges || preview.Source != "platform-review" || preview.ServerInstanceID != instance.ID {
|
|
t.Fatalf("unexpected preview: %+v", preview)
|
|
}
|
|
jobsAfterPreview := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-config-api", ownerSession)
|
|
if jobsAfterPreview.Count != 0 {
|
|
t.Fatalf("preview must not create jobs: %+v", jobsAfterPreview)
|
|
}
|
|
|
|
approveRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{
|
|
ExpectedConfigVersion: config.ConfigVersion,
|
|
Key: config.Key,
|
|
ProposedContent: proposed,
|
|
IdempotencyKey: "idem-config-api",
|
|
}, ownerSession)
|
|
assertStatus(t, approveRecorder, http.StatusAccepted)
|
|
dispatch := decodeBody[dto.ServerConfigWriteDispatchResponse](t, approveRecorder)
|
|
if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != config.Key || dispatch.Job.InputRef == "" {
|
|
t.Fatalf("unexpected approval dispatch: %+v", dispatch)
|
|
}
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
|
|
if strings.Contains(approveRecorder.Body.String(), forbidden) {
|
|
t.Fatalf("approval response exposed forbidden fragment %q: %s", forbidden, approveRecorder.Body.String())
|
|
}
|
|
}
|
|
|
|
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{
|
|
ExpectedConfigVersion: config.ConfigVersion + 1,
|
|
Key: config.Key,
|
|
ProposedContent: proposed,
|
|
}, ownerSession), http.StatusBadRequest, errorCodeValidation)
|
|
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{
|
|
ExpectedConfigVersion: config.ConfigVersion,
|
|
Key: config.Key,
|
|
ProposedContent: proposed,
|
|
IdempotencyKey: "idem-forbidden-api",
|
|
}, otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{
|
|
ServerInstanceID: "server-config-api",
|
|
Operation: domain.FileOperationRead,
|
|
Key: "/Users/tasia/.ssh/id_rsa",
|
|
IdempotencyKey: "idem-file-unsafe-api",
|
|
}, ownerSession), http.StatusBadRequest, errorCodeValidation)
|
|
|
|
fileRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{
|
|
ServerInstanceID: "server-config-api",
|
|
Operation: domain.FileOperationRead,
|
|
Key: "logs/latest.log",
|
|
IdempotencyKey: "idem-file-api",
|
|
}, ownerSession)
|
|
assertStatus(t, fileRecorder, http.StatusAccepted)
|
|
fileDispatch := decodeBody[dto.FileOperationDispatchResponse](t, fileRecorder)
|
|
if fileDispatch.Job.Capability != domain.JobCapabilityFilesRead || fileDispatch.Job.TargetKey != "logs/latest.log" {
|
|
t.Fatalf("unexpected file dispatch: %+v", fileDispatch)
|
|
}
|
|
}
|
|
|
|
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-owner-file-snapshot-api",
|
|
DisplayName: "File Snapshot API Owner",
|
|
Email: "owner-file-snapshot-api@example.test",
|
|
Roles: []string{"server-owner"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-other-file-snapshot-api",
|
|
DisplayName: "File Snapshot API Other",
|
|
Email: "other-file-snapshot-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-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
|
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
|
|
|
|
pluginRequest := validGamePluginRequest()
|
|
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead)
|
|
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"},
|
|
},
|
|
ConfigFields: []dto.PluginConfigFieldBody{
|
|
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
|
|
},
|
|
}
|
|
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-file-snapshot-api",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "File Snapshot API Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
}, ownerSession)
|
|
|
|
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
|
|
if snapshot.State != "not-read" || snapshot.Content != "" {
|
|
t.Fatalf("expected not-read snapshot, got %+v", snapshot)
|
|
}
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation)
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
|
|
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
|
ID: "job-file-snapshot-api-read",
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: "run-local",
|
|
Capability: domain.JobCapabilityFilesRead,
|
|
TargetKey: "scum-server-settings",
|
|
IdempotencyKey: "idem-file-snapshot-api-read",
|
|
})
|
|
helloRequest := validRunControlHelloRequest()
|
|
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead)
|
|
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
|
|
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
|
|
RunEndpointID: "run-local",
|
|
SessionToken: hello.SessionToken,
|
|
Capabilities: []string{domain.JobCapabilityFilesRead},
|
|
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
|
}))
|
|
if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" {
|
|
t.Fatalf("expected file read job claim, got %+v", claim)
|
|
}
|
|
content := "ServerName=API\nRconPassword=secret\n"
|
|
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
|
|
RunEndpointID: "run-local",
|
|
SessionToken: hello.SessionToken,
|
|
JobID: claim.Job.JobID,
|
|
LeaseToken: claim.Job.LeaseToken,
|
|
Attempt: claim.Job.Attempt,
|
|
State: domain.JobStateSucceeded,
|
|
Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"},
|
|
Message: "file read completed",
|
|
ExecutionResult: dto.RunJobExecutionResultBody{
|
|
Kind: "file.read",
|
|
Version: 9,
|
|
SizeBytes: int64(len(content)),
|
|
Content: content,
|
|
},
|
|
})
|
|
assertStatus(t, resultRecorder, http.StatusOK)
|
|
ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
|
|
if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=<redacted>") || strings.Contains(ready.Content, "secret") {
|
|
t.Fatalf("expected ready redacted snapshot, got %+v", ready)
|
|
}
|
|
}
|
|
|
|
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", "historical-logs"} {
|
|
if !availability[key] {
|
|
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
|
|
}
|
|
}
|
|
|
|
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
|
|
if runDistribution.ArtifactID == "" || runDistribution.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)
|
|
|
|
backfillRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession)
|
|
assertStatus(t, backfillRecorder, http.StatusAccepted)
|
|
backfill := decodeBody[dto.JobResponse](t, backfillRecorder)
|
|
if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" {
|
|
t.Fatalf("unexpected log backfill job: %+v", backfill)
|
|
}
|
|
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
|
|
foundFileLog := false
|
|
for _, stream := range liveLogs.Items {
|
|
if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" {
|
|
foundFileLog = true
|
|
}
|
|
}
|
|
if !foundFileLog {
|
|
t.Fatalf("unexpected live logs: %+v", liveLogs)
|
|
}
|
|
|
|
runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession)
|
|
if runReset.Generation != 2 || runReset.SecretRef == "" {
|
|
t.Fatalf("unexpected run key reset: %+v", runReset)
|
|
}
|
|
clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession)
|
|
if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef {
|
|
t.Fatalf("unexpected client key reset: %+v", clientReset)
|
|
}
|
|
|
|
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} {
|
|
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
audits := getJSONWithAuth[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?resourceId="+serverID, adminSession)
|
|
auditActions := map[string]bool{}
|
|
for _, audit := range audits.Items {
|
|
auditActions[audit.Action] = true
|
|
}
|
|
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "logs.backfill", "runtime-key.reset"} {
|
|
if !auditActions[action] {
|
|
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
|
|
}
|
|
}
|
|
}
|
|
|
|
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", dto.ServerLifecycleCreateRequest{
|
|
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, errorCodeValidation)
|
|
|
|
ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-ready",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "SCUM Ready",
|
|
State: domain.ServerInstanceStateReady,
|
|
}, adminSession)
|
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-ready/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
|
started := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{
|
|
ExpectedConfigVersion: ready.ConfigVersion,
|
|
IdempotencyKey: "idem-start",
|
|
}, adminSession)
|
|
if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart {
|
|
t.Fatalf("expected start workflow response, got %+v", started)
|
|
}
|
|
|
|
running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-running",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "SCUM Running",
|
|
State: domain.ServerInstanceStateRunning,
|
|
}, adminSession)
|
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-running/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
|
stopped := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-running/stop", dto.ServerLifecycleCommandRequest{
|
|
ExpectedConfigVersion: running.ConfigVersion,
|
|
IdempotencyKey: "idem-stop",
|
|
}, adminSession)
|
|
if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop {
|
|
t.Fatalf("expected stop workflow response, got %+v", stopped)
|
|
}
|
|
|
|
stale := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{
|
|
ExpectedConfigVersion: ready.ConfigVersion + 1,
|
|
IdempotencyKey: "idem-stale",
|
|
}, adminSession)
|
|
assertErrorResponse(t, stale, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
invalidStop := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-ready/stop", dto.ServerLifecycleCommandRequest{
|
|
ExpectedConfigVersion: ready.ConfigVersion,
|
|
IdempotencyKey: "idem-invalid-stop",
|
|
}, adminSession)
|
|
assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation)
|
|
}
|
|
|
|
func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
endpoint := validRunEndpointRequest()
|
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan)
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
|
postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", Name: "Reveal", IdempotencyKey: "deployment-reveal", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession)
|
|
|
|
redactedRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment", "", adminSession)
|
|
assertStatus(t, redactedRecorder, http.StatusOK)
|
|
if body := redactedRecorder.Body.String(); strings.Contains(body, "/srv/reveal") || strings.Contains(body, "./start-server") {
|
|
t.Fatalf("normal deployment view leaked protected inputs: %s", body)
|
|
}
|
|
redacted := decodeBody[dto.ServerDeploymentResponse](t, redactedRecorder)
|
|
if redacted.LatestDispatch != nil {
|
|
t.Fatalf("create-time deployment settings must not dispatch to Run, got %+v", redacted.LatestDispatch)
|
|
}
|
|
|
|
revealed := getJSONWithAuth[dto.ServerDeploymentRevealResponse](t, router, "/api/v1/server-instances/deployment-reveal/deployment/reveal", adminSession)
|
|
if revealed.ServerRoot != "/srv/reveal" || revealed.WorkingDirectory != "/srv/reveal" || revealed.StartCommand != "./start-server" || revealed.InstallCommand != "" {
|
|
t.Fatalf("unexpected explicitly revealed deployment: %+v", revealed)
|
|
}
|
|
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "deployment-other", DisplayName: "Other", Email: "deployment-other@example.test", Roles: []string{"server-owner"}, Password: "other-password"}, adminSession)
|
|
other := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "deployment-other@example.test", Password: "other-password"})
|
|
denied := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment/reveal", "", other.SessionID)
|
|
assertErrorResponse(t, denied, http.StatusForbidden, errorCodeForbidden)
|
|
}
|
|
|
|
func TestServerInstanceManagementAPI(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
|
|
ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-management",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "SCUM Ops",
|
|
State: domain.ServerInstanceStateReady,
|
|
}, adminSession)
|
|
|
|
newName := "SCUM Ops Renamed"
|
|
updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-management", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession)
|
|
if updated.Name != newName || updated.PluginID != ready.PluginID || updated.RunEndpointID != ready.RunEndpointID {
|
|
t.Fatalf("unexpected server update: %+v", updated)
|
|
}
|
|
|
|
running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-running-delete",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "SCUM Running Delete",
|
|
State: domain.ServerInstanceStateRunning,
|
|
}, adminSession)
|
|
unsafeDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
|
assertErrorResponse(t, unsafeDelete, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
|
assertStatus(t, deleted, http.StatusNoContent)
|
|
activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession)
|
|
for _, item := range activeList.Items {
|
|
if item.ID == "server-management" {
|
|
t.Fatalf("deleted server should be hidden from normal list: %+v", activeList)
|
|
}
|
|
}
|
|
deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession)
|
|
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
|
t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList)
|
|
}
|
|
forcedDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local", Force: true, Confirmation: service.ServerDeletionForceConfirmation}), adminSession)
|
|
assertStatus(t, forcedDelete, http.StatusNoContent)
|
|
forcedDeleted := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/"+running.ID, adminSession)
|
|
if forcedDeleted.State != domain.ServerInstanceStateDeleted {
|
|
t.Fatalf("expected forced deleted running server, got %+v", forcedDeleted)
|
|
}
|
|
|
|
blank := ""
|
|
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-delete", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
|
assertErrorResponse(t, invalidUpdate, http.StatusBadRequest, errorCodeValidation)
|
|
}
|
|
|
|
func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-owner",
|
|
DisplayName: "Server Owner",
|
|
Email: "owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
Status: domain.UserStatusActive,
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-helper",
|
|
DisplayName: "Server Helper",
|
|
Email: "helper@example.test",
|
|
Roles: []string{"server-admin"},
|
|
Status: domain.UserStatusActive,
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
|
|
ownerLogin := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner@example.test", Password: "secret-password"})
|
|
helperLogin := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "helper@example.test", Password: "secret-password"})
|
|
|
|
created := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-owned",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "Owned Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
}, ownerLogin.SessionID)
|
|
if created.OwnerUserID != "user-owner" || len(created.AdminUserIDs) != 0 {
|
|
t.Fatalf("expected owner-bound server, got %+v", created)
|
|
}
|
|
createdBody := anyJSON(t, created)
|
|
adminUserIDs, ok := createdBody["adminUserIds"].([]any)
|
|
if !ok || len(adminUserIDs) != 0 {
|
|
t.Fatalf("expected adminUserIds to serialize as an empty array, got %+v", createdBody["adminUserIds"])
|
|
}
|
|
|
|
adminList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession)
|
|
assertListCount(t, adminList.Count, 1)
|
|
ownerList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", ownerLogin.SessionID)
|
|
assertListCount(t, ownerList.Count, 1)
|
|
helperListBefore := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", helperLogin.SessionID)
|
|
assertListCount(t, helperListBefore.Count, 0)
|
|
|
|
candidates := getJSONWithAuth[dto.ServerMemberListResponse](t, router, "/api/v1/server-instances/server-owned/administrators/candidates", ownerLogin.SessionID)
|
|
if candidates.Count != 1 || candidates.Items[0].ID != "user-helper" {
|
|
t.Fatalf("expected only non-platform helper candidate, got %+v", candidates)
|
|
}
|
|
helperCandidateBody := anyJSON(t, candidates.Items[0])
|
|
if _, exists := helperCandidateBody["passwordHash"]; exists {
|
|
t.Fatalf("server member response must not expose passwordHash: %+v", helperCandidateBody)
|
|
}
|
|
|
|
added := postOKJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-helper"}, ownerLogin.SessionID)
|
|
if len(added.AdminUserIDs) != 1 || added.AdminUserIDs[0] != "user-helper" {
|
|
t.Fatalf("expected helper admin membership, got %+v", added)
|
|
}
|
|
helperDetail := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-owned", helperLogin.SessionID)
|
|
if helperDetail.ID != "server-owned" {
|
|
t.Fatalf("expected helper to access assigned server, got %+v", helperDetail)
|
|
}
|
|
|
|
nonOwnerAdd := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-owner"}, helperLogin.SessionID)
|
|
assertErrorResponse(t, nonOwnerAdd, http.StatusForbidden, errorCodeForbidden)
|
|
platformAdd := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-owned/administrators", dto.ServerMemberRequest{UserID: "user-admin"}, ownerLogin.SessionID)
|
|
assertErrorResponse(t, platformAdd, http.StatusForbidden, errorCodeForbidden)
|
|
|
|
removed := requestJSONWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-owned/administrators/user-helper", nil, ownerLogin.SessionID)
|
|
assertStatus(t, removed, http.StatusOK)
|
|
removedBody := decodeBody[dto.ServerInstanceResponse](t, removed)
|
|
if len(removedBody.AdminUserIDs) != 0 {
|
|
t.Fatalf("expected helper membership removed, got %+v", removedBody)
|
|
}
|
|
forbiddenDetail := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-owned", "", helperLogin.SessionID)
|
|
assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden)
|
|
}
|
|
|
|
func TestServerInstanceDeleteRequiresOwnershipAndPasswordConfirmation(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-delete-owner-api",
|
|
DisplayName: "Delete Owner API",
|
|
Email: "delete-owner-api@example.test",
|
|
Roles: []string{"server-owner"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-delete-other-api",
|
|
DisplayName: "Delete Other API",
|
|
Email: "delete-other-api@example.test",
|
|
Roles: []string{"server-admin"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "delete-owner-api@example.test", Password: "secret-password"}).SessionID
|
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "delete-other-api@example.test", Password: "secret-password"}).SessionID
|
|
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-delete-api",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "Delete API Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
}, ownerSession)
|
|
adminTarget := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-delete-admin-api",
|
|
PluginID: "server.scum",
|
|
RunEndpointID: "run-local",
|
|
Name: "Delete Admin API Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
}, ownerSession)
|
|
|
|
missingPassword := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{}), ownerSession)
|
|
assertErrorResponse(t, missingPassword, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
wrongPassword := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "wrong-password"}), ownerSession)
|
|
assertErrorResponse(t, wrongPassword, http.StatusForbidden, errorCodeForbidden)
|
|
|
|
forbiddenDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "secret-password"}), otherSession)
|
|
assertErrorResponse(t, forbiddenDelete, http.StatusForbidden, errorCodeForbidden)
|
|
|
|
adminDeleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+adminTarget.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
|
assertStatus(t, adminDeleted, http.StatusNoContent)
|
|
|
|
deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "secret-password"}), ownerSession)
|
|
assertStatus(t, deleted, http.StatusNoContent)
|
|
}
|
|
|
|
func TestAIProviderAPIResponseDoesNotExposeRawSecretFields(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
|
assertStatus(t, recorder, http.StatusCreated)
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode provider response: %v", err)
|
|
}
|
|
if _, exists := body["apiKey"]; exists {
|
|
t.Fatalf("AI provider response must not expose apiKey: %+v", body)
|
|
}
|
|
if _, exists := body["rawApiKey"]; exists {
|
|
t.Fatalf("AI provider response must not expose rawApiKey: %+v", body)
|
|
}
|
|
if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
|
|
t.Fatalf("expected API key presence only, got %+v", body)
|
|
}
|
|
if _, exists := body["baseUrl"]; exists || body["baseUrlConfigured"] != true {
|
|
t.Fatalf("expected base URL presence only, got %+v", body)
|
|
}
|
|
}
|
|
|
|
func TestAIProviderManagementAPI(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
createAIProviderFixture(t, router, adminSession)
|
|
|
|
update := validAIProviderUpdateRequest()
|
|
update.BaseURL = ""
|
|
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
|
assertStatus(t, updatedRecorder, http.StatusOK)
|
|
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
|
if updated.Name != "OpenAI Relay" || !updated.BaseURLConfigured || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
|
t.Fatalf("unexpected updated provider: %+v", updated)
|
|
}
|
|
statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
|
assertStatus(t, statusRecorder, http.StatusOK)
|
|
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
|
|
if disabled.Status != domain.AIProviderStatusDisabled {
|
|
t.Fatalf("expected disabled provider, got %+v", disabled)
|
|
}
|
|
|
|
testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
|
assertStatus(t, testRecorder, http.StatusOK)
|
|
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
|
if testResult.Success || testResult.Mode != "provider" {
|
|
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
|
|
}
|
|
|
|
models := getJSONWithAuth[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models", adminSession)
|
|
if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 {
|
|
t.Fatalf("unexpected models response: %+v", models)
|
|
}
|
|
|
|
statusRecorder = requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}, adminSession)
|
|
assertStatus(t, statusRecorder, http.StatusOK)
|
|
testRecorder = requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
|
assertStatus(t, testRecorder, http.StatusOK)
|
|
testResult = decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
|
if !testResult.Success {
|
|
t.Fatalf("expected metadata test success, got %+v", testResult)
|
|
}
|
|
}
|
|
|
|
func TestAIProviderManagementAPIErrors(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
createAIProviderFixture(t, router, adminSession)
|
|
|
|
rawUpdate := validAIProviderUpdateRequest()
|
|
rawUpdate.APIKeyRef = "sk-raw-secret"
|
|
rawFailure := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate, adminSession)
|
|
assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
invalidStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}, adminSession)
|
|
assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
missingUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest(), adminSession)
|
|
assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound)
|
|
missingStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
|
assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound)
|
|
missingTest := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "", adminSession)
|
|
assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound)
|
|
missingModels := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "", adminSession)
|
|
assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound)
|
|
}
|
|
|
|
func TestAIProviderManagementRequiresPlatformAdmin(t *testing.T) {
|
|
router := newTestRouter()
|
|
|
|
for _, request := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body any
|
|
}{
|
|
{name: "list", method: http.MethodGet, path: "/api/v1/ai-providers"},
|
|
{name: "create", method: http.MethodPost, path: "/api/v1/ai-providers", body: validAIProviderRequest()},
|
|
{name: "detail", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai"},
|
|
{name: "update", method: http.MethodPut, path: "/api/v1/ai-providers/ai.openai", body: validAIProviderUpdateRequest()},
|
|
{name: "status", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/status", body: dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}},
|
|
{name: "test", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/test"},
|
|
{name: "models", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai/models"},
|
|
} {
|
|
t.Run(request.name, func(t *testing.T) {
|
|
var recorder *httptest.ResponseRecorder
|
|
if request.body == nil {
|
|
recorder = performRaw(t, router, request.method, request.path, "")
|
|
} else {
|
|
recorder = performJSON(t, router, request.method, request.path, request.body)
|
|
}
|
|
assertErrorResponse(t, recorder, http.StatusUnauthorized, errorCodeUnauthorized)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
createAIProviderFixture(t, router, adminSession)
|
|
registration := validGamePluginManifestRegistrationRequest()
|
|
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
|
|
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-ai-api",
|
|
PluginID: "game.example",
|
|
RunEndpointID: "run-local",
|
|
Name: "AI API Server",
|
|
}, adminSession)
|
|
|
|
allowedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/invocations", dto.AIInvocationRequest{
|
|
RequestID: "ai-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Purpose: "logs.diagnose",
|
|
Prompt: "Summarize recent warnings safely",
|
|
ContextRefs: map[string]string{"server": "server://server-ai-api"},
|
|
}, adminSession)
|
|
assertStatus(t, allowedRecorder, http.StatusOK)
|
|
allowed := decodeBody[dto.AIInvocationResponse](t, allowedRecorder)
|
|
if allowed.Status != "ok" || !allowed.Usage.Mocked || allowed.Recommendation == "" {
|
|
t.Fatalf("expected mocked safe AI response, got %+v", allowed)
|
|
}
|
|
|
|
denied := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{
|
|
RequestID: "ai-denied",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Purpose: "config.suggest",
|
|
Prompt: "Suggest config",
|
|
}, adminSession)
|
|
if denied.Status != "denied" || denied.Error == nil || denied.Error.Code != "permission_denied" {
|
|
t.Fatalf("expected purpose denial, got %+v", denied)
|
|
}
|
|
|
|
unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/invocations", dto.AIInvocationRequest{
|
|
RequestID: "ai-unsafe",
|
|
Purpose: "logs.diagnose",
|
|
Prompt: "use sk-live-raw-secret",
|
|
}, adminSession)
|
|
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{
|
|
ServerInstanceID: instance.ID,
|
|
Prompt: "Turn off pvp and keep this reviewable",
|
|
CurrentConfig: "server.name=AI API Server\n",
|
|
}, adminSession)
|
|
assertStatus(t, configRecorder, http.StatusOK)
|
|
config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder)
|
|
if config.SuggestedConfig == "" || !strings.Contains(config.SuggestedConfig, "ai.recommendation=review-required") {
|
|
t.Fatalf("expected reviewable config suggestion, got %+v", config)
|
|
}
|
|
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession)
|
|
if jobs.Count != 0 {
|
|
t.Fatalf("AI suggestion must not dispatch config writes, got %+v", jobs)
|
|
}
|
|
|
|
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-ai-api",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionAIInvoke),
|
|
AIPurpose: "logs.diagnose",
|
|
Payload: map[string]string{"prompt": "Summarize the logs"},
|
|
}, adminSession)
|
|
if bridge.Status != "ok" || bridge.Result["recommendation"] == "" || bridge.Result["mocked"] != "true" {
|
|
t.Fatalf("expected bridge AI response, got %+v", bridge)
|
|
}
|
|
|
|
for _, body := range []string{allowedRecorder.Body.String(), configRecorder.Body.String(), mustJSON(t, bridge), mustJSON(t, denied)} {
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("AI response exposed forbidden fragment %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGamePluginManifestRegistryAPI(t *testing.T) {
|
|
router := newTestRouter()
|
|
registration := validGamePluginManifestRegistrationRequest()
|
|
|
|
created := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
|
if created.ID != "game.example" || created.ServerType != "example" || created.ServerDisplayName != "Example Server" {
|
|
t.Fatalf("unexpected plugin registry response: %+v", created)
|
|
}
|
|
if created.ManifestRef != "artifact://manifests/game.example/0.1.0" || created.CreateFormSchemaRef != "schemas/create-form.schema.json" {
|
|
t.Fatalf("expected manifest and schema refs, got %+v", created)
|
|
}
|
|
if len(created.DeclaredPermissions) != 6 || !created.Permissions.AI || !created.Permissions.Artifacts || !created.Permissions.Jobs {
|
|
t.Fatalf("expected declared and aggregate permissions, got %+v", created)
|
|
}
|
|
if len(created.Pages) != 1 || created.Pages[0].Permissions[0] != "server.logs.read" {
|
|
t.Fatalf("expected page metadata, got %+v", created.Pages)
|
|
}
|
|
if len(created.AIPurposes) != 1 || created.AIPurposes[0] != "logs.diagnose" {
|
|
t.Fatalf("expected AI purposes, got %+v", created.AIPurposes)
|
|
}
|
|
if len(created.BridgeActions) != 4 || created.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) {
|
|
t.Fatalf("expected bridge actions, got %+v", created.BridgeActions)
|
|
}
|
|
|
|
listed := getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=example&status=installed")
|
|
assertListCount(t, listed.Count, 1)
|
|
detail := getJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/game.example")
|
|
if detail.ID != created.ID || len(detail.RequiredRunCapabilities) != len(created.RequiredRunCapabilities) {
|
|
t.Fatalf("unexpected plugin detail: %+v", detail)
|
|
}
|
|
|
|
registration.Manifest.Version = "0.1.1"
|
|
registration.Manifest.Description = "Development plugin refreshed"
|
|
registration.ManifestRef = "artifact://manifests/game.example/0.1.1"
|
|
refreshed := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
|
if refreshed.ID != created.ID || refreshed.Version != "0.1.1" || refreshed.ManifestRef != "artifact://manifests/game.example/0.1.1" {
|
|
t.Fatalf("expected manifest registration to refresh existing plugin, got %+v", refreshed)
|
|
}
|
|
listed = getJSON[dto.GamePluginListResponse](t, router, "/api/v1/game-plugins?serverType=example&status=installed")
|
|
assertListCount(t, listed.Count, 1)
|
|
}
|
|
|
|
func TestPluginMarketplaceAPIListsDetailsAndChangesStateSafely(t *testing.T) {
|
|
router := newTestRouter()
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
|
|
|
|
listed := getJSON[dto.MarketplacePluginListResponse](t, router, "/api/v1/plugin-marketplace/plugins?serverType=example&status=installed&capability=logs.read&keyword=development")
|
|
if listed.Count != 1 || listed.Items[0].ID != "game.example" || listed.Items[0].Source != "platform-registry" {
|
|
t.Fatalf("unexpected marketplace list: %+v", listed)
|
|
}
|
|
if len(listed.Items[0].Capabilities) == 0 || listed.Items[0].Capabilities[0] != "process.install" || len(listed.Items[0].Pages) != 1 {
|
|
t.Fatalf("expected manifest-backed marketplace projection, got %+v", listed.Items[0])
|
|
}
|
|
|
|
detailRecorder := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins/game.example", "")
|
|
assertStatus(t, detailRecorder, http.StatusOK)
|
|
detail := decodeBody[dto.MarketplacePluginResponse](t, detailRecorder)
|
|
if detail.ID != "game.example" || detail.ManifestRef != "artifact://manifests/game.example/0.1.0" || detail.AIPurposes[0] != "logs.diagnose" {
|
|
t.Fatalf("unexpected marketplace detail: %+v", detail)
|
|
}
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} {
|
|
if strings.Contains(detailRecorder.Body.String(), forbidden) {
|
|
t.Fatalf("marketplace detail exposed forbidden fragment %q: %s", forbidden, detailRecorder.Body.String())
|
|
}
|
|
}
|
|
|
|
disabled := postOKJSON[dto.MarketplacePluginResponse](t, router, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable})
|
|
if disabled.Status != domain.GamePluginStatusDisabled {
|
|
t.Fatalf("expected disabled marketplace plugin, got %+v", disabled)
|
|
}
|
|
installed := postOKJSON[dto.MarketplacePluginResponse](t, router, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionInstall})
|
|
if installed.Status != domain.GamePluginStatusInstalled {
|
|
t.Fatalf("expected installed marketplace plugin, got %+v", installed)
|
|
}
|
|
|
|
empty := getJSON[dto.MarketplacePluginListResponse](t, router, "/api/v1/plugin-marketplace/plugins?keyword=missing")
|
|
if empty.Count != 0 {
|
|
t.Fatalf("expected empty marketplace keyword result, got %+v", empty)
|
|
}
|
|
missing := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins/missing", "")
|
|
assertErrorResponse(t, missing, http.StatusNotFound, errorCodeNotFound)
|
|
unsupported := performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/game.example/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateAction("download")})
|
|
assertErrorResponse(t, unsupported, http.StatusBadRequest, errorCodeValidation)
|
|
unsafeFilter := performRaw(t, router, http.MethodGet, "/api/v1/plugin-marketplace/plugins?keyword=sk-raw-secret", "")
|
|
assertErrorResponse(t, unsafeFilter, http.StatusBadRequest, errorCodeValidation)
|
|
}
|
|
|
|
func TestPluginBridgeAuthorizeAPI(t *testing.T) {
|
|
router := newTestRouter()
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
|
|
|
|
allowed := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: string(domain.PluginBridgeActionLogsQuery),
|
|
})
|
|
if !allowed.Allowed || allowed.RequiredPermissions[0] != "server.logs.read" {
|
|
t.Fatalf("expected allowed logs bridge action, got %+v", allowed)
|
|
}
|
|
|
|
denied := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: string(domain.PluginBridgeActionFilesRequest),
|
|
})
|
|
if denied.Allowed || denied.Reason == "" {
|
|
t.Fatalf("expected denied files bridge action, got %+v", denied)
|
|
}
|
|
|
|
aiAllowed := postOKJSON[dto.PluginBridgeAuthorizeResponse](t, router, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: string(domain.PluginBridgeActionAIInvoke),
|
|
AIPurpose: "logs.diagnose",
|
|
})
|
|
if !aiAllowed.Allowed {
|
|
t.Fatalf("expected allowed AI bridge action, got %+v", aiAllowed)
|
|
}
|
|
|
|
unsupported := performJSON(t, router, http.MethodPost, "/api/v1/plugin-bridge/authorize", dto.PluginBridgeAuthorizeRequest{
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: "direct.run.socket",
|
|
})
|
|
assertErrorResponse(t, unsupported, http.StatusBadRequest, errorCodeValidation)
|
|
}
|
|
|
|
func TestPluginBridgeExecuteAPI(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
|
ID: "user-bridge-owner",
|
|
DisplayName: "Bridge Owner",
|
|
Email: "bridge-owner@example.test",
|
|
Roles: []string{"server-owner"},
|
|
Password: "secret-password",
|
|
}, adminSession)
|
|
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID
|
|
createAIProviderFixture(t, router, adminSession)
|
|
|
|
registration := validGamePluginManifestRegistrationRequest()
|
|
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch))
|
|
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.lifecycle", "server.logs.read", "server.files.read", "ai.invoke"}
|
|
registration.Manifest.Pages[0].BridgeActions = []string{
|
|
string(domain.PluginBridgeActionServerInstancesRead),
|
|
string(domain.PluginBridgeActionJobsDispatch),
|
|
string(domain.PluginBridgeActionLogsQuery),
|
|
string(domain.PluginBridgeActionFilesRequest),
|
|
string(domain.PluginBridgeActionAIInvoke),
|
|
}
|
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-bridge-api",
|
|
PluginID: "game.example",
|
|
RunEndpointID: "run-local",
|
|
Name: "Bridge API Server",
|
|
State: domain.ServerInstanceStateRunning,
|
|
}, ownerSession)
|
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
|
lifecycleInstance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
|
ID: "server-bridge-lifecycle-api",
|
|
PluginID: "game.example",
|
|
RunEndpointID: "run-local",
|
|
Name: "Bridge Lifecycle API Server",
|
|
State: domain.ServerInstanceStateReady,
|
|
}, ownerSession)
|
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+lifecycleInstance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
|
stream := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
|
ID: "log-bridge-api",
|
|
ServerInstanceID: instance.ID,
|
|
Source: domain.LogStreamSourceProcess,
|
|
StreamKey: "stdout",
|
|
StorageBackend: domain.LogStorageBackendLocalSegments,
|
|
RetentionPolicy: "default",
|
|
})
|
|
|
|
serverRead := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-read-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionServerInstancesRead),
|
|
}, ownerSession)
|
|
if serverRead.Status != "ok" || serverRead.Result["serverInstanceId"] != instance.ID || serverRead.Result["state"] != string(domain.ServerInstanceStateRunning) {
|
|
t.Fatalf("expected safe server context response, got %+v", serverRead)
|
|
}
|
|
|
|
logs := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-logs-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionLogsQuery),
|
|
Payload: map[string]string{"logStreamId": stream.ID, "limit": "10"},
|
|
}, ownerSession)
|
|
if logs.Status != "ok" || logs.Result["logStreamId"] != stream.ID || logs.Result["entryCount"] != "0" {
|
|
t.Fatalf("expected safe log query response, got %+v", logs)
|
|
}
|
|
|
|
lifecycle := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-lifecycle-start-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: lifecycleInstance.ID,
|
|
Action: string(domain.PluginBridgeActionJobsDispatch),
|
|
Payload: map[string]string{
|
|
"lifecycleAction": "start",
|
|
"capability": domain.LifecycleCapabilityStart,
|
|
"expectedConfigVersion": strconv.Itoa(lifecycleInstance.ConfigVersion),
|
|
"idempotencyKey": "idem-bridge-lifecycle-start",
|
|
},
|
|
}, ownerSession)
|
|
if lifecycle.Status != "queued" || lifecycle.Result["capability"] != domain.LifecycleCapabilityStart || lifecycle.Result["lifecycleAction"] != string(domain.ServerLifecycleActionStart) || lifecycle.Result["serverInstanceId"] != lifecycleInstance.ID {
|
|
t.Fatalf("expected platform-mediated lifecycle dispatch response, got %+v", lifecycle)
|
|
}
|
|
|
|
lifecycleMismatch := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-lifecycle-mismatch-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: lifecycleInstance.ID,
|
|
Action: string(domain.PluginBridgeActionJobsDispatch),
|
|
Payload: map[string]string{
|
|
"lifecycleAction": "start",
|
|
"capability": domain.LifecycleCapabilityStop,
|
|
"expectedConfigVersion": strconv.Itoa(lifecycleInstance.ConfigVersion),
|
|
"idempotencyKey": "idem-bridge-lifecycle-mismatch",
|
|
},
|
|
}, ownerSession)
|
|
if lifecycleMismatch.Status != "denied" || lifecycleMismatch.Error == nil || lifecycleMismatch.Error.Code != "capability_denied" {
|
|
t.Fatalf("expected mismatched lifecycle capability denial, got %+v", lifecycleMismatch)
|
|
}
|
|
|
|
fileDispatch := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-file-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionFilesRequest),
|
|
Payload: map[string]string{"operation": "read", "key": "logs/latest.log", "idempotencyKey": "idem-bridge-file"},
|
|
}, ownerSession)
|
|
if fileDispatch.Status != "queued" || fileDispatch.Result["capability"] != domain.JobCapabilityFilesRead || fileDispatch.Result["targetKey"] != "logs/latest.log" {
|
|
t.Fatalf("expected safe file dispatch reference, got %+v", fileDispatch)
|
|
}
|
|
|
|
aiResponse := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-ai-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
Action: string(domain.PluginBridgeActionAIInvoke),
|
|
AIPurpose: "logs.diagnose",
|
|
}, ownerSession)
|
|
if aiResponse.Status != "ok" || aiResponse.Result["recommendation"] == "" || aiResponse.Result["mocked"] != "true" {
|
|
t.Fatalf("expected mediated AI safe response, got %+v", aiResponse)
|
|
}
|
|
|
|
denied := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-denied-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionArtifactsOpen),
|
|
}, ownerSession)
|
|
if denied.Status != "denied" || denied.Error == nil || denied.Error.Code != "permission_denied" {
|
|
t.Fatalf("expected permission denied safe envelope, got %+v", denied)
|
|
}
|
|
|
|
unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
|
RequestID: "bridge-unsafe-1",
|
|
PluginID: "game.example",
|
|
RouteKey: "logs",
|
|
ServerInstanceID: instance.ID,
|
|
Action: string(domain.PluginBridgeActionFilesRequest),
|
|
Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"},
|
|
}, ownerSession)
|
|
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied)} {
|
|
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGamePluginManifestRegistryAPIRejectsUnsafeManifest(t *testing.T) {
|
|
router := newTestRouter()
|
|
registration := validGamePluginManifestRegistrationRequest()
|
|
registration.Manifest.Description = "requires direct run socket and raw AI key"
|
|
|
|
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
|
|
assertStatus(t, response, http.StatusBadRequest)
|
|
errorBody := decodeBody[dto.ErrorResponse](t, response)
|
|
if errorBody.Code != errorCodeValidation {
|
|
t.Fatalf("expected validation error, got %+v", errorBody)
|
|
}
|
|
joinedDetails := strings.Join(errorBody.Details, ",")
|
|
if !strings.Contains(joinedDetails, "direct run access") || !strings.Contains(joinedDetails, "raw credential") {
|
|
t.Fatalf("expected unsafe manifest details, got %+v", errorBody)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
registration := validGamePluginManifestRegistrationRequest()
|
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "remote.run.rcon.command")
|
|
registration.Manifest.RuntimeProfiles = dto.GamePluginRuntimeProfilesBody{
|
|
Discovery: []dto.RuntimeDiscoveryProbeBody{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true}},
|
|
LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}, TransportKeys: []string{"rcon"}}},
|
|
TransportProfiles: []dto.RuntimeTransportProfileBody{{Key: "rcon", Kind: "rcon", TargetKey: "rcon.password", Capabilities: []string{"remote.run.rcon.command"}}},
|
|
}
|
|
registered := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
|
if len(registered.RuntimeProfiles.LifecycleProfiles) != 1 || registered.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
|
|
t.Fatalf("runtime profiles were not projected: %+v", registered.RuntimeProfiles)
|
|
}
|
|
endpoint := validRunEndpointRequest()
|
|
endpoint.Capabilities = append(endpoint.Capabilities, "remote.run.rcon.command")
|
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
|
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "runtime-binding-api", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Binding API", State: domain.ServerInstanceStateReady}, adminSession)
|
|
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "runtime-binding-other", DisplayName: "Runtime Binding Other", Email: "runtime-binding-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession)
|
|
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "runtime-binding-other@example.test", Password: "secret-password"}).SessionID
|
|
assertErrorResponse(t, performRequest(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", nil), http.StatusUnauthorized, errorCodeUnauthorized)
|
|
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local"}, otherSession), http.StatusForbidden, errorCodeForbidden)
|
|
|
|
unconfigured := getJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", adminSession)
|
|
if unconfigured.Configured || unconfigured.Reason != "runtime profile is not configured" {
|
|
t.Fatalf("unexpected unconfigured projection: %+v", unconfigured)
|
|
}
|
|
missingStart := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/start", dto.ServerLifecycleCommandRequest{ExpectedConfigVersion: server.ConfigVersion, IdempotencyKey: "api-start-missing-binding"}, adminSession)
|
|
assertErrorResponse(t, missingStart, http.StatusBadRequest, errorCodeValidation)
|
|
|
|
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", dto.ServerLifecycleCreateRequest{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, errorCodeValidation)
|
|
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{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, errorCodeValidation)
|
|
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 TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T) {
|
|
router := newTestRouter()
|
|
adminSession := createAdminSession(t, router)
|
|
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
|
createAIProviderFixture(t, router, adminSession)
|
|
|
|
capacity := getJSONWithAuth[dto.ProductionCapacitySummaryResponse](t, router, "/api/v1/production/capacity", adminSession)
|
|
if len(capacity.Endpoints) == 0 {
|
|
t.Fatalf("expected persisted capacity endpoints, got %+v", capacity)
|
|
}
|
|
decision := postOKJSONWithAuth[dto.CapacityAdmissionDecisionResponse](t, router, "/api/v1/production/capacity/admission", dto.CapacityAdmissionRequest{ServerInstanceID: serverID, Capability: domain.JobCapabilityRemoteRunRCONCommand, IdempotencyKey: "api-capacity-check"}, adminSession)
|
|
if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" {
|
|
t.Fatalf("expected unavailable capability admission to create durable evidence, got %+v", decision)
|
|
}
|
|
alerts := getJSONWithAuth[dto.AlertListResponse](t, router, "/api/v1/alerts?state=active", adminSession)
|
|
if alerts.Count == 0 {
|
|
t.Fatalf("expected durable alert list, got %+v", alerts)
|
|
}
|
|
acknowledged := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/acknowledge", dto.AlertAcknowledgeRequest{Note: "operator review"}, adminSession)
|
|
if acknowledged.State != string(domain.AlertStateAcknowledged) {
|
|
t.Fatalf("expected acknowledged state, got %+v", acknowledged)
|
|
}
|
|
resolved := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/resolve", dto.AlertResolveRequest{Note: "review complete"}, adminSession)
|
|
if resolved.State != string(domain.AlertStateResolved) {
|
|
t.Fatalf("expected resolved state, got %+v", resolved)
|
|
}
|
|
|
|
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 %+v %+v", capacity, decision, alerts, resolved, 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("production governance 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", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
|
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,
|
|
},
|
|
}
|
|
}
|