Integrate SCUM real ops workflows

This commit is contained in:
npc0-hue
2026-08-10 21:12:53 +08:00
parent 1063330710
commit a770bc6250
88 changed files with 6375 additions and 2719 deletions
+14 -163
View File
@@ -189,18 +189,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
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)
_ = instance
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession), http.StatusNotFound)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusNotFound)
}
func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
@@ -235,52 +226,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
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)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-config-api/config", "", ownerSession), http.StatusNotFound)
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n"}, ownerSession), http.StatusNotFound)
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n", IdempotencyKey: "idem-config-api"}, otherSession), http.StatusNotFound)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{
ServerInstanceID: "server-config-api",
Operation: domain.FileOperationRead,
@@ -304,42 +252,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
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.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-file-snapshot-api",
@@ -347,57 +260,9 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
RunEndpointID: "run-local",
Name: "File Snapshot API Server",
State: domain.ServerInstanceStateRunning,
}, ownerSession)
}, adminSession)
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)
}
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound)
}
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
@@ -412,7 +277,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
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"} {
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} {
if !availability[key] {
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
}
@@ -464,22 +329,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
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)
}
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession), http.StatusNotFound)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/logs/live", "", adminSession), http.StatusNotFound)
runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession)
if runReset.Generation != 2 || runReset.SecretRef == "" {
@@ -490,7 +341,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
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 _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} {
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(body, forbidden) {
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
@@ -503,7 +354,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
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"} {
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "runtime-key.reset"} {
if !auditActions[action] {
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
}