Refactor test utilities and enhance access token handling

- Introduced ObtainAccessTokenWithRootPermission function to streamline the creation of test users with root permissions, ensuring consistent access token generation for tests.
- Updated various test cases to utilize the new function, improving clarity and reducing redundancy in access token acquisition.
- Enhanced team configuration retrieval to expose public settings while hiding sensitive information, improving security in API responses.
This commit is contained in:
Max 2025-10-25 13:26:51 +08:00
parent f31b3f5882
commit ddf8fd7a32
16 changed files with 766 additions and 158 deletions

View file

@ -10,7 +10,7 @@ NOW := $(shell date +"%FT%T%z")
OS := $(shell uname)
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|tests|openai|aigc|neo|twilio|share*')
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*' | awk '!/\/tests\// || /openapi\/tests/')
TESTTAGS ?= ""
# TESTWIDGETS := $(shell $(GO) list ./widgets/...)

1
openapi/agent/agnet.go Normal file
View file

@ -0,0 +1 @@
package agent

1
openapi/llm/llm.go Normal file
View file

@ -0,0 +1 @@
package llm

1
openapi/mcp/mcp.go Normal file
View file

@ -0,0 +1 @@
package mcp

View file

@ -46,11 +46,11 @@ func TestCreateCollection(t *testing.T) {
"created_by": "test_user",
},
"config": map[string]interface{}{
"embedding_provider": "__yao.openai", // Required: embedding provider ID
"embedding_option": "text-embedding-3-small", // Required: embedding option value
"locale": "en", // Optional: locale for provider reading
"index_type": "hnsw", // Required: valid index type
"distance": "cosine", // Required: distance metric
"embedding_provider_id": "__yao.openai", // Required: embedding provider ID
"embedding_option_id": "text-embedding-3-small", // Required: embedding option value
"locale": "en", // Optional: locale for provider reading
"index_type": "hnsw", // Required: valid index type
"distance": "cosine", // Required: distance metric
},
}
@ -171,11 +171,11 @@ func TestRemoveCollection(t *testing.T) {
"category": "test_remove",
},
"config": map[string]interface{}{
"embedding_provider": "__yao.openai", // Required: embedding provider ID
"embedding_option": "text-embedding-3-small", // Required: embedding option value
"locale": "en", // Optional: locale for provider reading
"index_type": "hnsw", // Required: valid index type
"distance": "cosine", // Required: distance metric
"embedding_provider_id": "__yao.openai", // Required: embedding provider ID
"embedding_option_id": "text-embedding-3-small", // Required: embedding option value
"locale": "en", // Optional: locale for provider reading
"index_type": "hnsw", // Required: valid index type
"distance": "cosine", // Required: distance metric
},
}
@ -349,11 +349,17 @@ func TestGetCollections(t *testing.T) {
// Expect successful response
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should successfully retrieve collections when KB is initialized")
var collections []interface{}
err = json.NewDecoder(resp.Body).Decode(&collections)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
t.Logf("Successfully retrieved %d collections", len(collections))
// Response should have pagination structure
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d collections", len(data))
} else {
t.Logf("Successfully retrieved collections response (data field type: %T)", response["data"])
}
})
t.Run("GetCollectionsWithFilter", func(t *testing.T) {
@ -380,11 +386,17 @@ func TestGetCollections(t *testing.T) {
// Expect successful response
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should successfully retrieve collections when KB is initialized")
var collections []interface{}
err = json.NewDecoder(resp.Body).Decode(&collections)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
t.Logf("Successfully retrieved %d filtered collections", len(collections))
// Response should have pagination structure
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d filtered collections", len(data))
} else {
t.Logf("Successfully retrieved filtered collections response (data field type: %T)", response["data"])
}
})
t.Run("GetCollectionsWithMultipleFilters", func(t *testing.T) {
@ -411,11 +423,17 @@ func TestGetCollections(t *testing.T) {
// Expect successful response
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should successfully retrieve collections when KB is initialized")
var collections []interface{}
err = json.NewDecoder(resp.Body).Decode(&collections)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
t.Logf("Successfully retrieved %d collections with multiple filters", len(collections))
// Response should have pagination structure
data, hasData := response["data"].([]interface{})
if hasData {
t.Logf("Successfully retrieved %d collections with multiple filters", len(data))
} else {
t.Logf("Successfully retrieved collections with multiple filters response (data field type: %T)", response["data"])
}
})
}
@ -504,11 +522,11 @@ func TestCollectionIntegration(t *testing.T) {
"purpose": "full_lifecycle_test",
},
"config": map[string]interface{}{
"embedding_provider": "__yao.openai", // Required: embedding provider ID
"embedding_option": "text-embedding-3-small", // Required: embedding option value
"locale": "en", // Optional: locale for provider reading
"index_type": "hnsw", // Required: valid index type
"distance": "cosine", // Required: distance metric
"embedding_provider_id": "__yao.openai", // Required: embedding provider ID
"embedding_option_id": "text-embedding-3-small", // Required: embedding option value
"locale": "en", // Optional: locale for provider reading
"index_type": "hnsw", // Required: valid index type
"distance": "cosine", // Required: distance metric
},
}

