Integrate SCUM real ops workflows
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user