feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
+67 -39
View File
@@ -35,12 +35,12 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
users := getJSONWithAuth[dto.UserListResponse](t, router, "/api/v1/users?status=active", adminSession)
assertListCount(t, users.Count, 2)
providerResponse := postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
providerResponse := createAIProviderFixture(t, router, adminSession)
if providerResponse.APIKeyRef != "secret://providers/openai" {
t.Fatalf("expected AI provider key reference, got %+v", providerResponse)
}
getJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai")
providers := getJSON[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active")
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())
@@ -305,28 +305,18 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
}
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
if runDistribution.ArtifactID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" {
if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) {
t.Fatalf("unexpected run distribution: %+v", runDistribution)
}
runDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
if runDownload.ArtifactID != runDistribution.ArtifactID || runDownload.DownloadURL == "" {
t.Fatalf("unexpected run download: %+v", runDownload)
}
updateRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/update", dto.RunUpdateRequest{ArtifactID: runDistribution.ArtifactID, Checksum: runDistribution.Checksum, IdempotencyKey: "api-run-update"}, adminSession)
assertStatus(t, updateRecorder, http.StatusAccepted)
update := decodeBody[dto.RunUpdateJobResponse](t, updateRecorder)
if update.JobID == "" || update.ArtifactID != runDistribution.ArtifactID || update.Status != string(domain.DistributionJobStatusQueued) {
t.Fatalf("unexpected run update job: %+v", update)
}
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)
}
clientDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
if clientDownload.ArtifactID != clientDistribution.ArtifactID {
t.Fatalf("unexpected client download: %+v", clientDownload)
}
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)
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)
@@ -363,7 +353,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, runDownload), mustJSON(t, clientDownload), 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), 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)
@@ -376,7 +366,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
for _, audit := range audits.Items {
auditActions[audit.Action] = true
}
for _, action := range []string{"run.generate", "run.download", "run.update", "client-manager.build", "client-manager.download", "dependency.install", "logs.backfill", "runtime-key.reset"} {
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)
}
@@ -412,9 +402,9 @@ func TestCoreAPIErrorResponses(t *testing.T) {
rawKey := validAIProviderRequest()
rawKey.ID = "ai.raw"
rawKey.APIKeyRef = "sk-raw-secret"
providerFailure := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey)
providerFailure := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey, adminSession)
assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation)
missingProvider := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "")
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)
@@ -791,7 +781,8 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
router := newTestRouter()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest())
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
@@ -811,38 +802,39 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
func TestAIProviderManagementAPI(t *testing.T) {
router := newTestRouter()
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
adminSession := createAdminSession(t, router)
createAIProviderFixture(t, router, adminSession)
update := validAIProviderUpdateRequest()
updatedRecorder := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update)
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.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive {
t.Fatalf("unexpected updated provider: %+v", updated)
}
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled})
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 := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "")
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 != "metadata" {
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
}
models := getJSON[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models")
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 = performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive})
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 = performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "")
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 {
@@ -852,30 +844,60 @@ func TestAIProviderManagementAPI(t *testing.T) {
func TestAIProviderManagementAPIErrors(t *testing.T) {
router := newTestRouter()
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
adminSession := createAdminSession(t, router)
createAIProviderFixture(t, router, adminSession)
rawUpdate := validAIProviderUpdateRequest()
rawUpdate.APIKeyRef = "sk-raw-secret"
rawFailure := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate)
rawFailure := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate, adminSession)
assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation)
invalidStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError})
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 := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest())
missingUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest(), adminSession)
assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound)
missingStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled})
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 := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "")
missingTest := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "", adminSession)
assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound)
missingModels := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "")
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)
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
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)}
@@ -1090,7 +1112,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
Password: "secret-password",
}, adminSession)
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
createAIProviderFixture(t, router, adminSession)
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch))
@@ -1404,6 +1426,11 @@ func createAdminSession(t *testing.T, router http.Handler) string {
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
@@ -1494,6 +1521,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
endpoint := validRunEndpointRequest()
endpoint.ID = "run-runtime"
endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityDistributionBuild,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,