View file

@ -8,6 +8,7 @@ import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"testing"
"time"
@ -624,6 +625,130 @@ type TokenInfo struct {
UserID string
}
// ObtainAccessTokenWithRootPermission creates a complete test user with root permissions and obtains an access token.
// This simulates a real user login flow:
// 1. Creates a role with root permissions if it doesn't exist
// 2. Creates a real user in the database with this role
// 3. Issues a token with system:root scope for full permissions
func ObtainAccessTokenWithRootPermission(t *testing.T, serverURL, clientID, clientSecret, redirectURI, scope string) *TokenInfo {
testMutex.RLock()
server := openapi.Server
testMutex.RUnlock()
if server == nil || server.OAuth == nil {
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
}
oauthService := oauth.OAuth
if oauthService == nil {
t.Fatal("Global OAuth service not initialized")
}
userProvider, err := oauthService.GetUserProvider()
if err != nil || userProvider == nil {
t.Fatal("UserProvider not available")
}
ctx := context.Background()
// Step 1: Ensure system:root role exists in database (delete and recreate if exists)
// Note: This is the default client role used by ACL
roleID := "system:root"
t.Logf("Setting up role %s in database", roleID)
// Check if role already exists and delete it for clean state
_, err = userProvider.GetRole(ctx, roleID)
if err == nil {
// Role exists, delete it first
t.Logf("Role %s already exists, deleting for clean state", roleID)
err = userProvider.DeleteRole(ctx, roleID)
if err != nil {
t.Logf("Warning: Failed to delete existing role: %v", err)
}
}
// Clear role cache to ensure fresh data
if oauthService.GetCache() != nil {
cache := oauthService.GetCache()
// Clear all role-related cache with the correct prefix
cache.Del("acl:role:scopes:" + roleID)
cache.Del("acl:role:scopes:restricted:" + roleID)
t.Logf("Cleared ACL role cache for %s", roleID)
}
// Create the role with system:root permissions
// Pass permissions as []string directly
roleData := map[string]interface{}{
"role_id": roleID,
"name": "System Root",
"description": "System root role with full system access",
"permissions": []string{"system:root"},
"is_active": true,
}
_, err = userProvider.CreateRole(ctx, roleData)
if err != nil {
t.Fatalf("Failed to create test role: %v", err)
}
t.Logf("Successfully created role %s with system:root permissions", roleID)
// Step 2: Create a real test user in the database
testUserID := fmt.Sprintf("test_user_root_%d", time.Now().UnixNano())
userData := map[string]interface{}{
"user_id": testUserID,
"status": "active",
"role_id": roleID, // Assign test_root role (which has system:root scope)
}
_, err = userProvider.CreateUser(ctx, userData)
if err != nil {
t.Fatalf("Failed to create test user in database: %v", err)
}
t.Logf("Created test user %s with role %s in database", testUserID, roleID)
// Step 3: Create subject (fingerprint) for OAuth
subject, err := oauthService.Subject(clientID, testUserID)
if err != nil {
t.Fatalf("Failed to create user subject: %v", err)
}
t.Logf("Created subject mapping: clientID=%s, userID=%s, subject=%s", clientID, testUserID, subject)
// Step 4: Create access token with system:root scope for full permissions
fullScope := scope
if scope != "" && !strings.Contains(scope, "system:root") {
fullScope = scope + " system:root"
} else if scope == "" {
fullScope = "system:root"
}
extraClaims := map[string]interface{}{
"user_id": testUserID,
}
accessToken, err := oauthService.MakeAccessToken(clientID, fullScope, subject, 3600, extraClaims)
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
refreshToken, err := oauthService.MakeRefreshToken(clientID, fullScope, subject, 7200, extraClaims)
if err != nil {
t.Fatalf("Failed to create refresh token: %v", err)
}
tokenInfo := &TokenInfo{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: 3600,
Scope: fullScope,
ClientID: clientID,
UserID: testUserID,
}
t.Logf("Issued token for user %s with scope: %s", testUserID, fullScope)
return tokenInfo
}
// ObtainAccessToken obtains an access token for testing OAuth endpoints that require authentication.
func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirectURI, scope string) *TokenInfo {
testMutex.RLock()
@ -644,37 +769,36 @@ func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirect
t.Fatal("Global OAuth service not initialized")
}
accessToken, err := oauthService.MakeAccessToken(clientID, scope, subject, 3600)
// Step 3: Add system:root to scope for full permissions in tests
fullScope := scope
if scope != "" && !strings.Contains(scope, "system:root") {
fullScope = scope + " system:root"
} else if scope == "" {
fullScope = "system:root"
}
// Step 4: Create access token with system:root scope
accessToken, err := oauthService.MakeAccessToken(clientID, fullScope, subject, 3600)
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
refreshToken, err := oauthService.MakeRefreshToken(clientID, scope, subject, 7200)
refreshToken, err := oauthService.MakeRefreshToken(clientID, fullScope, subject, 7200)
if err != nil {
t.Fatalf("Failed to create refresh token: %v", err)
}
// Create a synthetic token response
token := &types.Token{
tokenInfo := &TokenInfo{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: 3600,
Scope: scope,
}
tokenInfo := &TokenInfo{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.TokenType,
ExpiresIn: token.ExpiresIn,
Scope: token.Scope,
Scope: fullScope,
ClientID: clientID,
UserID: testUserID, // Include the test user ID
UserID: testUserID,
}
t.Logf("Obtained access token: %s (type: %s, expires_in: %d, user_id: %s)",
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn, tokenInfo.UserID)
t.Logf("Obtained access token with scope: %s (user_id: %s)", fullScope, testUserID)
return tokenInfo
}
@ -767,6 +891,41 @@ func ObtainTokenForUser(t *testing.T, clientID, clientSecret, userID, scope stri
t.Fatal("Global OAuth service not initialized")
}
// Get user provider to assign root role
userProvider, err := oauthService.GetUserProvider()
if err != nil || userProvider == nil {
t.Fatal("UserProvider not available")
}
ctx := context.Background()
// Ensure system:root role exists (create if needed)
roleID := "system:root"
_, err = userProvider.GetRole(ctx, roleID)
if err != nil {
// Role doesn't exist, create it
t.Logf("Creating system:root role for user %s", userID)
roleData := map[string]interface{}{
"role_id": roleID,
"name": "System Root",
"description": "System root role with full system access",
"permissions": []string{"system:root"},
"is_active": true,
}
_, err = userProvider.CreateRole(ctx, roleData)
if err != nil {
t.Logf("Warning: Failed to create system:root role: %v", err)
}
}
// Assign system:root role to the user
err = userProvider.SetUserRole(ctx, userID, roleID)
if err != nil {
t.Logf("Warning: Failed to assign system:root role to user: %v", err)
} else {
t.Logf("Assigned system:root role to user %s", userID)
}
// Create subject (fingerprint) for this user
// This sets up the fingerprint mapping: clientID:subject -> userID
subject, err := oauthService.Subject(clientID, userID)
@ -776,14 +935,22 @@ func ObtainTokenForUser(t *testing.T, clientID, clientSecret, userID, scope stri
t.Logf("Created fingerprint mapping: clientID=%s, userID=%s, subject=%s", clientID, userID, subject)
// Create access token
accessToken, err := oauthService.MakeAccessToken(clientID, scope, subject, 3600)
// Add system:root to scope for full permissions
fullScope := scope
if scope != "" && !strings.Contains(scope, "system:root") {
fullScope = scope + " system:root"
} else if scope == "" {
fullScope = "system:root"
}
// Create access token with system:root scope
accessToken, err := oauthService.MakeAccessToken(clientID, fullScope, subject, 3600)
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Create refresh token
refreshToken, err := oauthService.MakeRefreshToken(clientID, scope, subject, 7200)
refreshToken, err := oauthService.MakeRefreshToken(clientID, fullScope, subject, 7200)
if err != nil {
t.Fatalf("Failed to create refresh token: %v", err)
}
@ -793,12 +960,12 @@ func ObtainTokenForUser(t *testing.T, clientID, clientSecret, userID, scope stri
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: 3600,
Scope: scope,
Scope: fullScope,
ClientID: clientID,
UserID: userID,
}
t.Logf("Issued token for user %s (subject: %s)", userID, subject)
t.Logf("Issued token for user %s with scope: %s (subject: %s)", userID, fullScope, subject)
return tokenInfo
}
@ -811,13 +978,47 @@ func createTestUser(t *testing.T, server *openapi.OpenAPI, clientID string) (str
// Generate a unique test user ID
testUserID := fmt.Sprintf("test_user_%d", time.Now().UnixNano())
// Access the global OAuth service to set up fingerprint mapping
// The OAuth interface doesn't expose Subject method, so we need to access the concrete service
// Access the global OAuth service
oauthService := oauth.OAuth
if oauthService == nil {
t.Fatal("Global OAuth service not initialized")
}
// Create user in database with system:root role
userProvider, err := oauthService.GetUserProvider()
if err == nil && userProvider != nil {
ctx := context.Background()
roleID := "system:root"
// Ensure system:root role exists
_, err := userProvider.GetRole(ctx, roleID)
if err != nil {
// Role doesn't exist, create it
roleData := map[string]interface{}{
"role_id": roleID,
"name": "System Root",
"description": "System root role with full system access",
"permissions": []string{"system:root"},
"is_active": true,
}
_, err = userProvider.CreateRole(ctx, roleData)
if err != nil {
t.Logf("Warning: Failed to create system:root role: %v", err)
}
}
// Create user in database with system:root role
userData := map[string]interface{}{
"user_id": testUserID,
"status": "active",
"role_id": roleID,
}
_, err = userProvider.CreateUser(ctx, userData)
if err != nil {
t.Logf("Warning: Failed to create user in database: %v", err)
}
}
// Create subject (fingerprint) for this user using the concrete OAuth service
// This will set up the proper fingerprint mapping: clientID:subject -> userID
subject, err := oauthService.Subject(clientID, testUserID)

View file

@ -74,7 +74,7 @@ func TestEntryVerifyWithExistingUser(t *testing.T) {
assert.NoError(t, err)
// Verify response for existing user (login flow)
assert.Equal(t, "login", result.Status)
assert.Equal(t, user.EntryVerificationStatus("login"), result.Status)
assert.True(t, result.UserExists)
assert.NotEmpty(t, result.AccessToken)
assert.Equal(t, "Bearer", result.TokenType)
@ -132,7 +132,7 @@ func TestEntryVerifyWithNewUser(t *testing.T) {
assert.NoError(t, err)
// Verify response for new user (register flow)
assert.Equal(t, "register", result.Status)
assert.Equal(t, user.EntryVerificationStatus("register"), result.Status)
assert.False(t, result.UserExists)
assert.NotEmpty(t, result.AccessToken)
assert.Equal(t, "Bearer", result.TokenType)
@ -266,7 +266,7 @@ func TestEntryVerifyWithMobile(t *testing.T) {
assert.NoError(t, err)
// Verify response for existing user with mobile
assert.Equal(t, "login", result.Status)
assert.Equal(t, user.EntryVerificationStatus("login"), result.Status)
assert.True(t, result.UserExists)
assert.NotEmpty(t, result.AccessToken)
@ -305,7 +305,7 @@ func TestEntryVerifyWithMobile(t *testing.T) {
assert.NoError(t, err)
// Verify response for new mobile user
assert.Equal(t, "register", result.Status)
assert.Equal(t, user.EntryVerificationStatus("register"), result.Status)
assert.False(t, result.UserExists)
assert.True(t, result.VerificationSent)

View file

@ -34,8 +34,8 @@ func TestInvitationCreate(t *testing.T) {
client := testutils.RegisterTestClient(t, "Invitation Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Get access token with root permissions (creates real user in DB with system:root role)
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
@ -332,8 +332,8 @@ func TestInvitationList(t *testing.T) {
client := testutils.RegisterTestClient(t, "Invitation List Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Get access token with root permissions
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
@ -474,8 +474,8 @@ func TestInvitationGet(t *testing.T) {
client := testutils.RegisterTestClient(t, "Invitation Get Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Get access token with root permissions
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
@ -580,8 +580,8 @@ func TestInvitationResend(t *testing.T) {
client := testutils.RegisterTestClient(t, "Invitation Resend Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Get access token with root permissions
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
@ -663,8 +663,8 @@ func TestMultipleInvitationCreation(t *testing.T) {
client := testutils.RegisterTestClient(t, "Multiple Invitation Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Get access token with root permissions
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
@ -758,8 +758,8 @@ func TestInvitationDelete(t *testing.T) {
client := testutils.RegisterTestClient(t, "Invitation Delete Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Get access token
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Get access token with root permissions
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]

View file

@ -11,96 +11,6 @@ import (
"github.com/yaoapp/yao/openapi/tests/testutils"
)
func TestUserLogin(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User Login Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test entry endpoint (unified login/register, currently empty implementation)
testCases := []struct {
name string
method string
endpoint string
body map[string]interface{}
expectCode int
}{
{
"post entry without credentials",
"POST",
"/user/entry",
map[string]interface{}{},
200, // Currently empty implementation, may change when implemented
},
{
"post entry with credentials",
"POST",
"/user/entry",
map[string]interface{}{
"username": "testuser",
"password": "testpass",
},
200, // Currently empty implementation, may change when implemented
},
{
"post entry with email",
"POST",
"/user/entry",
map[string]interface{}{
"email": "test@example.com",
"password": "testpass",
},
200, // Currently empty implementation, may change when implemented
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + tc.endpoint
// Prepare request body
var req *http.Request
var err error
if tc.method == "POST" {
bodyBytes, _ := json.Marshal(tc.body)
req, err = http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req, err = http.NewRequest(tc.method, requestURL, nil)
}
assert.NoError(t, err, "Should create HTTP request")
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode)
t.Logf("Login test %s: status=%d", tc.name, resp.StatusCode)
// Note: Since login is currently not implemented (empty function),
// we can't test actual login functionality yet.
// This test serves as a placeholder for when login is implemented.
}
})
}
}
func TestUserLoginValidation(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -133,7 +134,7 @@ func TestMemberList(t *testing.T) {
if len(data) > 0 {
member := data[0].(map[string]interface{})
assert.Equal(t, tokenInfo.UserID, member["user_id"], "Owner should be in member list")
assert.Equal(t, "owner", member["role_id"], "Creator should have owner role")
assert.Equal(t, "owner:free", member["role_id"], "Creator should have owner:free role")
}
}
}
@ -1008,7 +1009,7 @@ func getOwnerMemberID(t *testing.T, serverURL, baseURL, teamID, accessToken stri
// Find the owner member and return their user_id
for _, item := range data {
member := item.(map[string]interface{})
if role, ok := member["role_id"].(string); ok && role == "owner" {
if role, ok := member["role_id"].(string); ok && strings.HasPrefix(role, "owner") {
userID, ok := member["user_id"].(string)
if !ok {
t.Fatal("Owner member missing user_id")

View file

@ -0,0 +1,368 @@
package user_test
import (
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/openapi/user"
)
// TestTeamConfigRobotLoad tests loading team configuration with robot field
func TestTeamConfigRobotLoad(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Get team config with robot configuration
teamConfig := user.GetTeamConfig("en")
assert.NotNil(t, teamConfig, "Team config should not be nil")
if teamConfig != nil && teamConfig.Robot != nil {
t.Logf("Robot config loaded successfully")
// Test robot roles
assert.NotNil(t, teamConfig.Robot.Roles, "Robot roles should not be nil")
if teamConfig.Robot.Roles != nil {
t.Logf("Robot roles: %v", teamConfig.Robot.Roles)
assert.Greater(t, len(teamConfig.Robot.Roles), 0, "Robot should have at least one role")
}
// Test robot agents
assert.NotNil(t, teamConfig.Robot.Agents, "Robot agents should not be nil")
if teamConfig.Robot.Agents != nil {
t.Logf("Robot agents - Executor: %s, Planner: %s, Profiler: %s",
teamConfig.Robot.Agents.Executor,
teamConfig.Robot.Agents.Planner,
teamConfig.Robot.Agents.Profiler)
assert.NotEmpty(t, teamConfig.Robot.Agents.Executor, "Executor agent should not be empty")
assert.NotEmpty(t, teamConfig.Robot.Agents.Planner, "Planner agent should not be empty")
assert.NotEmpty(t, teamConfig.Robot.Agents.Profiler, "Profiler agent should not be empty")
}
// Test robot email domains
assert.NotNil(t, teamConfig.Robot.EmailDomains, "Robot email domains should not be nil")
if teamConfig.Robot.EmailDomains != nil {
t.Logf("Robot has %d email domain(s)", len(teamConfig.Robot.EmailDomains))
assert.Greater(t, len(teamConfig.Robot.EmailDomains), 0, "Robot should have at least one email domain")
for i, domain := range teamConfig.Robot.EmailDomains {
t.Logf("Email domain %d: %s (%s)", i, domain.Name, domain.Domain)
assert.NotEmpty(t, domain.Name, "Email domain name should not be empty")
assert.NotEmpty(t, domain.Domain, "Email domain should not be empty")
assert.NotEmpty(t, domain.Messenger, "Email messenger should not be empty")
assert.Greater(t, domain.PrefixMinLength, 0, "PrefixMinLength should be greater than 0")
assert.Greater(t, domain.PrefixMaxLength, domain.PrefixMinLength, "PrefixMaxLength should be greater than PrefixMinLength")
// Test whitelist
assert.NotNil(t, domain.Whitelist, "Whitelist should not be nil")
if domain.Whitelist != nil {
t.Logf(" Whitelist - Domains: %v, Senders: %v, IPs: %v",
domain.Whitelist.Domains,
domain.Whitelist.Senders,
domain.Whitelist.IPs)
}
}
}
// Test robot defaults
assert.NotNil(t, teamConfig.Robot.Defaults, "Robot defaults should not be nil")
if teamConfig.Robot.Defaults != nil {
t.Logf("Robot defaults - LLM: %s, AutonomousMode: %v, CostLimit: %d",
teamConfig.Robot.Defaults.LLM,
teamConfig.Robot.Defaults.AutonomousMode,
teamConfig.Robot.Defaults.CostLimit)
assert.NotEmpty(t, teamConfig.Robot.Defaults.LLM, "Default LLM should not be empty")
}
} else {
t.Log("No robot configuration found in team config")
}
}
// TestGetTeamConfigPublic tests that GetTeamConfigPublic hides sensitive fields
func TestGetTeamConfigPublic(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Get original config
originalConfig := user.GetTeamConfig("en")
assert.NotNil(t, originalConfig, "Original config should not be nil")
// Get public config
publicConfig := user.GetTeamConfigPublic("en")
assert.NotNil(t, publicConfig, "Public config should not be nil")
// Test that basic fields are preserved
assert.Equal(t, originalConfig.Type, publicConfig.Type, "Type should be preserved")
assert.Equal(t, originalConfig.Role, publicConfig.Role, "Role should be preserved")
assert.Equal(t, originalConfig.Roles, publicConfig.Roles, "Roles should be preserved")
assert.Equal(t, originalConfig.Invite, publicConfig.Invite, "Invite config should be preserved")
// Test robot configuration
if originalConfig.Robot != nil {
t.Log("Testing robot config sanitization")
assert.NotNil(t, publicConfig.Robot, "Public config should have robot config")
// Test that roles are preserved
assert.Equal(t, originalConfig.Robot.Roles, publicConfig.Robot.Roles, "Robot roles should be preserved")
// Test that agents are hidden (SENSITIVE)
assert.Nil(t, publicConfig.Robot.Agents, "Robot agents should be hidden in public config")
if originalConfig.Robot.Agents != nil {
t.Logf("Original agents (hidden in public): Executor=%s, Planner=%s, Profiler=%s",
originalConfig.Robot.Agents.Executor,
originalConfig.Robot.Agents.Planner,
originalConfig.Robot.Agents.Profiler)
}
// Test that defaults are preserved
assert.Equal(t, originalConfig.Robot.Defaults, publicConfig.Robot.Defaults, "Robot defaults should be preserved")
// Test email domains
if originalConfig.Robot.EmailDomains != nil {
assert.NotNil(t, publicConfig.Robot.EmailDomains, "Public config should have email domains")
assert.Equal(t, len(originalConfig.Robot.EmailDomains), len(publicConfig.Robot.EmailDomains),
"Email domains count should match")
for i := 0; i < len(originalConfig.Robot.EmailDomains); i++ {
origDomain := originalConfig.Robot.EmailDomains[i]
pubDomain := publicConfig.Robot.EmailDomains[i]
// Test that basic fields are preserved
assert.Equal(t, origDomain.Name, pubDomain.Name, "Domain name should be preserved")
assert.Equal(t, origDomain.Domain, pubDomain.Domain, "Domain should be preserved")
assert.Equal(t, origDomain.Messenger, pubDomain.Messenger, "Messenger should be preserved")
assert.Equal(t, origDomain.PrefixMinLength, pubDomain.PrefixMinLength, "PrefixMinLength should be preserved")
assert.Equal(t, origDomain.PrefixMaxLength, pubDomain.PrefixMaxLength, "PrefixMaxLength should be preserved")
assert.Equal(t, origDomain.ReservedWords, pubDomain.ReservedWords, "ReservedWords should be preserved")
// Test that whitelist is hidden (SENSITIVE)
assert.Nil(t, pubDomain.Whitelist, "Whitelist should be hidden in public config")
if origDomain.Whitelist != nil {
t.Logf("Domain %s whitelist (hidden in public): Domains=%v, Senders=%v, IPs=%v",
origDomain.Name,
origDomain.Whitelist.Domains,
origDomain.Whitelist.Senders,
origDomain.Whitelist.IPs)
}
}
}
}
}
// TestGetTeamConfigPublicNoMutation tests that GetTeamConfigPublic doesn't mutate original data
func TestGetTeamConfigPublicNoMutation(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Get original config
originalConfig := user.GetTeamConfig("en")
if originalConfig == nil || originalConfig.Robot == nil {
t.Skip("No robot config available for this test")
}
// Store original values for comparison
var originalAgentsPresent bool
var originalAgents *user.RobotAgents
if originalConfig.Robot.Agents != nil {
originalAgentsPresent = true
originalAgents = &user.RobotAgents{
Executor: originalConfig.Robot.Agents.Executor,
Planner: originalConfig.Robot.Agents.Planner,
Profiler: originalConfig.Robot.Agents.Profiler,
}
}
var originalWhitelists []*user.EmailDomainWhitelist
if originalConfig.Robot.EmailDomains != nil {
for _, domain := range originalConfig.Robot.EmailDomains {
if domain.Whitelist != nil {
originalWhitelists = append(originalWhitelists, &user.EmailDomainWhitelist{
Domains: domain.Whitelist.Domains,
Senders: domain.Whitelist.Senders,
IPs: domain.Whitelist.IPs,
})
}
}
}
// Get public config (should create a copy, not mutate original)
publicConfig := user.GetTeamConfigPublic("en")
assert.NotNil(t, publicConfig, "Public config should not be nil")
// Verify original config is unchanged
originalConfigAfter := user.GetTeamConfig("en")
assert.NotNil(t, originalConfigAfter, "Original config should still exist")
if originalAgentsPresent {
assert.NotNil(t, originalConfigAfter.Robot.Agents, "Original agents should still be present")
assert.Equal(t, originalAgents.Executor, originalConfigAfter.Robot.Agents.Executor, "Original executor should be unchanged")
assert.Equal(t, originalAgents.Planner, originalConfigAfter.Robot.Agents.Planner, "Original planner should be unchanged")
assert.Equal(t, originalAgents.Profiler, originalConfigAfter.Robot.Agents.Profiler, "Original profiler should be unchanged")
}
if len(originalWhitelists) > 0 {
for i, domain := range originalConfigAfter.Robot.EmailDomains {
if i < len(originalWhitelists) {
assert.NotNil(t, domain.Whitelist, "Original whitelist should still be present")
assert.Equal(t, originalWhitelists[i].Domains, domain.Whitelist.Domains, "Original whitelist domains should be unchanged")
assert.Equal(t, originalWhitelists[i].Senders, domain.Whitelist.Senders, "Original whitelist senders should be unchanged")
assert.Equal(t, originalWhitelists[i].IPs, domain.Whitelist.IPs, "Original whitelist IPs should be unchanged")
}
}
}
t.Log("Original config remains intact after calling GetTeamConfigPublic")
}
// TestTeamConfigAPIPublic tests that the API endpoint returns public config
func TestTeamConfigAPIPublic(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register a test client and get access token
testClient := testutils.RegisterTestClient(t, "Robot Config Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token for authentication
tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile")
// Test API endpoint
requestURL := serverURL + baseURL + "/user/teams/config?locale=en"
// Create request with Authorization header
req, err := http.NewRequest("GET", requestURL, nil)
assert.NoError(t, err, "Should create HTTP request")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected status code 200")
// Parse response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
var teamConfig user.TeamConfig
err = json.Unmarshal(body, &teamConfig)
assert.NoError(t, err, "Should parse JSON response")
t.Logf("API response has %d roles", len(teamConfig.Roles))
// Test that robot config is present (if available in config files)
if teamConfig.Robot != nil {
t.Log("Robot config present in API response")
// Test that public fields are present
if teamConfig.Robot.Roles != nil {
t.Logf("Robot roles: %v", teamConfig.Robot.Roles)
assert.Greater(t, len(teamConfig.Robot.Roles), 0, "Robot should have at least one role")
}
if teamConfig.Robot.Defaults != nil {
t.Logf("Robot defaults - LLM: %s, AutonomousMode: %v, CostLimit: %d",
teamConfig.Robot.Defaults.LLM,
teamConfig.Robot.Defaults.AutonomousMode,
teamConfig.Robot.Defaults.CostLimit)
}
// Test that sensitive fields are hidden
assert.Nil(t, teamConfig.Robot.Agents, "Robot agents should be hidden in API response (SENSITIVE)")
if teamConfig.Robot.EmailDomains != nil {
t.Logf("Robot has %d email domain(s)", len(teamConfig.Robot.EmailDomains))
for i, domain := range teamConfig.Robot.EmailDomains {
t.Logf("Email domain %d: %s (%s)", i, domain.Name, domain.Domain)
assert.NotEmpty(t, domain.Name, "Email domain name should be present")
assert.NotEmpty(t, domain.Domain, "Email domain should be present")
// Test that whitelist is hidden
assert.Nil(t, domain.Whitelist, "Whitelist should be hidden in API response (SENSITIVE)")
}
}
} else {
t.Log("No robot configuration in API response")
}
}
// TestTeamConfigAPILocales tests that API returns correct locale-specific robot config
func TestTeamConfigAPILocales(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register a test client and get access token
testClient := testutils.RegisterTestClient(t, "Robot Config Locale Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token for authentication
tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile")
// Test different locales
locales := []string{"en", "zh-cn", ""}
client := &http.Client{Timeout: 10 * time.Second}
for _, locale := range locales {
t.Run("locale_"+locale, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/teams/config"
if locale != "" {
requestURL += "?locale=" + locale
}
req, err := http.NewRequest("GET", requestURL, nil)
assert.NoError(t, err, "Should create HTTP request")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected status code 200")
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
var teamConfig user.TeamConfig
err = json.Unmarshal(body, &teamConfig)
assert.NoError(t, err, "Should parse JSON response")
t.Logf("Locale '%s': %d roles", locale, len(teamConfig.Roles))
// Verify sensitive fields are hidden
if teamConfig.Robot != nil {
assert.Nil(t, teamConfig.Robot.Agents, "Agents should be hidden for locale: "+locale)
if teamConfig.Robot.EmailDomains != nil {
for _, domain := range teamConfig.Robot.EmailDomains {
assert.Nil(t, domain.Whitelist, "Whitelist should be hidden for locale: "+locale)
}
}
}
})
}
}

View file

@ -330,6 +330,54 @@ func GetTeamConfig(locale string) *TeamConfig {
return nil
}
// GetTeamConfigPublic returns the public team configuration for a given locale
// This method hides sensitive fields (agents, email_domains.whitelist) without destroying the loaded data
func GetTeamConfigPublic(locale string) *TeamConfig {
configMutex.RLock()
defer configMutex.RUnlock()
// Get the original config
originalConfig := GetTeamConfig(locale)
if originalConfig == nil {
return nil
}
// Create a deep copy of the config to avoid modifying the original
publicConfig := &TeamConfig{
Type: originalConfig.Type,
Role: originalConfig.Role,
Roles: originalConfig.Roles, // Shallow copy is OK for roles (read-only)
Invite: originalConfig.Invite, // Shallow copy is OK for invite config (read-only)
}
// Handle robot config - create a copy without sensitive fields
if originalConfig.Robot != nil {
publicConfig.Robot = &RobotConfig{
Roles: originalConfig.Robot.Roles, // Shallow copy of string slice
// Agents is intentionally omitted (sensitive)
Defaults: originalConfig.Robot.Defaults, // Shallow copy is OK (read-only)
}
// Copy email domains without whitelist
if originalConfig.Robot.EmailDomains != nil {
publicConfig.Robot.EmailDomains = make([]*RobotEmailDomain, len(originalConfig.Robot.EmailDomains))
for i, domain := range originalConfig.Robot.EmailDomains {
publicConfig.Robot.EmailDomains[i] = &RobotEmailDomain{
Name: domain.Name,
Messenger: domain.Messenger,
Domain: domain.Domain,
PrefixMinLength: domain.PrefixMinLength,
PrefixMaxLength: domain.PrefixMaxLength,
ReservedWords: domain.ReservedWords,
// Whitelist is intentionally omitted (sensitive)
}
}
}
}
return publicConfig
}
// extractEnvVarName extracts the environment variable name from a string like "$ENV.VAR_NAME"
func extractEnvVarName(value string) string {
if value == "" {

View file

@ -33,7 +33,7 @@ func GinTeamConfig(c *gin.Context) {
locale = strings.TrimSpace(locale)
locale = strings.Trim(locale, "?&=")
config := GetTeamConfig(locale)
config := GetTeamConfigPublic(locale)
if config == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,

View file

@ -499,9 +499,50 @@ type CreateInvitationRequest struct {
// ==== Team Configuration Types ====
// RobotConfig represents the AI member (robot) configuration
type RobotConfig struct {
Roles []string `json:"roles,omitempty"` // Available roles for AI members
Agents *RobotAgents `json:"agents,omitempty"` // Agent configuration for AI members
EmailDomains []*RobotEmailDomain `json:"email_domains,omitempty"` // Email domain configurations
Defaults *RobotDefaults `json:"defaults,omitempty"` // Default settings for AI members
}
// RobotAgents represents the agent configuration for AI members
type RobotAgents struct {
Executor string `json:"executor,omitempty"` // Agent responsible for executing tasks
Planner string `json:"planner,omitempty"` // Agent responsible for planning
Profiler string `json:"profiler,omitempty"` // Agent responsible for identity
}
// RobotEmailDomain represents an email domain configuration for AI members
type RobotEmailDomain struct {
Name string `json:"name,omitempty"` // Display name
Messenger string `json:"messenger,omitempty"` // Messenger channel
Domain string `json:"domain,omitempty"` // Email domain
PrefixMinLength int `json:"prefix_min_length,omitempty"` // Minimum prefix length
PrefixMaxLength int `json:"prefix_max_length,omitempty"` // Maximum prefix length
ReservedWords []string `json:"reserved_words,omitempty"` // Reserved words
Whitelist *EmailDomainWhitelist `json:"whitelist,omitempty"` // Whitelist configuration
}
// EmailDomainWhitelist represents the whitelist configuration for email domains
type EmailDomainWhitelist struct {
Domains []string `json:"domains,omitempty"` // Whitelisted domains
Senders []string `json:"senders,omitempty"` // Whitelisted senders
IPs []string `json:"ips,omitempty"` // Whitelisted IPs
}
// RobotDefaults represents default settings for AI members
type RobotDefaults struct {
LLM string `json:"llm,omitempty"` // Default LLM model
AutonomousMode bool `json:"autonomous_mode,omitempty"` // Default autonomous mode
CostLimit int `json:"cost_limit,omitempty"` // Default daily cost limit
}
// TeamConfig represents the team configuration loaded from DSL files
type TeamConfig struct {
Roles []*TeamRole `json:"roles,omitempty"`
Robot *RobotConfig `json:"robot,omitempty"`
Invite *InviteConfig `json:"invite,omitempty"`
Type string `json:"type,omitempty"` // Default subscription type for new teams
Role string `json:"role,omitempty"` // Default user role for team creator

View file

@ -117,7 +117,7 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
team.Use(oauth.Guard)
// Team Configuration
team.GET("/config", GinTeamConfig) // Get team configuration (requires authentication)
team.GET("/config", GinTeamConfig) // Get team configuration (public version, sensitive fields hidden)
// Team Selection
team.POST("/select", GinTeamSelection) // POST /teams/select - Select a team and issue tokens with team_id (requires authentication)

View file

@ -249,6 +249,24 @@ func loadSystemStores(t *testing.T, cfg config.Config) error {
source = replaceVars(source, vars)
}
// Parse store config to check if we need to create directories (for badger stores)
var storeConfig map[string]interface{}
if err := application.Parse(path, []byte(source), &storeConfig); err == nil {
// Check if this is a badger store
if storeType, ok := storeConfig["type"].(string); ok && storeType == "badger" {
// Extract the path from option.path
if option, ok := storeConfig["option"].(map[string]interface{}); ok {
if storePath, ok := option["path"].(string); ok {
// Create directory for badger store
if err := os.MkdirAll(storePath, 0755); err != nil {
log.Error("failed to create directory for store %s at %s: %s", id, storePath, err.Error())
return fmt.Errorf("failed to create directory for store %s: %w", id, err)
}
}
}
}
}
// Load store with the processed source
_, err = store.LoadSource([]byte(source), id, filepath.Join("__system", path))
if err != nil {