From 1e01ed768ed318bd387e63896d77980ad4010548 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 12:05:11 +0800 Subject: [PATCH] Implement robot member update functionality with comprehensive validation - Added the `UpdateRobotMember` method to handle updates for robot members, including validation for member existence, type checks, and email uniqueness. - Introduced a new `UpdateRobotMemberRequest` structure to facilitate updates via the API, allowing for modifications to various fields such as `robot_email`, `status`, and `autonomous_mode`. - Enhanced the `GinMemberUpdateRobot` handler to process update requests, ensuring proper authentication and error handling for various scenarios. - Expanded test coverage with new test cases for the robot member update functionality, validating successful updates, error conditions, and edge cases. - Refactored existing member management logic to integrate the new update capabilities, improving overall system robustness and user experience. --- openapi/oauth/providers/user/member.go | 77 ++++ openapi/oauth/providers/user/member_test.go | 227 +++++++++++ openapi/oauth/types/interfaces.go | 1 + openapi/tests/user/member_test.go | 412 ++++++++++++++++++++ openapi/user/member.go | 164 ++++++++ openapi/user/types.go | 20 + openapi/user/user.go | 1 + 7 files changed, 902 insertions(+) diff --git a/openapi/oauth/providers/user/member.go b/openapi/oauth/providers/user/member.go index 6f3934ac..6eafd6ab 100644 --- a/openapi/oauth/providers/user/member.go +++ b/openapi/oauth/providers/user/member.go @@ -386,6 +386,83 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo return u.CreateMember(ctx, memberData) } +// UpdateRobotMember updates a robot member by member_id +func (u *DefaultUser) UpdateRobotMember(ctx context.Context, memberID string, robotData maps.MapStrAny) error { + // First, verify the member exists and is a robot + existingMember, err := u.GetMemberByMemberID(ctx, memberID) + if err != nil { + return fmt.Errorf("failed to get member: %w", err) + } + + // Verify this is a robot member + memberType, exists := existingMember["member_type"] + if !exists || memberType != "robot" { + return fmt.Errorf("member %s is not a robot member", memberID) + } + + // Check if robot_email already exists globally (if updating robot_email) + if robotEmail, exists := robotData["robot_email"]; exists && robotEmail != nil && robotEmail != "" { + robotEmailStr := fmt.Sprintf("%v", robotEmail) + + // Only check uniqueness if the email is actually changing + currentEmail, _ := existingMember["robot_email"] + if currentEmail != robotEmailStr { + m := model.Select(u.memberModel) + existingMembers, err := m.Get(model.QueryParam{ + Select: []interface{}{"id", "member_id"}, + Wheres: []model.QueryWhere{ + {Column: "robot_email", Value: robotEmailStr}, + }, + Limit: 1, + }) + if err != nil { + return fmt.Errorf("failed to check robot_email uniqueness: %w", err) + } + if len(existingMembers) > 0 { + // Check if it's not the same member + existingMemberID, _ := existingMembers[0]["member_id"] + if existingMemberID != memberID { + return fmt.Errorf("robot_email %s already exists", robotEmailStr) + } + } + } + } + + memberData := maps.MapStrAny{} + + // Copy shared profile fields (used by both users and robots) + profileFields := []string{ + "display_name", "bio", "avatar", "email", + } + for _, field := range profileFields { + if value, exists := robotData[field]; exists { + memberData[field] = value + } + } + + // Copy robot-specific fields + robotFields := []string{ + "role_id", "system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules", + "robot_config", "agents", "mcp_servers", + "language_model", "cost_limit", "autonomous_mode", "robot_status", + "notes", "metadata", "status", + "__yao_updated_by", "__yao_team_id", "__yao_tenant_id", + } + + for _, field := range robotFields { + if value, exists := robotData[field]; exists { + memberData[field] = value + } + } + + // Skip update if no valid fields to update + if len(memberData) == 0 { + return nil + } + + return u.UpdateMemberByMemberID(ctx, memberID, memberData) +} + // AddMember adds a user to a team (invitation-based) func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error) { // Check if member already exists diff --git a/openapi/oauth/providers/user/member_test.go b/openapi/oauth/providers/user/member_test.go index 6d5b1491..8090102b 100644 --- a/openapi/oauth/providers/user/member_test.go +++ b/openapi/oauth/providers/user/member_test.go @@ -1240,6 +1240,233 @@ func TestMemberExistsByRobotEmail(t *testing.T) { }) } +func TestUpdateRobotMember(t *testing.T) { + prepare(t) + defer clean() + + ctx := context.Background() + + // Use UUID to ensure unique identifiers + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] + + // Create test user (team owner) + ownerUser := createTestUser(ctx, t, "owner"+testUUID) + regularUser := createTestUser(ctx, t, "user"+testUUID) + + // Create test team + teamMap := maps.MapStrAny{ + "name": "Update Robot Test Team " + testUUID, + "display_name": "Update Robot Test " + testUUID, + "description": "A test team for robot update testing", + "owner_id": ownerUser, + "status": "active", + } + + teamID, err := testProvider.CreateTeam(ctx, teamMap) + assert.NoError(t, err) + + var robotMemberID string + var regularMemberID string + + // Create a robot member + t.Run("Setup_CreateRobotMember", func(t *testing.T) { + robotData := maps.MapStrAny{ + "display_name": "TestBot" + testUUID, + "bio": "Original bio", + "role_id": "bot", + "autonomous_mode": false, + "robot_status": "idle", + "system_prompt": "Original prompt", + "language_model": "gpt-3.5-turbo", + "cost_limit": 50.00, + "robot_email": "testbot" + testUUID + "@robot.example.com", + } + + memberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData) + assert.NoError(t, err) + assert.NotEmpty(t, memberID) + robotMemberID = memberID + }) + + // Create a regular user member for testing + t.Run("Setup_CreateRegularMember", func(t *testing.T) { + memberData := maps.MapStrAny{ + "team_id": teamID, + "user_id": regularUser, + "member_type": "user", + "role_id": "user", + "status": "active", + } + + memberID, err := testProvider.CreateMember(ctx, memberData) + assert.NoError(t, err) + assert.NotEmpty(t, memberID) + regularMemberID = memberID + }) + + // Test successful update of robot member + t.Run("UpdateRobotMember_Success", func(t *testing.T) { + updateData := maps.MapStrAny{ + "display_name": "UpdatedBot" + testUUID, + "bio": "Updated bio", + "autonomous_mode": true, + "robot_status": "working", + "system_prompt": "Updated prompt", + "language_model": "gpt-4", + "cost_limit": 100.00, + } + + err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData) + assert.NoError(t, err) + + // Verify updates + member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID) + assert.NoError(t, err) + assert.Equal(t, "UpdatedBot"+testUUID, member["display_name"]) + assert.Equal(t, "Updated bio", member["bio"]) + assert.Equal(t, "working", member["robot_status"]) + assert.Equal(t, "Updated prompt", member["system_prompt"]) + assert.Equal(t, "gpt-4", member["language_model"]) + }) + + // Test updating robot_email + t.Run("UpdateRobotMember_UpdateEmail", func(t *testing.T) { + newEmail := "updated-testbot" + testUUID + "@robot.example.com" + updateData := maps.MapStrAny{ + "robot_email": newEmail, + } + + err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData) + assert.NoError(t, err) + + // Verify update + member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID) + assert.NoError(t, err) + assert.Equal(t, newEmail, member["robot_email"]) + }) + + // Test updating robot configuration fields + t.Run("UpdateRobotMember_UpdateConfiguration", func(t *testing.T) { + updateData := maps.MapStrAny{ + "authorized_senders": []string{ + "admin@example.com", + "manager@example.com", + }, + "email_filter_rules": []string{ + ".*@example\\.com$", + ".*@test\\.com$", + }, + "robot_config": map[string]interface{}{ + "max_tokens": 2000, + "temperature": 0.7, + }, + "agents": []string{ + "agent1", + "agent2", + }, + "mcp_servers": []string{ + "mcp://server1", + "mcp://server2", + }, + } + + err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData) + assert.NoError(t, err) + + // Verify update + member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID) + assert.NoError(t, err) + assert.NotNil(t, member["authorized_senders"]) + assert.NotNil(t, member["email_filter_rules"]) + assert.NotNil(t, member["robot_config"]) + }) + + // Test error when trying to update non-robot member + t.Run("UpdateRobotMember_NotRobotMember", func(t *testing.T) { + updateData := maps.MapStrAny{ + "display_name": "Should Fail", + } + + err := testProvider.UpdateRobotMember(ctx, regularMemberID, updateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a robot member") + }) + + // Test error when member_id doesn't exist + t.Run("UpdateRobotMember_NotFound", func(t *testing.T) { + updateData := maps.MapStrAny{ + "display_name": "Should Fail", + } + + err := testProvider.UpdateRobotMember(ctx, "non-existent-member-id", updateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get member") + }) + + // Test robot_email uniqueness validation during update + t.Run("UpdateRobotMember_EmailUniqueness", func(t *testing.T) { + // Create another robot with a different email + anotherRobotData := maps.MapStrAny{ + "display_name": "AnotherBot" + testUUID, + "role_id": "bot", + "robot_email": "anotherbot" + testUUID + "@robot.example.com", + } + + _, err := testProvider.CreateRobotMember(ctx, teamID, anotherRobotData) + assert.NoError(t, err) + + // Try to update the first robot's email to match the second robot's email + updateData := maps.MapStrAny{ + "robot_email": "anotherbot" + testUUID + "@robot.example.com", + } + + err = testProvider.UpdateRobotMember(ctx, robotMemberID, updateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) + + // Test updating with same email (should succeed - no actual change) + t.Run("UpdateRobotMember_SameEmail", func(t *testing.T) { + // Get current email + member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID) + assert.NoError(t, err) + currentEmail := member["robot_email"] + + // Update with same email + updateData := maps.MapStrAny{ + "robot_email": currentEmail, + } + + err = testProvider.UpdateRobotMember(ctx, robotMemberID, updateData) + assert.NoError(t, err) + }) + + // Test update with empty data (should not error) + t.Run("UpdateRobotMember_EmptyData", func(t *testing.T) { + err := testProvider.UpdateRobotMember(ctx, robotMemberID, maps.MapStrAny{}) + assert.NoError(t, err) // Should not error, just do nothing + }) + + // Test updating status + t.Run("UpdateRobotMember_UpdateStatus", func(t *testing.T) { + updateData := maps.MapStrAny{ + "status": "inactive", + } + + err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData) + assert.NoError(t, err) + + // Verify update + member, err := testProvider.GetMemberByMemberID(ctx, robotMemberID) + assert.NoError(t, err) + assert.Equal(t, "inactive", member["status"]) + + // Restore to active + err = testProvider.UpdateRobotMember(ctx, robotMemberID, maps.MapStrAny{"status": "active"}) + assert.NoError(t, err) + }) +} + func TestRobotEmailUniqueness(t *testing.T) { prepare(t) defer clean() diff --git a/openapi/oauth/types/interfaces.go b/openapi/oauth/types/interfaces.go index c79962f9..bcee1859 100644 --- a/openapi/oauth/types/interfaces.go +++ b/openapi/oauth/types/interfaces.go @@ -317,6 +317,7 @@ type UserProvider interface { // Robot Member Operations CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error) + UpdateRobotMember(ctx context.Context, memberID string, robotData maps.MapStrAny) error UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error GetActiveRobotMembers(ctx context.Context) ([]maps.MapStr, error) diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go index f3bd2f63..7afbc55e 100644 --- a/openapi/tests/user/member_test.go +++ b/openapi/tests/user/member_test.go @@ -1555,4 +1555,416 @@ func TestMemberCheckRobotEmail(t *testing.T) { } } +// TestMemberUpdateRobot tests the PUT /user/teams/:team_id/members/robots/:member_id endpoint +func TestMemberUpdateRobot(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 Update Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token with root permissions (required for robot operations) + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Use UUID to ensure unique test data + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] + + // Create a test team + createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Robot Update Test Team "+testUUID) + teamID := getTeamID(createdTeam) + + // Helper function to create a robot member for testing + createTestRobot := func(suffix string) (string, string) { + robotEmail := fmt.Sprintf("test-robot-%s-%s@robot.test.com", testUUID, suffix) + robotBody := map[string]interface{}{ + "name": "Test Robot " + suffix, + "robot_email": robotEmail, + "email": fmt.Sprintf("display-%s-%s@test.com", testUUID, suffix), + "role": "member", + "prompt": "Original prompt for " + suffix, + "llm": "gpt-3.5-turbo", + "autonomous_mode": "disabled", + "cost_limit": 50.0, + } + robotBodyBytes, _ := json.Marshal(robotBody) + robotReq, _ := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members/robots", bytes.NewBuffer(robotBodyBytes)) + robotReq.Header.Set("Content-Type", "application/json") + robotReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + client := &http.Client{} + robotResp, err := client.Do(robotReq) + assert.NoError(t, err) + if robotResp != nil { + defer robotResp.Body.Close() + assert.Equal(t, 201, robotResp.StatusCode, "Should create robot member successfully") + body, _ := io.ReadAll(robotResp.Body) + var response map[string]interface{} + json.Unmarshal(body, &response) + return toString(response["member_id"]), robotEmail + } + return "", "" + } + + testCases := []struct { + name string + setupFunc func() (string, string) // Returns (memberID, originalRobotEmail) + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + validateFn func(*testing.T, string) // Optional validation function with memberID + }{ + { + "update robot without authentication", + func() (string, string) { return createTestRobot("1") }, + map[string]interface{}{ + "name": "Updated Name", + }, + map[string]string{}, + 401, + "should require authentication", + nil, + }, + { + "update robot with all fields", + func() (string, string) { return createTestRobot("2") }, + map[string]interface{}{ + "name": "Updated Robot Full", + "email": fmt.Sprintf("updated-display-%s@test.com", testUUID), + "robot_email": fmt.Sprintf("updated-robot-%s@robot.test.com", testUUID), + "bio": "Updated comprehensive description", + "role": "admin", + "report_to": tokenInfo.UserID, + "prompt": "Updated system prompt", + "llm": "gpt-4", + "agents": []string{"agent1", "agent2"}, + "mcp_tools": []string{"tool1", "tool2"}, + "authorized_senders": []string{"admin@test.com"}, + "email_filter_rules": []string{".*@test\\.com$"}, + "autonomous_mode": "enabled", + "cost_limit": 100.0, + "status": "active", + "robot_status": "working", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update robot with all fields successfully", + func(t *testing.T, memberID string) { + // Verify the update + getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + getReq, _ := http.NewRequest("GET", getMemberURL, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + client := &http.Client{} + getResp, err := client.Do(getReq) + assert.NoError(t, err) + if getResp != nil { + defer getResp.Body.Close() + if getResp.StatusCode == 200 { + var member map[string]interface{} + body, _ := io.ReadAll(getResp.Body) + json.Unmarshal(body, &member) + assert.Equal(t, "Updated Robot Full", member["display_name"]) + assert.Equal(t, "Updated system prompt", member["system_prompt"]) + assert.Equal(t, "gpt-4", member["language_model"]) + } + } + }, + }, + { + "update robot with partial fields", + func() (string, string) { return createTestRobot("3") }, + map[string]interface{}{ + "name": "Partially Updated Robot", + "prompt": "Partially updated prompt", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update robot with partial fields", + func(t *testing.T, memberID string) { + getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + getReq, _ := http.NewRequest("GET", getMemberURL, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + client := &http.Client{} + getResp, err := client.Do(getReq) + assert.NoError(t, err) + if getResp != nil { + defer getResp.Body.Close() + if getResp.StatusCode == 200 { + var member map[string]interface{} + body, _ := io.ReadAll(getResp.Body) + json.Unmarshal(body, &member) + assert.Equal(t, "Partially Updated Robot", member["display_name"]) + assert.Equal(t, "Partially updated prompt", member["system_prompt"]) + // Original fields should remain + assert.Equal(t, "gpt-3.5-turbo", member["language_model"]) + } + } + }, + }, + { + "update robot_email to new unique email", + func() (string, string) { return createTestRobot("4") }, + map[string]interface{}{ + "robot_email": fmt.Sprintf("new-unique-%s@robot.test.com", testUUID), + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update robot_email to new unique email", + func(t *testing.T, memberID string) { + getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + getReq, _ := http.NewRequest("GET", getMemberURL, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + client := &http.Client{} + getResp, err := client.Do(getReq) + assert.NoError(t, err) + if getResp != nil { + defer getResp.Body.Close() + if getResp.StatusCode == 200 { + var member map[string]interface{} + body, _ := io.ReadAll(getResp.Body) + json.Unmarshal(body, &member) + assert.Equal(t, fmt.Sprintf("new-unique-%s@robot.test.com", testUUID), member["robot_email"]) + } + } + }, + }, + { + "update robot_email to duplicate email", + func() (string, string) { + // Create two robots + memberID1, email1 := createTestRobot("5a") + _, _ = createTestRobot("5b") + return memberID1, email1 + }, + map[string]interface{}{ + "robot_email": fmt.Sprintf("test-robot-%s-5b@robot.test.com", testUUID), + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 409, + "should reject duplicate robot_email", + nil, + }, + { + "update autonomous_mode variations", + func() (string, string) { return createTestRobot("6") }, + map[string]interface{}{ + "autonomous_mode": "1", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should handle autonomous_mode=1", + func(t *testing.T, memberID string) { + getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + getReq, _ := http.NewRequest("GET", getMemberURL, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + client := &http.Client{} + getResp, err := client.Do(getReq) + assert.NoError(t, err) + if getResp != nil { + defer getResp.Body.Close() + if getResp.StatusCode == 200 { + var member map[string]interface{} + body, _ := io.ReadAll(getResp.Body) + json.Unmarshal(body, &member) + // autonomous_mode should be enabled + autonomousMode := member["autonomous_mode"] + assert.True(t, autonomousMode == true || autonomousMode == float64(1) || autonomousMode == int64(1)) + } + } + }, + }, + { + "update robot status", + func() (string, string) { return createTestRobot("7") }, + map[string]interface{}{ + "status": "inactive", + "robot_status": "error", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update robot status fields", + func(t *testing.T, memberID string) { + getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID + getReq, _ := http.NewRequest("GET", getMemberURL, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + client := &http.Client{} + getResp, err := client.Do(getReq) + assert.NoError(t, err) + if getResp != nil { + defer getResp.Body.Close() + if getResp.StatusCode == 200 { + var member map[string]interface{} + body, _ := io.ReadAll(getResp.Body) + json.Unmarshal(body, &member) + assert.Equal(t, "inactive", member["status"]) + assert.Equal(t, "error", member["robot_status"]) + } + } + }, + }, + { + "update array fields", + func() (string, string) { return createTestRobot("8") }, + map[string]interface{}{ + "agents": []string{"new-agent1", "new-agent2", "new-agent3"}, + "mcp_tools": []string{"new-tool1"}, + "authorized_senders": []string{"sender1@test.com", "sender2@test.com"}, + "email_filter_rules": []string{".*@allowed\\.com$"}, + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update array fields", + nil, + }, + { + "update non-existent robot", + func() (string, string) { return "non-existent-member-id", "" }, + map[string]interface{}{ + "name": "Should Fail", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent robot", + nil, + }, + { + "update regular user member as robot", + func() (string, string) { + // Create a regular user member instead of robot + memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "regular-user-"+testUUID) + return memberID, "" + }, + map[string]interface{}{ + "name": "Should Fail", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should reject updating non-robot member", + nil, + }, + { + "update robot in non-existent team", + func() (string, string) { return createTestRobot("10") }, + map[string]interface{}{ + "name": "Should Fail", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + nil, + }, + { + "update robot with invalid JSON", + func() (string, string) { return createTestRobot("11") }, + nil, // Will send invalid JSON + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should handle invalid JSON", + nil, + }, + { + "update robot with empty body", + func() (string, string) { return createTestRobot("12") }, + map[string]interface{}{}, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should handle empty update (no-op)", + nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + memberID, _ := tc.setupFunc() + + // Use non-existent team ID for the specific test case + targetTeamID := teamID + if tc.name == "update robot in non-existent team" { + targetTeamID = "non-existent-team-id" + } + + requestURL := serverURL + baseURL + "/user/teams/" + targetTeamID + "/members/robots/" + memberID + + var req *http.Request + var err error + + if tc.body == nil { + // Send invalid JSON for invalid JSON test case + req, err = http.NewRequest("PUT", requestURL, bytes.NewBufferString("invalid json")) + } else { + bodyBytes, _ := json.Marshal(tc.body) + req, err = http.NewRequest("PUT", 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 == 200 { + // Parse response as success message + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + assert.Contains(t, response, "message", "Should have success message") + assert.Equal(t, "Robot member updated successfully", response["message"], "Should have correct success message") + + // Run custom validation if provided + if tc.validateFn != nil { + tc.validateFn(t, memberID) + } + } + + t.Logf("Robot member update test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + // Note: getTeamID function is already defined in team_test.go diff --git a/openapi/user/member.go b/openapi/user/member.go index 47e201c6..7743f284 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -352,6 +352,142 @@ func GinMemberCreateRobot(c *gin.Context) { response.RespondWithSuccess(c, http.StatusCreated, gin.H{"member_id": memberID}) } +// GinMemberUpdateRobot handles PUT /teams/:team_id/members/robots/:member_id - Update robot member +func GinMemberUpdateRobot(c *gin.Context) { + // Get authorized user info + authInfo := authorized.GetInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + teamID := c.Param("id") + memberID := c.Param("member_id") + if teamID == "" || memberID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID and Member ID are required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse request body + var req UpdateRobotMemberRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Prepare update data + updateData := maps.MapStrAny{} + + // Add fields if provided + if req.Name != "" { + updateData["display_name"] = req.Name + } + if req.Email != "" { + updateData["email"] = req.Email + } + if req.RobotEmail != "" { + updateData["robot_email"] = req.RobotEmail + } + if req.Bio != "" { + updateData["bio"] = req.Bio + } + if req.RoleID != "" { + updateData["role_id"] = req.RoleID + } + if req.ManagerID != "" { + updateData["manager_id"] = req.ManagerID + } + if req.SystemPrompt != "" { + updateData["system_prompt"] = req.SystemPrompt + } + if req.LanguageModel != "" { + updateData["language_model"] = req.LanguageModel + } + if req.Status != "" { + updateData["status"] = req.Status + } + if req.RobotStatus != "" { + updateData["robot_status"] = req.RobotStatus + } + if req.AutonomousMode != "" { + updateData["autonomous_mode"] = toBool(req.AutonomousMode) + } + if req.CostLimit > 0 { + updateData["cost_limit"] = req.CostLimit + } + + // Handle array fields (they can be empty arrays) + if req.AuthorizedSenders != nil { + updateData["authorized_senders"] = req.AuthorizedSenders + } + if req.EmailFilterRules != nil { + updateData["email_filter_rules"] = req.EmailFilterRules + } + if req.Agents != nil { + updateData["agents"] = req.Agents + } + if req.MCPServers != nil { + updateData["mcp_servers"] = req.MCPServers + } + + // Wrap with update scope for permission tracking + robotData := authInfo.WithUpdateScope(updateData) + + // Call business logic + err := memberUpdateRobot(c.Request.Context(), authInfo.UserID, teamID, memberID, robotData) + if err != nil { + log.Error("Failed to update robot member: %v", err) + // Check error type for appropriate response + if strings.Contains(err.Error(), "not found") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else if strings.Contains(err.Error(), "access denied") { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + } else if strings.Contains(err.Error(), "not a robot member") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + } else if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusConflict, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to update robot member", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Return success + response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Robot member updated successfully"}) +} + // GinMemberUpdate handles PUT /teams/:team_id/members/:member_id - Update team member func GinMemberUpdate(c *gin.Context) { // Get authorized user info @@ -921,6 +1057,34 @@ func memberCreateRobot(ctx context.Context, userID, teamID string, robotData map return memberID, nil } +// memberUpdateRobot handles the business logic for updating a robot member +func memberUpdateRobot(ctx context.Context, userID, teamID, memberID string, robotData maps.MapStrAny) error { + // Check if user has access to the team (write permission: owner only) + isOwner, _, err := checkTeamAccess(ctx, teamID, userID) + if err != nil { + return err + } + + // Only allow access if user is owner + if !isOwner { + return fmt.Errorf("access denied: only team owner can update robot members") + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + // Use UpdateRobotMember method which handles robot-specific logic and validation + err = provider.UpdateRobotMember(ctx, memberID, robotData) + if err != nil { + return fmt.Errorf("failed to update robot member: %w", err) + } + + return nil +} + // memberUpdate handles the business logic for updating a team member func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateData maps.MapStrAny) error { // Check if user has access to the team (write permission: owner only) diff --git a/openapi/user/types.go b/openapi/user/types.go index 5f11815e..e1c05fcd 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -460,6 +460,26 @@ type CreateRobotMemberRequest struct { CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD } +// UpdateRobotMemberRequest represents the request to update a robot member +type UpdateRobotMemberRequest struct { + Name string `json:"name,omitempty"` // Display name + Email string `json:"email,omitempty"` // Email address (optional, for display only) + RobotEmail string `json:"robot_email,omitempty"` // Robot's globally unique email address + AuthorizedSenders []string `json:"authorized_senders,omitempty"` // Whitelist of emails authorized to send commands + EmailFilterRules []string `json:"email_filter_rules,omitempty"` // Email filtering rules (supports regex patterns) + Bio string `json:"bio,omitempty"` // Bio/description + RoleID string `json:"role,omitempty"` // Role ID + ManagerID string `json:"report_to,omitempty"` // Direct manager user ID + SystemPrompt string `json:"prompt,omitempty"` // 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 + Status string `json:"status,omitempty"` // Status: active, inactive + RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle, working, error +} + // MemberListRequest represents the request to list team members with advanced filtering type MemberListRequest struct { // Pagination diff --git a/openapi/user/user.go b/openapi/user/user.go index 83eba457..28b74816 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -136,6 +136,7 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { team.GET("/:id/members", GinMemberList) // GET /api/user/teams/:id/members - List team members team.GET("/:id/members/check-robot-email", GinMemberCheckRobotEmail) // GET /api/user/teams/:id/members/check-robot-email?robot_email=xxx - Check if robot email exists globally team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /api/user/teams/:id/members/robots - Add robot member + team.PUT("/:id/members/robots/:member_id", GinMemberUpdateRobot) // PUT /api/user/teams/:id/members/robots/:member_id - Update robot member team.GET("/:id/members/:member_id", GinMemberGet) // GET /api/user/teams/:id/members/:member_id - Get member details team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member