feat(setting): implement cloud settings endpoints and owner verification

- Added new endpoints for managing cloud settings under /setting/cloud, including GET, PUT, and POST methods.
- Introduced owner verification logic to ensure only team owners can modify settings, utilizing caching for efficiency.
- Enhanced data structures for cloud settings responses, including CloudRegion and CloudPageData types.
- Refactored existing functions to integrate new owner verification and error handling mechanisms.
This commit is contained in:
Max 2026-04-28 21:20:43 +08:00
parent 3bc15b1039
commit bba43c369a
5 changed files with 887 additions and 9 deletions

409
openapi/setting/cloud.go Normal file
View file

@ -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
}

View file

@ -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"

View file

@ -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

View file

@ -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"`
}

View file

@ -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"])
}