diff --git a/Makefile b/Makefile index eedbf0a8..c5b5fc2d 100644 --- a/Makefile +++ b/Makefile @@ -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/...) diff --git a/openapi/agent/agnet.go b/openapi/agent/agnet.go new file mode 100644 index 00000000..48831554 --- /dev/null +++ b/openapi/agent/agnet.go @@ -0,0 +1 @@ +package agent diff --git a/openapi/llm/llm.go b/openapi/llm/llm.go new file mode 100644 index 00000000..006c3ded --- /dev/null +++ b/openapi/llm/llm.go @@ -0,0 +1 @@ +package llm diff --git a/openapi/mcp/mcp.go b/openapi/mcp/mcp.go new file mode 100644 index 00000000..87468ebd --- /dev/null +++ b/openapi/mcp/mcp.go @@ -0,0 +1 @@ +package mcp diff --git a/openapi/tests/kb/collection_test.go b/openapi/tests/kb/collection_test.go index ba9a8c49..c3af4fcb 100644 --- a/openapi/tests/kb/collection_test.go +++ b/openapi/tests/kb/collection_test.go @@ -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 }, } diff --git a/openapi/tests/testutils/testutils.go b/openapi/tests/testutils/testutils.go index a8f2087d..b2dbedb3 100644 --- a/openapi/tests/testutils/testutils.go +++ b/openapi/tests/testutils/testutils.go @@ -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) diff --git a/openapi/tests/user/entry_test.go b/openapi/tests/user/entry_test.go index e9864a48..433bfeaa 100644 --- a/openapi/tests/user/entry_test.go +++ b/openapi/tests/user/entry_test.go @@ -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) diff --git a/openapi/tests/user/invitation_test.go b/openapi/tests/user/invitation_test.go index 392375ad..bcc102a6 100644 --- a/openapi/tests/user/invitation_test.go +++ b/openapi/tests/user/invitation_test.go @@ -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] diff --git a/openapi/tests/user/login_test.go b/openapi/tests/user/login_test.go index 4e1d415f..3b8a5777 100644 --- a/openapi/tests/user/login_test.go +++ b/openapi/tests/user/login_test.go @@ -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) diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go index 5a36292b..ac06bdb5 100644 --- a/openapi/tests/user/member_test.go +++ b/openapi/tests/user/member_test.go @@ -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") diff --git a/openapi/tests/user/team_config_robot_test.go b/openapi/tests/user/team_config_robot_test.go new file mode 100644 index 00000000..a9f5ae6a --- /dev/null +++ b/openapi/tests/user/team_config_robot_test.go @@ -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) + } + } + } + }) + } +} diff --git a/openapi/user/config.go b/openapi/user/config.go index e9503cbc..162437e8 100644 --- a/openapi/user/config.go +++ b/openapi/user/config.go @@ -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 == "" { diff --git a/openapi/user/team.go b/openapi/user/team.go index 09545209..f26bd0b7 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -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, diff --git a/openapi/user/types.go b/openapi/user/types.go index 9d957716..7f43e1dc 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -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 diff --git a/openapi/user/user.go b/openapi/user/user.go index 2453952c..da3d2d3d 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -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) diff --git a/test/utils.go b/test/utils.go index b362d569..79c3fc95 100644 --- a/test/utils.go +++ b/test/utils.go @@ -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 {