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:
parent
3bc15b1039
commit
bba43c369a
5 changed files with 887 additions and 9 deletions
409
openapi/setting/cloud.go
Normal file
409
openapi/setting/cloud.go
Normal 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
|
||||
}
|
||||
28
openapi/setting/cloud_presets.yml
Normal file
28
openapi/setting/cloud_presets.yml
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
330
openapi/tests/setting/cloud_test.go
Normal file
330
openapi/tests/setting/cloud_test.go
Normal 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"])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue