diff --git a/openapi/setting/cloud.go b/openapi/setting/cloud.go new file mode 100644 index 00000000..4194a81e --- /dev/null +++ b/openapi/setting/cloud.go @@ -0,0 +1,409 @@ +package setting + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/openapi/oauth/authorized" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/setting" + "gopkg.in/yaml.v3" +) + +//go:embed cloud_presets.yml +var cloudPresetsYML []byte + +const ( + cloudNS = "cloud" + cloudMaskChars = 4 + cloudEncPrefix = "enc:" +) + +// cloudPresets holds the parsed region list from the embedded YAML. +type cloudPresets struct { + Regions []CloudRegion `yaml:"regions"` +} + +var cloudRegions []CloudRegion + +func init() { + var p cloudPresets + if err := yaml.Unmarshal(cloudPresetsYML, &p); err == nil { + cloudRegions = p.Regions + } +} + +func cloudDefaultRegion() CloudRegion { + for _, r := range cloudRegions { + if r.Default { + return r + } + } + if len(cloudRegions) > 0 { + return cloudRegions[0] + } + return CloudRegion{Key: "us", APIURL: "https://api-us.yao.run"} +} + +func cloudFindRegion(key string) *CloudRegion { + for i := range cloudRegions { + if cloudRegions[i].Key == key { + return &cloudRegions[i] + } + } + return nil +} + +func cloudScope(info *oauthTypes.AuthorizedInfo) setting.ScopeID { + if info.TeamID != "" { + return setting.ScopeID{Scope: setting.ScopeTeam, TeamID: info.TeamID} + } + return setting.ScopeID{Scope: setting.ScopeUser, UserID: info.UserID} +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// handleCloudGet returns the cloud configuration for the current team. +// GET /setting/cloud +func handleCloudGet(c *gin.Context) { + info := authorized.GetInfo(c) + def := cloudDefaultRegion() + + var saved map[string]interface{} + if setting.Global != nil { + saved, _ = setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS) + } + + data := CloudPageData{ + Regions: cloudRegions, + Region: def.Key, + APIURL: def.APIURL, + APIKey: "", + Status: "unconfigured", + } + + if saved != nil { + if v, ok := saved["region"].(string); ok && v != "" { + data.Region = v + } + if v, ok := saved["api_url"].(string); ok && v != "" { + data.APIURL = v + } + if v, ok := saved["api_key"].(string); ok && v != "" { + data.APIKey = cloudMaskKey(cloudDecrypt(v)) + } + if v, ok := saved["status"].(string); ok && v != "" { + data.Status = v + } + } + + response.RespondWithSuccess(c, http.StatusOK, data) +} + +// handleCloudUpdate saves the cloud configuration. +// When api_key is provided, validates it by calling the cloud API before saving. +// PUT /setting/cloud +func handleCloudUpdate(c *gin.Context) { + if !guardOwner(c) { + return + } + info := authorized.GetInfo(c) + scope := cloudScope(info) + + var body struct { + Region string `json:"region"` + APIURL string `json:"api_url"` + APIKey string `json:"api_key"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, http.StatusBadRequest, "invalid request body") + return + } + + if body.Region != "" { + if r := cloudFindRegion(body.Region); r == nil { + respondError(c, http.StatusBadRequest, fmt.Sprintf("unknown region: %s", body.Region)) + return + } + } + + if setting.Global == nil { + respondError(c, http.StatusInternalServerError, "setting registry not initialized") + return + } + + existing, _ := setting.Global.Get(scope, cloudNS) + + m := make(map[string]interface{}) + for k, v := range existing { + m[k] = v + } + + if body.Region != "" { + m["region"] = body.Region + } + if body.APIURL != "" { + m["api_url"] = body.APIURL + } + + // Resolve the effective api_url for key validation + apiURL := body.APIURL + if apiURL == "" { + if v, ok := m["api_url"].(string); ok { + apiURL = v + } + } + if apiURL == "" { + if body.Region != "" { + if r := cloudFindRegion(body.Region); r != nil { + apiURL = r.APIURL + } + } + if apiURL == "" { + apiURL = cloudDefaultRegion().APIURL + } + } + + if body.APIKey != "" { + if err := cloudValidateKey(apiURL, body.APIKey); err != nil { + respondError(c, http.StatusBadRequest, fmt.Sprintf("API key validation failed: %s", err.Error())) + return + } + m["api_key"] = cloudEncrypt(body.APIKey) + m["status"] = "connected" + } + + hasKey := false + if v, ok := m["api_key"].(string); ok && v != "" { + hasKey = true + } + if _, ok := m["status"].(string); !ok { + if hasKey { + m["status"] = "disconnected" + } else { + m["status"] = "unconfigured" + } + } + + if _, err := setting.Global.Set(scope, cloudNS, m); err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + + def := cloudDefaultRegion() + result := CloudPageData{ + Regions: cloudRegions, + Region: def.Key, + APIURL: def.APIURL, + APIKey: "", + Status: "unconfigured", + } + if v, ok := m["region"].(string); ok && v != "" { + result.Region = v + } + if v, ok := m["api_url"].(string); ok && v != "" { + result.APIURL = v + } + if v, ok := m["api_key"].(string); ok && v != "" { + result.APIKey = cloudMaskKey(cloudDecrypt(v)) + } + if v, ok := m["status"].(string); ok && v != "" { + result.Status = v + } + + response.RespondWithSuccess(c, http.StatusOK, result) +} + +// cloudValidateKey verifies the API key by calling GET {apiURL}/v1/models. +func cloudValidateKey(apiURL, apiKey string) error { + url := strings.TrimRight(apiURL, "/") + "/v1/models" + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned HTTP %d", resp.StatusCode) + } + return nil +} + +// handleCloudTest tests the cloud connection by calling GET {api_url}/v1/models. +// Caller must provide api_url and api_key in the request body. +// POST /setting/cloud/test +func handleCloudTest(c *gin.Context) { + if !guardOwner(c) { + return + } + + var input struct { + APIURL string `json:"api_url"` + APIKey string `json:"api_key"` + } + if err := c.ShouldBindJSON(&input); err != nil { + respondError(c, http.StatusBadRequest, "invalid request body") + return + } + + if input.APIURL == "" || input.APIKey == "" { + respondError(c, http.StatusBadRequest, "api_url and api_key are required") + return + } + + url := strings.TrimRight(input.APIURL, "/") + "/v1/models" + + start := time.Now() + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + req.Header.Set("Authorization", "Bearer "+input.APIKey) + + resp, err := client.Do(req) + latency := time.Since(start).Milliseconds() + + if err != nil { + response.RespondWithSuccess(c, http.StatusOK, CloudTestResult{ + Success: false, + Message: fmt.Sprintf("Connection failed: %s", err.Error()), + }) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + response.RespondWithSuccess(c, http.StatusOK, CloudTestResult{ + Success: false, + Message: fmt.Sprintf("Server returned HTTP %d", resp.StatusCode), + }) + return + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + + response.RespondWithSuccess(c, http.StatusOK, CloudTestResult{ + Success: true, + Message: "Connection successful", + LatencyMs: latency, + }) +} + +// --------------------------------------------------------------------------- +// Crypto helpers (AES-256-GCM, same scheme as llmprovider) +// --------------------------------------------------------------------------- + +func cloudEncrypt(plaintext string) string { + secret := config.Conf.DB.AESKey + if secret == "" { + return plaintext + } + enc, err := cloudEncryptString(plaintext, secret) + if err != nil { + return plaintext + } + return cloudEncPrefix + enc +} + +func cloudDecrypt(value string) string { + if !strings.HasPrefix(value, cloudEncPrefix) { + return value + } + secret := config.Conf.DB.AESKey + if secret == "" { + return strings.TrimPrefix(value, cloudEncPrefix) + } + dec, err := cloudDecryptString(strings.TrimPrefix(value, cloudEncPrefix), secret) + if err != nil { + return value + } + return dec +} + +func cloudMaskKey(key string) string { + if key == "" { + return "" + } + if len(key) <= cloudMaskChars { + return strings.Repeat("*", len(key)) + } + prefix := key[:3] + suffix := key[len(key)-cloudMaskChars:] + return prefix + "..." + suffix +} + +func cloudDeriveKey(secret string) []byte { + h := sha256.Sum256([]byte(secret)) + return h[:] +} + +func cloudEncryptString(plaintext, secret string) (string, error) { + key := cloudDeriveKey(secret) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func cloudDecryptString(encoded, secret string) (string, error) { + key := cloudDeriveKey(secret) + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} diff --git a/openapi/setting/cloud_presets.yml b/openapi/setting/cloud_presets.yml new file mode 100644 index 00000000..5b7f04c5 --- /dev/null +++ b/openapi/setting/cloud_presets.yml @@ -0,0 +1,28 @@ +# Cloud service region presets. +# Embedded at compile time via go:embed in cloud.go. + +regions: + - key: us + label: + zh-CN: "美国" + en-US: "United States" + api_url: "https://api-us.yao.run" + default: true + + - key: cn + label: + zh-CN: "中国" + en-US: "China" + api_url: "https://api.yaoagents.cn" + + - key: ap + label: + zh-CN: "亚太" + en-US: "Asia Pacific" + api_url: "https://api-ap.yao.run" + + - key: eu + label: + zh-CN: "欧洲" + en-US: "Europe" + api_url: "https://api-eu.yao.run" diff --git a/openapi/setting/setting.go b/openapi/setting/setting.go index 130f3ec2..094490f9 100644 --- a/openapi/setting/setting.go +++ b/openapi/setting/setting.go @@ -1,13 +1,27 @@ package setting import ( + "fmt" + "net/http" + "time" + "github.com/gin-gonic/gin" + gouStore "github.com/yaoapp/gou/store" + "github.com/yaoapp/kun/log" + oauth "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/authorized" oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/response" - "github.com/yaoapp/yao/setting" ) +const ownerCachePrefix = "setting:owner:" +const ownerCacheTTL = 5 * time.Minute + +func getCache() gouStore.Store { + c, _ := gouStore.Get("__yao.cache") + return c +} + // Attach registers all /setting/* routes under the given group. // Currently only System Info routes are wired; other groups will be // added incrementally. @@ -17,17 +31,86 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) { sys := group.Group("/system") sys.GET("", handleSystemInfo) sys.POST("/check-update", handleSystemCheckUpdate) + + cloud := group.Group("/cloud") + cloud.GET("", handleCloudGet) + cloud.PUT("", handleCloudUpdate) + cloud.POST("/test", handleCloudTest) } -// resolveOwner extracts the authenticated user/team from the Gin context -// and returns a setting.ScopeID suitable for registry operations. -func resolveOwner(c *gin.Context) setting.ScopeID { - info := authorized.GetInfo(c) - return setting.ScopeID{ - Scope: setting.ScopeUser, - TeamID: info.TeamID, - UserID: info.UserID, +// requireOwner checks that the current user is the team owner. +// Non-team context (TeamID == ""): always allowed — user is managing their own data. +// Team context: checks cache first, then queries the member table is_owner field. +// Use this as a guard for any write operation across all /setting/* groups. +func requireOwner(c *gin.Context, info *oauthTypes.AuthorizedInfo) error { + if info == nil || info.UserID == "" { + return fmt.Errorf("authentication required") } + if info.TeamID == "" { + return nil + } + + cacheKey := ownerCachePrefix + info.TeamID + ":" + info.UserID + + if cache := getCache(); cache != nil { + if val, ok := cache.Get(cacheKey); ok { + if isOwner, ok := val.(bool); ok { + if isOwner { + return nil + } + return fmt.Errorf("access denied: only team owner can modify settings") + } + } + } + + if oauth.OAuth == nil { + return fmt.Errorf("service not initialized") + } + provider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return fmt.Errorf("service not available") + } + + member, err := provider.GetMember(c.Request.Context(), info.TeamID, info.UserID) + if err != nil { + log.Error("[setting] GetMember failed: %v", err) + return fmt.Errorf("access denied") + } + + isOwner := checkIsOwner(member["is_owner"]) + if cache := getCache(); cache != nil { + cache.Set(cacheKey, isOwner, ownerCacheTTL) + } + + if isOwner { + return nil + } + return fmt.Errorf("access denied: only team owner can modify settings") +} + +func checkIsOwner(val interface{}) bool { + switch v := val.(type) { + case bool: + return v + case int: + return v == 1 + case int64: + return v == 1 + case float64: + return v == 1 + } + return false +} + +// guardOwner is a convenience wrapper: calls requireOwner and writes 403 on failure. +// Returns true if the request should continue, false if it was aborted. +func guardOwner(c *gin.Context) bool { + info := authorized.GetInfo(c) + if err := requireOwner(c, info); err != nil { + respondError(c, http.StatusForbidden, err.Error()) + return false + } + return true } // respondError is a thin helper that writes a JSON error via the shared diff --git a/openapi/setting/types.go b/openapi/setting/types.go index f8d3ec68..80f288a7 100644 --- a/openapi/setting/types.go +++ b/openapi/setting/types.go @@ -53,3 +53,31 @@ type CheckUpdateResult struct { LatestVersion string `json:"latest_version,omitempty"` DownloadURL string `json:"download_url,omitempty"` } + +// --------------------------------------------------------------------------- +// Cloud Service +// --------------------------------------------------------------------------- + +// CloudRegion is a static entry loaded from cloud_presets.yml. +type CloudRegion struct { + Key string `json:"key" yaml:"key"` + Label map[string]string `json:"label" yaml:"label"` + APIURL string `json:"api_url" yaml:"api_url"` + Default bool `json:"default,omitempty" yaml:"default"` +} + +// CloudPageData is the response for GET /setting/cloud. +type CloudPageData struct { + Regions []CloudRegion `json:"regions"` + Region string `json:"region"` + APIURL string `json:"api_url"` + APIKey string `json:"api_key"` + Status string `json:"status"` +} + +// CloudTestResult is the response for POST /setting/cloud/test. +type CloudTestResult struct { + Success bool `json:"success"` + Message string `json:"message"` + LatencyMs int64 `json:"latency_ms,omitempty"` +} diff --git a/openapi/tests/setting/cloud_test.go b/openapi/tests/setting/cloud_test.go new file mode 100644 index 00000000..099d98f6 --- /dev/null +++ b/openapi/tests/setting/cloud_test.go @@ -0,0 +1,330 @@ +package setting_test + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/tests/testutils" + "github.com/yaoapp/yao/setting" +) + +func initSettingRegistry(t *testing.T) { + t.Helper() + if setting.Global == nil { + if err := setting.Init(); err != nil { + t.Fatalf("setting.Init: %v", err) + } + } +} + +func obtainToken(t *testing.T, serverURL string) string { + t.Helper() + client := testutils.RegisterTestClient(t, "Cloud Test", []string{"https://localhost/callback"}) + t.Cleanup(func() { testutils.CleanupTestClient(t, client.ClientID) }) + token := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + return token.AccessToken +} + +// obtainRestrictedToken creates a token with specific scope (no system:root). +// Used to test ACL permission denial. +func obtainRestrictedToken(t *testing.T, serverURL, scope string) string { + t.Helper() + client := testutils.RegisterTestClient(t, "Cloud Restricted", []string{"https://localhost/callback"}) + t.Cleanup(func() { testutils.CleanupTestClient(t, client.ClientID) }) + + oauthService := oauth.OAuth + if oauthService == nil { + t.Fatal("Global OAuth service not initialized") + } + + token := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + subject, err := oauthService.Subject(client.ClientID, token.UserID) + if err != nil { + t.Fatalf("Failed to create subject: %v", err) + } + + accessToken, err := oauthService.MakeAccessToken(client.ClientID, scope, subject, 3600) + if err != nil { + t.Fatalf("Failed to create access token: %v", err) + } + return accessToken +} + +// ----------- Functional tests (system:root token) ----------- + +func TestCloudGet(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/cloud", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if !assert.NoError(t, err) || !assert.NotNil(t, resp) { + return + } + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + + assert.Contains(t, body, "regions") + assert.Contains(t, body, "region") + assert.Contains(t, body, "api_url") + assert.Contains(t, body, "api_key") + assert.Contains(t, body, "status") + + regions, ok := body["regions"].([]interface{}) + assert.True(t, ok) + assert.GreaterOrEqual(t, len(regions), 4) + + assert.Equal(t, "unconfigured", body["status"]) + assert.Equal(t, "", body["api_key"]) +} + +func TestCloudGetUnauthenticated(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/cloud", nil) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestCloudUpdate(t *testing.T) { + apiKey := os.Getenv("CLOUD_TEST_API_KEY") + if apiKey == "" { + t.Skip("CLOUD_TEST_API_KEY not set, skipping cloud update test (key validation required)") + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + payload := map[string]interface{}{ + "region": "us", + "api_url": "https://api-us.yao.run", + "api_key": apiKey, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/cloud", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + + assert.Equal(t, "us", body["region"]) + assert.Equal(t, "https://api-us.yao.run", body["api_url"]) + assert.Equal(t, "connected", body["status"]) + + maskedKey, _ := body["api_key"].(string) + assert.True(t, strings.Contains(maskedKey, "..."), "masked key should use prefix...suffix format") + + // GET should also return masked key and connected status + req2, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/cloud", nil) + req2.Header.Set("Authorization", "Bearer "+token) + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + + var body2 map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&body2) + assert.Equal(t, "us", body2["region"]) + assert.Equal(t, "connected", body2["status"]) +} + +func TestCloudUpdateInvalidKey(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + payload := map[string]interface{}{ + "region": "us", + "api_url": "https://api-us.yao.run", + "api_key": "sk-invalid-key-that-should-fail", + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/cloud", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "invalid API key should be rejected") +} + +func TestCloudUpdateInvalidRegion(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + payload := map[string]interface{}{ + "region": "mars", + "api_url": "https://api-mars.yao.run", + "api_key": "sk-test", + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/cloud", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestCloudTest(t *testing.T) { + apiKey := os.Getenv("CLOUD_TEST_API_KEY") + if apiKey == "" { + t.Skip("CLOUD_TEST_API_KEY not set, skipping cloud connection test") + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + // Save config first (key is validated during save) + payload := map[string]interface{}{ + "region": "us", + "api_url": "https://api-us.yao.run", + "api_key": apiKey, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/cloud", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Test connection with explicit api_url and api_key + testPayload := map[string]interface{}{ + "api_url": "https://api-us.yao.run", + "api_key": apiKey, + } + testRaw, _ := json.Marshal(testPayload) + req2, err := http.NewRequest("POST", serverURL+baseURL()+"/setting/cloud/test", bytes.NewReader(testRaw)) + assert.NoError(t, err) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&body) + assert.Equal(t, true, body["success"]) + assert.NotEmpty(t, body["message"]) +} + +// ----------- ACL permission tests ----------- + +func TestCloudACL_ReadOnlyScopeCannotWrite(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + + // Token with read-only scope (no system:root, only setting:cloud:read:all) + readToken := obtainRestrictedToken(t, serverURL, "setting:cloud:read:all") + + // GET should work + req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/cloud", nil) + req.Header.Set("Authorization", "Bearer "+readToken) + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "read-only scope should allow GET") + + // PUT should be denied + payload := map[string]interface{}{ + "region": "cn", + "api_url": "https://api.yaoagents.cn", + "api_key": "sk-test", + } + raw, _ := json.Marshal(payload) + req2, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/cloud", bytes.NewReader(raw)) + req2.Header.Set("Authorization", "Bearer "+readToken) + req2.Header.Set("Content-Type", "application/json") + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusForbidden, resp2.StatusCode, "read-only scope should deny PUT") +} + +func TestCloudACL_NoScopeCannotAccess(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + + // Token with irrelevant scope (no setting scopes at all) + noSettingToken := obtainRestrictedToken(t, serverURL, "kb:collections:read:all") + + req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/cloud", nil) + req.Header.Set("Authorization", "Bearer "+noSettingToken) + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "token without setting scope should be denied") +} + +func TestCloudUpdateRegionOnly(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + payload := map[string]interface{}{ + "region": "cn", + "api_url": "https://api.yaoagents.cn", + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/cloud", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "update without api_key should succeed (no validation needed)") + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + assert.Equal(t, "cn", body["region"]) + assert.Equal(t, "https://api.yaoagents.cn", body["api_url"]) +}