diff --git a/openapi/oauth/providers/user/member.go b/openapi/oauth/providers/user/member.go index f6d5cc81..e20b615e 100644 --- a/openapi/oauth/providers/user/member.go +++ b/openapi/oauth/providers/user/member.go @@ -193,6 +193,26 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo return 0, fmt.Errorf("role_id is required for robot members") } + // Check if email already exists in this team + if email, exists := robotData["email"]; exists && email != nil && email != "" { + emailStr := fmt.Sprintf("%v", email) + m := model.Select(u.memberModel) + existingMembers, err := m.Get(model.QueryParam{ + Select: []interface{}{"id"}, + Wheres: []model.QueryWhere{ + {Column: "team_id", Value: teamID}, + {Column: "email", Value: emailStr}, + }, + Limit: 1, + }) + if err != nil { + return 0, fmt.Errorf("failed to check email uniqueness: %w", err) + } + if len(existingMembers) > 0 { + return 0, fmt.Errorf("email %s already exists in this team", emailStr) + } + } + memberData := maps.MapStrAny{ "team_id": teamID, "member_type": "robot", @@ -202,7 +222,7 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo // Copy shared profile fields (used by both users and robots) profileFields := []string{ - "display_name", "bio", "avatar", + "display_name", "bio", "avatar", "email", } for _, field := range profileFields { if value, exists := robotData[field]; exists { diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go index ac06bdb5..7990d07a 100644 --- a/openapi/tests/user/member_test.go +++ b/openapi/tests/user/member_test.go @@ -3,11 +3,13 @@ package user_test import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/yaoapp/yao/openapi" "github.com/yaoapp/yao/openapi/tests/testutils" @@ -1022,4 +1024,313 @@ func getOwnerMemberID(t *testing.T, serverURL, baseURL, teamID, accessToken stri return "" } +// TestMemberCreateRobot tests the POST /user/teams/:team_id/members/robots endpoint +func TestMemberCreateRobot(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 for OAuth authentication + testClient := testutils.RegisterTestClient(t, "Robot Member Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token with root permissions (required for creating robot members) + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Use UUID to ensure unique team name + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] + + // Create a test team + createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Robot Member Test Team "+testUUID) + teamID := getTeamID(createdTeam) + + testCases := []struct { + name string + teamID string + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + }{ + { + "create robot without authentication", + teamID, + map[string]interface{}{ + "name": "Test Robot", + "email": "robot@test.com", + "role": "member", + "prompt": "You are a helpful assistant", + }, + map[string]string{}, + 401, + "should require authentication", + }, + { + "create robot with all fields", + teamID, + map[string]interface{}{ + "name": "AI Assistant Full", + "email": fmt.Sprintf("ai-full-%s@test.com", testUUID), + "bio": "A comprehensive AI assistant", + "role": "member", + "report_to": tokenInfo.UserID, + "prompt": "You are a helpful AI assistant with full capabilities", + "llm": "gpt-4", + "agents": []string{"data-analyst", "code-reviewer"}, + "mcp_tools": []string{"filesystem", "database"}, + "autonomous_mode": "enabled", + "cost_limit": 100.50, + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should create robot with all fields successfully", + }, + { + "create robot with required fields only", + teamID, + map[string]interface{}{ + "name": "AI Assistant Min", + "email": fmt.Sprintf("ai-min-%s@test.com", testUUID), + "role": "member", + "prompt": "You are a basic assistant", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should create robot with required fields only", + }, + { + "create robot with autonomous_mode variations", + teamID, + map[string]interface{}{ + "name": "AI Assistant Auto", + "email": fmt.Sprintf("ai-auto-%s@test.com", testUUID), + "role": "member", + "prompt": "You are an autonomous assistant", + "autonomous_mode": "1", // Test numeric string + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should handle autonomous_mode=1", + }, + { + "create robot with disabled autonomous_mode", + teamID, + map[string]interface{}{ + "name": "AI Assistant Manual", + "email": fmt.Sprintf("ai-manual-%s@test.com", testUUID), + "role": "member", + "prompt": "You are a manual assistant", + "autonomous_mode": "disabled", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should handle autonomous_mode=disabled", + }, + { + "create robot without name", + teamID, + map[string]interface{}{ + "email": "no-name@test.com", + "role": "member", + "prompt": "You are an assistant", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require name", + }, + { + "create robot without email", + teamID, + map[string]interface{}{ + "name": "No Email Robot", + "role": "member", + "prompt": "You are an assistant", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require email", + }, + { + "create robot without role", + teamID, + map[string]interface{}{ + "name": "No Role Robot", + "email": "no-role@test.com", + "prompt": "You are an assistant", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require role", + }, + { + "create robot without prompt", + teamID, + map[string]interface{}{ + "name": "No Prompt Robot", + "email": "no-prompt@test.com", + "role": "member", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require prompt", + }, + { + "create robot with duplicate email", + teamID, + map[string]interface{}{ + "name": "Duplicate Email Robot", + "email": fmt.Sprintf("ai-full-%s@test.com", testUUID), // Same as first successful case + "role": "member", + "prompt": "You are an assistant", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 409, + "should reject duplicate email in same team", + }, + { + "create robot in non-existent team", + "non-existent-team-id", + map[string]interface{}{ + "name": "Robot in Void", + "email": "void@test.com", + "role": "member", + "prompt": "You are lost", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + { + "create robot with invalid JSON", + teamID, + nil, // Will send invalid JSON + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should handle invalid JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/robots" + + var req *http.Request + var err error + + if tc.body == nil { + // Send invalid JSON for invalid JSON test case + req, err = http.NewRequest("POST", requestURL, bytes.NewBufferString("invalid json")) + } else { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest("POST", requestURL, bytes.NewBuffer(bodyBytes)) + } + assert.NoError(t, err, "Should create HTTP request") + + req.Header.Set("Content-Type", "application/json") + + // Add headers + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + 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 for %s", tc.expectCode, tc.name) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "Should read response body") + + if resp.StatusCode == 201 { + // Parse response as created member + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + // Verify response structure + assert.Contains(t, response, "member_id", "Should have member_id") + assert.NotEmpty(t, response["member_id"], "Member ID should not be empty") + + // Verify the member was created with correct type + memberID := toString(response["member_id"]) + getMemberURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/" + memberID + getReq, _ := http.NewRequest("GET", getMemberURL, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + getResp, err := client.Do(getReq) + if err == nil && getResp != nil { + defer getResp.Body.Close() + if getResp.StatusCode == 200 { + var member map[string]interface{} + getBody, _ := io.ReadAll(getResp.Body) + json.Unmarshal(getBody, &member) + + // Verify robot member fields + assert.Equal(t, "robot", member["member_type"], "Should be robot member type") + if tc.body["name"] != nil { + assert.Equal(t, tc.body["name"], member["display_name"], "Should have correct display_name") + } + if tc.body["email"] != nil { + assert.Equal(t, tc.body["email"], member["email"], "Should have correct email") + } + if tc.body["prompt"] != nil { + assert.Equal(t, tc.body["prompt"], member["system_prompt"], "Should have correct system_prompt") + } + } + } + } + + t.Logf("Robot member create test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// toString converts interface{} to string for test assertions +func toString(v interface{}) string { + switch val := v.(type) { + case string: + return val + case float64: + return fmt.Sprintf("%.0f", val) + case int: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + default: + return fmt.Sprintf("%v", val) + } +} + // Note: getTeamID function is already defined in team_test.go diff --git a/openapi/user/member.go b/openapi/user/member.go index 4f65226c..31ad82f7 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -147,10 +147,10 @@ func GinMemberGet(c *gin.Context) { response.RespondWithSuccess(c, http.StatusOK, member) } -// GinMemberCreateDirect handles POST /teams/:team_id/members - Add member directly to team -func GinMemberCreateDirect(c *gin.Context) { +// GinMemberCreateRobot handles POST /teams/:team_id/members/robots - Add robot member to team +func GinMemberCreateRobot(c *gin.Context) { // Get authorized user info - authInfo := oauth.GetAuthorizedInfo(c) + authInfo := authorized.GetInfo(c) if authInfo == nil || authInfo.UserID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, @@ -171,7 +171,7 @@ func GinMemberCreateDirect(c *gin.Context) { } // Parse request body - var req CreateMemberRequest + var req CreateRobotMemberRequest if err := c.ShouldBindJSON(&req); err != nil { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, @@ -181,22 +181,40 @@ func GinMemberCreateDirect(c *gin.Context) { return } - // Prepare member data - memberData := maps.MapStrAny{ - "user_id": req.UserID, - "member_type": req.MemberType, - "role_id": req.RoleID, + // Prepare base robot member data + baseData := maps.MapStrAny{ + "display_name": req.Name, + "email": req.Email, + "bio": req.Bio, + "role_id": req.RoleID, + "system_prompt": req.SystemPrompt, + "autonomous_mode": toBool(req.AutonomousMode), } - // Add settings if provided - if req.Settings != nil { - memberData["settings"] = req.Settings + // Add optional fields + if req.ManagerID != "" { + baseData["manager_id"] = req.ManagerID } + if req.LanguageModel != "" { + baseData["language_model"] = req.LanguageModel + } + if len(req.Agents) > 0 { + baseData["agents"] = req.Agents + } + if len(req.MCPServers) > 0 { + baseData["mcp_servers"] = req.MCPServers + } + if req.CostLimit > 0 { + baseData["cost_limit"] = req.CostLimit + } + + // Wrap with create scope for permission tracking + robotData := authInfo.WithCreateScope(baseData) // Call business logic - memberID, err := memberCreateDirect(c.Request.Context(), authInfo.UserID, teamID, memberData) + memberID, err := memberCreateRobot(c.Request.Context(), authInfo.UserID, teamID, robotData) if err != nil { - log.Error("Failed to create member: %v", err) + log.Error("Failed to create robot member: %v", err) // Check error type for appropriate response if strings.Contains(err.Error(), "not found") { errorResp := &response.ErrorResponse{ @@ -210,7 +228,7 @@ func GinMemberCreateDirect(c *gin.Context) { ErrorDescription: err.Error(), } response.RespondWithError(c, response.StatusForbidden, errorResp) - } else if strings.Contains(err.Error(), "already exists") { + } else if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate") { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: err.Error(), @@ -219,7 +237,7 @@ func GinMemberCreateDirect(c *gin.Context) { } else { errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, - ErrorDescription: "Failed to create member", + ErrorDescription: "Failed to create robot member", } response.RespondWithError(c, response.StatusInternalServerError, errorResp) } @@ -454,48 +472,6 @@ func ProcessMemberGet(process *process.Process) interface{} { return result } -// ProcessMemberCreateDirect user.member.create Member create processor -// Args[0] string: team_id -// Args[1] map: Member data {"user_id": "user123", "member_type": "user", "role_id": "member", "settings": {...}} -// Return: map: {"member_id": "created_member_id"} -func ProcessMemberCreateDirect(process *process.Process) interface{} { - process.ValidateArgNums(2) - - // Get user_id from session - userIDStr := GetUserIDFromSession(process) - - teamID := process.ArgsString(0) - memberData := maps.MapStrAny(process.ArgsMap(1)) - - if teamID == "" { - exception.New("team_id is required", 400).Throw() - } - - // Validate required fields - if _, ok := memberData["user_id"]; !ok { - exception.New("user_id is required", 400).Throw() - } - if _, ok := memberData["role_id"]; !ok { - exception.New("role_id is required", 400).Throw() - } - - // Get context - ctx := process.Context - if ctx == nil { - ctx = context.Background() - } - - // Call business logic - memberID, err := memberCreateDirect(ctx, userIDStr, teamID, memberData) - if err != nil { - exception.New("failed to create member: %s", 500, err.Error()).Throw() - } - - return map[string]interface{}{ - "member_id": memberID, - } -} - // ProcessMemberUpdate user.member.update Member update processor // Args[0] string: team_id // Args[1] string: member_id @@ -644,8 +620,8 @@ func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapSt return memberData, nil } -// memberCreateDirect handles the business logic for creating a team member directly -func memberCreateDirect(ctx context.Context, userID, teamID string, memberData maps.MapStrAny) (int64, error) { +// memberCreateRobot handles the business logic for creating a robot member +func memberCreateRobot(ctx context.Context, userID, teamID string, robotData maps.MapStrAny) (int64, error) { // Check if user has access to the team (write permission: owner only) isOwner, _, err := checkTeamAccess(ctx, teamID, userID) if err != nil { @@ -654,7 +630,7 @@ func memberCreateDirect(ctx context.Context, userID, teamID string, memberData m // Only allow access if user is owner if !isOwner { - return 0, fmt.Errorf("access denied: only team owner can add members") + return 0, fmt.Errorf("access denied: only team owner can add robot members") } // Get user provider instance @@ -663,30 +639,10 @@ func memberCreateDirect(ctx context.Context, userID, teamID string, memberData m return 0, fmt.Errorf("failed to get user provider: %w", err) } - // Check if member already exists - memberUserID := toString(memberData["user_id"]) - exists, err := provider.MemberExists(ctx, teamID, memberUserID) + // Use CreateRobotMember method which handles robot-specific logic + memberID, err := provider.CreateRobotMember(ctx, teamID, robotData) if err != nil { - return 0, fmt.Errorf("failed to check member existence: %w", err) - } - if exists { - return 0, fmt.Errorf("member already exists in this team") - } - - // Set team ID and default values - memberData["team_id"] = teamID - if memberData["member_type"] == nil || memberData["member_type"] == "" { - memberData["member_type"] = "user" - } - memberData["status"] = "active" - memberData["joined_at"] = time.Now() - memberData["created_at"] = time.Now() - memberData["updated_at"] = time.Now() - - // Create member - memberID, err := provider.CreateMember(ctx, memberData) - if err != nil { - return 0, fmt.Errorf("failed to create member: %w", err) + return 0, fmt.Errorf("failed to create robot member: %w", err) } return memberID, nil diff --git a/openapi/user/types.go b/openapi/user/types.go index 7f43e1dc..a01cf5cb 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -416,12 +416,19 @@ type MemberDetailResponse struct { UserInfo map[string]interface{} `json:"user_info,omitempty"` } -// CreateMemberRequest represents the request to add a member directly -type CreateMemberRequest struct { - UserID string `json:"user_id" binding:"required"` - MemberType string `json:"member_type,omitempty"` // "user" or "robot" - RoleID string `json:"role_id" binding:"required"` - Settings *MemberSettings `json:"settings,omitempty"` +// CreateRobotMemberRequest represents the request to create a new robot member +type CreateRobotMemberRequest struct { + Name string `json:"name" binding:"required"` // Display name + Email string `json:"email" binding:"required"` // Email address + Bio string `json:"bio,omitempty"` // Bio/description + RoleID string `json:"role" binding:"required"` // Role ID + ManagerID string `json:"report_to,omitempty"` // Direct manager user ID + SystemPrompt string `json:"prompt" binding:"required"` // Identity & role prompt + LanguageModel string `json:"llm,omitempty"` // Language model (e.g., "gpt-4") + Agents []string `json:"agents,omitempty"` // Accessible agents + MCPServers []string `json:"mcp_tools,omitempty"` // MCP servers/tools + AutonomousMode string `json:"autonomous_mode,omitempty"` // "enabled" or "disabled" + CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD } // UpdateMemberRequest represents the request to update a member diff --git a/openapi/user/user.go b/openapi/user/user.go index da3d2d3d..e2641c71 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -134,7 +134,7 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { // Team Members - Nested resource endpoints team.GET("/:id/members", GinMemberList) // GET /teams/:id/members - List team members - team.POST("/:id/members", GinMemberCreateDirect) // POST /teams/:id/members - Add team member + team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /teams/:id/members/robots - Add robot member team.GET("/:id/members/:member_id", GinMemberGet) // GET /teams/:id/members/:member_id - Get member details team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /teams/:id/members/:member_id - Update member team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /teams/:id/members/:member_id - Remove member diff --git a/openapi/user/utils.go b/openapi/user/utils.go index 67afbbbb..e5a763fb 100644 --- a/openapi/user/utils.go +++ b/openapi/user/utils.go @@ -35,6 +35,7 @@ func GetUserIDFromSession(process *process.Process) string { // toBool converts various types to boolean // Supports: bool, int, int64, float64, string +// String values: "true", "false", "1", "0", "enabled", "disabled", "yes", "no", "on", "off" // Returns false for nil or unsupported types func toBool(v interface{}) bool { if v == nil { @@ -51,7 +52,16 @@ func toBool(v interface{}) bool { case float64: return val != 0 case string: - return val == "true" || val == "1" + // Normalize string to lowercase for case-insensitive comparison + normalized := strings.ToLower(strings.TrimSpace(val)) + switch normalized { + case "true", "1", "enabled", "yes", "on": + return true + case "false", "0", "disabled", "no", "off", "": + return false + default: + return false + } default: return false }