From 5a894c11ab3119013e051f91d39b81cd533a397c Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 23 Sep 2025 10:22:52 +0800 Subject: [PATCH] Add authorized info handling and session ID retrieval in OAuth guard - Introduced methods to set and retrieve authorized information from the context, enhancing the OAuth guard functionality. - Added a new `AuthorizedInfo` type to encapsulate user-related data such as subject, client ID, user ID, and scope. - Implemented session ID retrieval from various sources (cookies, headers, query strings) to improve session management. - Updated test utilities to support the creation of test users and access tokens, ensuring comprehensive testing of OAuth functionalities. --- openapi/oauth/guard.go | 79 +++ openapi/oauth/types/types.go | 9 + openapi/tests/testutils/testutils.go | 69 ++- openapi/tests/user/team_test.go | 871 +++++++++++++++++++++++++++ openapi/user/team.go | 844 ++++++++++++++++++++++++++ openapi/user/types.go | 56 +- openapi/user/user.go | 22 +- openapi/user/utils.go | 149 +++++ 8 files changed, 2061 insertions(+), 38 deletions(-) create mode 100644 openapi/tests/user/team_test.go create mode 100644 openapi/user/team.go create mode 100644 openapi/user/utils.go diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index c0414ed8..65aa6251 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -33,6 +33,53 @@ func (s *Service) Guard(c *gin.Context) { if claims.ExpiresAt.Before(time.Now()) { s.tryAutoRefreshToken(c, claims) } + + // Set Authorized Info + s.setAuthorizedInfo(c, claims) +} + +// GetAuthorizedInfo Get Authorized Info from context +func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo { + info := &types.AuthorizedInfo{} + + if subject, ok := c.Get("__subject"); ok { + info.Subject = subject.(string) + } + + if clientID, ok := c.Get("__client_id"); ok { + info.ClientID = clientID.(string) + } + + if userID, ok := c.Get("__user_id"); ok { + info.UserID = userID.(string) + } + + if scope, ok := c.Get("__scope"); ok { + info.Scope = scope.(string) + } + + return info +} + +// Set Authorized Info in context +func (s *Service) setAuthorizedInfo(c *gin.Context, claims *types.TokenClaims) { + sid := s.getSessionID(c) + + // Set __sid in context + if sid != "" { + c.Set("__sid", sid) + } + + // Set __userID in context + userID, err := s.UserID(claims.ClientID, claims.Subject) + if err == nil && userID != "" { + c.Set("__user_id", userID) + } + + // Set subject scope, client_id, user_id in context + c.Set("__subject", claims.Subject) + c.Set("__scope", claims.Scope) + c.Set("__client_id", claims.ClientID) } func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) { @@ -77,3 +124,35 @@ func (s *Service) getRefreshToken(c *gin.Context) string { } return strings.TrimPrefix(token, "Bearer ") } + +// Get Session ID from cookies, headers, or query string +func (s *Service) getSessionID(c *gin.Context) string { + + // 0. If has __sid in context, return it + sid, ok := c.Get("__sid") + if ok { + return sid.(string) + } + + // 1. Try to get Session ID from cookies first + if sid, err := c.Cookie("__Host-session_id"); err == nil && sid != "" { + return sid + } + + // 2. Try to get Session ID from X-Session-ID header + if sessionHeader := c.GetHeader("X-Session-ID"); sessionHeader != "" { + return sessionHeader + } + + // 3. Try to get Session ID from query string + if sessionQuery := c.Query("session_id"); sessionQuery != "" { + return sessionQuery + } + + // 4. Try alternative query parameter names + if sessionQuery := c.Query("sid"); sessionQuery != "" { + return sessionQuery + } + + return "" +} diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index 539b005f..ddd5b06a 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -574,6 +574,15 @@ type TokenClaims struct { JTI string `json:"jti,omitempty"` // JWT ID (for JWT tokens) } +// AuthorizedInfo represents authorized information +type AuthorizedInfo struct { + Subject string `json:"sub,omitempty"` // Subject identifier + ClientID string `json:"client_id"` // OAuth client ID + Scope string `json:"scope,omitempty"` // Access scope + SessionID string `json:"session_id,omitempty"` // Session ID + UserID string `json:"user_id,omitempty"` // User ID +} + // JWTClaims represents JWT-specific claims structure type JWTClaims struct { jwt.StandardClaims diff --git a/openapi/tests/testutils/testutils.go b/openapi/tests/testutils/testutils.go index 5b8628c6..8d39671d 100644 --- a/openapi/tests/testutils/testutils.go +++ b/openapi/tests/testutils/testutils.go @@ -16,6 +16,7 @@ import ( "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/kb" "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/test" ) @@ -618,6 +619,7 @@ type TokenInfo struct { ExpiresIn int Scope string ClientID string + UserID string } // ObtainAccessToken obtains an access token for testing OAuth endpoints that require authentication. @@ -630,19 +632,33 @@ func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirect t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.") } - // Step 1: Get authorization code with PKCE parameters - authInfo := ObtainAuthorizationCode(t, serverURL, clientID, redirectURI, scope) + // Step 1: Create a test user and set up fingerprint mapping + testUserID, subject := createTestUser(t, server, clientID) - // Step 2: Exchange authorization code for access token with PKCE code verifier - ctx := context.Background() - token, err := server.OAuth.Token(ctx, "authorization_code", authInfo.Code, clientID, authInfo.CodeVerifier) - if err != nil { - t.Fatalf("Failed to exchange authorization code for token: %v", err) + // Step 2: Create access token directly using the OAuth service + // This bypasses the authorization flow and creates a token for our test user + oauthService := oauth.OAuth + if oauthService == nil { + t.Fatal("Global OAuth service not initialized") } - // Verify we got a valid token - if token.AccessToken == "" { - t.Fatal("Token response missing access token") + accessToken, err := oauthService.MakeAccessToken(clientID, scope, subject, 3600) + if err != nil { + t.Fatalf("Failed to create access token: %v", err) + } + + refreshToken, err := oauthService.MakeRefreshToken(clientID, scope, subject, 7200) + if err != nil { + t.Fatalf("Failed to create refresh token: %v", err) + } + + // Create a synthetic token response + token := &types.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + ExpiresIn: 3600, + Scope: scope, } tokenInfo := &TokenInfo{ @@ -652,10 +668,11 @@ func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirect ExpiresIn: token.ExpiresIn, Scope: token.Scope, ClientID: clientID, + UserID: testUserID, // Include the test user ID } - t.Logf("Obtained access token: %s (type: %s, expires_in: %d)", - tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn) + t.Logf("Obtained access token: %s (type: %s, expires_in: %d, user_id: %s)", + tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn, tokenInfo.UserID) return tokenInfo } @@ -681,3 +698,31 @@ func generateCodeChallenge(codeVerifier string) string { // Base64 URL encode the hash without padding return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]) } + +// createTestUser creates a test user and sets up proper fingerprint mapping for OAuth authentication +func createTestUser(t *testing.T, server *openapi.OpenAPI, clientID string) (string, string) { + if server.OAuth == nil { + t.Fatal("OAuth service not initialized") + } + + // 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 + oauthService := oauth.OAuth + if oauthService == nil { + t.Fatal("Global OAuth service not initialized") + } + + // 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) + if err != nil { + t.Fatalf("Failed to create user subject: %v", err) + } + + t.Logf("Created test user: %s with subject: %s", testUserID, subject) + return testUserID, subject +} + diff --git a/openapi/tests/user/team_test.go b/openapi/tests/user/team_test.go new file mode 100644 index 00000000..14de55cf --- /dev/null +++ b/openapi/tests/user/team_test.go @@ -0,0 +1,871 @@ +package user_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestTeamList tests the GET /user/teams endpoint +func TestTeamList(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, "Team Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + testCases := []struct { + name string + endpoint string + headers map[string]string + expectCode int + expectMsg string + }{ + { + "list teams without authentication", + "/user/teams", + map[string]string{}, + 401, + "should require authentication", + }, + { + "list teams with valid token", + "/user/teams", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should return user teams", + }, + { + "list teams with pagination", + "/user/teams?page=1&pagesize=10", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should handle pagination parameters", + }, + { + "list teams with status filter", + "/user/teams?status=active", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should filter by status", + }, + { + "list teams with name search", + "/user/teams?name=test", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should search by name", + }, + { + "list teams with invalid pagesize", + "/user/teams?pagesize=1000", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should limit pagesize to maximum allowed", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + tc.endpoint + req, err := http.NewRequest("GET", requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // 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 pagination result + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + // Check pagination structure (consistent with other modules) + if data, ok := response["data"]; ok { + assert.IsType(t, []interface{}{}, data, "Should have data array") + } + if total, ok := response["total"]; ok { + assert.IsType(t, float64(0), total, "Should have total count") + } + if page, ok := response["page"]; ok { + assert.IsType(t, float64(0), page, "Should have page number") + } + if pagesize, ok := response["pagesize"]; ok { + assert.IsType(t, float64(0), pagesize, "Should have pagesize") + } + } + + t.Logf("Team list test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestTeamCreate tests the POST /user/teams endpoint +func TestTeamCreate(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, "Team Create Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + testCases := []struct { + name string + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + }{ + { + "create team without authentication", + map[string]interface{}{ + "name": "Test Team", + }, + map[string]string{}, + 401, + "should require authentication", + }, + { + "create team with valid data", + map[string]interface{}{ + "name": "Test Team", + "description": "A test team for unit testing", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should create team successfully", + }, + { + "create team with settings", + map[string]interface{}{ + "name": "Team with Settings", + "description": "Team with custom settings", + "settings": map[string]interface{}{ + "theme": "dark", + "visibility": "private", + }, + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 201, + "should create team with settings", + }, + { + "create team without name", + map[string]interface{}{ + "description": "Team without name", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require team name", + }, + { + "create team with empty name", + map[string]interface{}{ + "name": "", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 400, + "should require non-empty team name", + }, + { + "create team with invalid JSON", + 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" + + 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 team object + var team map[string]interface{} + err = json.Unmarshal(body, &team) + assert.NoError(t, err, "Should parse JSON response") + + // Verify team structure + assert.Contains(t, team, "id", "Should have team ID") + assert.Contains(t, team, "team_id", "Should have team_id") + assert.Contains(t, team, "name", "Should have team name") + assert.Contains(t, team, "owner_id", "Should have owner_id") + assert.Contains(t, team, "status", "Should have status") + assert.Contains(t, team, "created_at", "Should have created_at") + assert.Contains(t, team, "updated_at", "Should have updated_at") + + // Verify values + if tc.body != nil { + if name, ok := tc.body["name"]; ok { + assert.Equal(t, name, team["name"], "Should have correct team name") + } + if description, ok := tc.body["description"]; ok { + assert.Equal(t, description, team["description"], "Should have correct description") + } + } + } + + t.Logf("Team create test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestTeamGet tests the GET /user/teams/:team_id endpoint +func TestTeamGet(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, "Team Get Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // First create a team to test with + createTeamBody := map[string]interface{}{ + "name": "Get Test Team", + "description": "Team for testing get functionality", + "settings": map[string]interface{}{ + "theme": "light", + }, + } + + createReq := createTeamRequest(t, serverURL+baseURL+"/user/teams", createTeamBody, tokenInfo.AccessToken) + createResp, err := (&http.Client{}).Do(createReq) + assert.NoError(t, err, "Should create test team") + defer createResp.Body.Close() + + var createdTeam map[string]interface{} + if createResp.StatusCode == 201 { + createBody, _ := io.ReadAll(createResp.Body) + json.Unmarshal(createBody, &createdTeam) + } + + testCases := []struct { + name string + teamID string + headers map[string]string + expectCode int + expectMsg string + }{ + { + "get team without authentication", + getTeamID(createdTeam), + map[string]string{}, + 401, + "should require authentication", + }, + { + "get existing team", + getTeamID(createdTeam), + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should return team details", + }, + { + "get non-existent team", + "non-existent-team-id", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + { + "get team with empty team_id returns team list", + "", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should return team list when team_id is empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + endpoint := "/user/teams" + if tc.teamID != "" { + endpoint += "/" + tc.teamID + } + requestURL := serverURL + baseURL + endpoint + + req, err := http.NewRequest("GET", requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // 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 { + if tc.teamID == "" { + // Parse response as team list (pagination result) + var response map[string]interface{} + err = json.Unmarshal(body, &response) + assert.NoError(t, err, "Should parse JSON response") + + // Check pagination structure + assert.Contains(t, response, "data", "Should have data array") + assert.Contains(t, response, "total", "Should have total count") + assert.Contains(t, response, "page", "Should have page number") + assert.Contains(t, response, "pagesize", "Should have pagesize") + } else { + // Parse response as team detail object + var team map[string]interface{} + err = json.Unmarshal(body, &team) + assert.NoError(t, err, "Should parse JSON response") + + // Verify team detail structure + assert.Contains(t, team, "id", "Should have team ID") + assert.Contains(t, team, "team_id", "Should have team_id") + assert.Contains(t, team, "name", "Should have team name") + assert.Contains(t, team, "description", "Should have description") + assert.Contains(t, team, "owner_id", "Should have owner_id") + assert.Contains(t, team, "status", "Should have status") + assert.Contains(t, team, "settings", "Should have settings") + assert.Contains(t, team, "created_at", "Should have created_at") + assert.Contains(t, team, "updated_at", "Should have updated_at") + + // Verify values match created team + assert.Equal(t, "Get Test Team", team["name"], "Should have correct team name") + assert.Equal(t, "Team for testing get functionality", team["description"], "Should have correct description") + if settings, ok := team["settings"].(map[string]interface{}); ok { + assert.Equal(t, "light", settings["theme"], "Should have correct theme setting") + } + } + } + + t.Logf("Team get test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestTeamUpdate tests the PUT /user/teams/:team_id endpoint +func TestTeamUpdate(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, "Team Update Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a team to test updates + createTeamBody := map[string]interface{}{ + "name": "Update Test Team", + "description": "Team for testing update functionality", + } + + createReq := createTeamRequest(t, serverURL+baseURL+"/user/teams", createTeamBody, tokenInfo.AccessToken) + createResp, err := (&http.Client{}).Do(createReq) + assert.NoError(t, err, "Should create test team") + defer createResp.Body.Close() + + var createdTeam map[string]interface{} + if createResp.StatusCode == 201 { + createBody, _ := io.ReadAll(createResp.Body) + json.Unmarshal(createBody, &createdTeam) + } + + testCases := []struct { + name string + teamID string + body map[string]interface{} + headers map[string]string + expectCode int + expectMsg string + }{ + { + "update team without authentication", + getTeamID(createdTeam), + map[string]interface{}{ + "name": "Updated Name", + }, + map[string]string{}, + 401, + "should require authentication", + }, + { + "update team name", + getTeamID(createdTeam), + map[string]interface{}{ + "name": "Updated Team Name", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update team name", + }, + { + "update team description", + getTeamID(createdTeam), + map[string]interface{}{ + "description": "Updated description", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update team description", + }, + { + "update team settings", + getTeamID(createdTeam), + map[string]interface{}{ + "settings": map[string]interface{}{ + "theme": "dark", + "visibility": "public", + }, + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should update team settings", + }, + { + "update non-existent team", + "non-existent-team-id", + map[string]interface{}{ + "name": "Updated Name", + }, + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + { + "update team with invalid JSON", + getTeamID(createdTeam), + 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 + + 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 updated team object + var team map[string]interface{} + err = json.Unmarshal(body, &team) + assert.NoError(t, err, "Should parse JSON response") + + // Verify updated values + if tc.body != nil { + if name, ok := tc.body["name"]; ok { + assert.Equal(t, name, team["name"], "Should have updated team name") + } + if description, ok := tc.body["description"]; ok { + assert.Equal(t, description, team["description"], "Should have updated description") + } + if settings, ok := tc.body["settings"]; ok { + assert.Equal(t, settings, team["settings"], "Should have updated settings") + } + } + } + + t.Logf("Team update test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestTeamDelete tests the DELETE /user/teams/:team_id endpoint +func TestTeamDelete(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, "Team Delete Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + // Create teams to test deletion + createTeam := func(name string) map[string]interface{} { + createTeamBody := map[string]interface{}{ + "name": name, + "description": "Team for testing delete functionality", + } + + createReq := createTeamRequest(t, serverURL+baseURL+"/user/teams", createTeamBody, tokenInfo.AccessToken) + createResp, err := (&http.Client{}).Do(createReq) + assert.NoError(t, err, "Should create test team") + defer createResp.Body.Close() + + var createdTeam map[string]interface{} + if createResp.StatusCode == 201 { + createBody, _ := io.ReadAll(createResp.Body) + json.Unmarshal(createBody, &createdTeam) + } + return createdTeam + } + + testCases := []struct { + name string + teamID string + headers map[string]string + expectCode int + expectMsg string + }{ + { + "delete team without authentication", + getTeamID(createTeam("Delete Test Team 1")), + map[string]string{}, + 401, + "should require authentication", + }, + { + "delete existing team", + getTeamID(createTeam("Delete Test Team 2")), + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 200, + "should delete team successfully", + }, + { + "delete non-existent team", + "non-existent-team-id", + map[string]string{ + "Authorization": "Bearer " + tokenInfo.AccessToken, + }, + 404, + "should return not found for non-existent team", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + + req, err := http.NewRequest("DELETE", requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // 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, "Team deleted successfully", response["message"], "Should have correct success message") + } + + t.Logf("Team delete test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// TestTeamAuthenticationEdgeCases tests authentication and authorization edge cases +func TestTeamAuthenticationEdgeCases(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, "Team Auth Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, testClient.ClientID) + + // Obtain access token for authenticated requests + tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile") + + testCases := []struct { + name string + endpoint string + method string + headers map[string]string + expectCode int + expectMsg string + }{ + { + "invalid bearer token format", + "/user/teams", + "GET", + map[string]string{ + "Authorization": "Bearer invalid-token", + }, + 401, + "should reject invalid token", + }, + { + "missing bearer prefix", + "/user/teams", + "GET", + map[string]string{ + "Authorization": tokenInfo.AccessToken, + }, + 200, + "may accept token without Bearer prefix (implementation dependent)", + }, + { + "expired token simulation", + "/user/teams", + "GET", + map[string]string{ + "Authorization": "Bearer expired.token.here", + }, + 401, + "should reject expired token", + }, + { + "malformed authorization header", + "/user/teams", + "GET", + map[string]string{ + "Authorization": "Malformed", + }, + 401, + "should reject malformed header", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requestURL := serverURL + baseURL + tc.endpoint + + req, err := http.NewRequest(tc.method, requestURL, nil) + assert.NoError(t, err, "Should create HTTP request") + + // 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") + + t.Logf("Auth edge case test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body)) + } + }) + } +} + +// Helper functions + +// createTeamRequest creates a POST request for team creation +func createTeamRequest(t *testing.T, url string, body map[string]interface{}, accessToken string) *http.Request { + bodyBytes, err := json.Marshal(body) + assert.NoError(t, err, "Should marshal team creation body") + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + assert.NoError(t, err, "Should create team creation request") + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + return req +} + +// getTeamID extracts team_id from created team response +func getTeamID(team map[string]interface{}) string { + if team == nil { + return "" + } + if teamID, ok := team["team_id"].(string); ok { + return teamID + } + return "" +} diff --git a/openapi/user/team.go b/openapi/user/team.go new file mode 100644 index 00000000..65a97689 --- /dev/null +++ b/openapi/user/team.go @@ -0,0 +1,844 @@ +package user + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/kun/maps" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/providers/user" + "github.com/yaoapp/yao/openapi/response" +) + +// Team Management Handlers + +// GinTeamList handles GET /teams - Get user teams +func GinTeamList(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + // Parse pagination parameters + page := 1 + pagesize := 20 + + if p := c.Query("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + + if ps := c.Query("pagesize"); ps != "" { + if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { + pagesize = parsed + } + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Build query parameters + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "owner_id", Value: authInfo.UserID}, + }, + Orders: []model.QueryOrder{ + {Column: "created_at", Option: "desc"}, + }, + } + + // Add status filter if provided + if status := c.Query("status"); status != "" { + param.Wheres = append(param.Wheres, model.QueryWhere{ + Column: "status", + Value: status, + }) + } + + // Add name search if provided + if name := c.Query("name"); name != "" { + param.Wheres = append(param.Wheres, model.QueryWhere{ + Column: "name", + Value: "%" + name + "%", + OP: "like", + }) + } + + // Get paginated teams + result, err := provider.PaginateTeams(c.Request.Context(), param, page, pagesize) + if err != nil { + log.Error("Failed to get user teams: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to retrieve teams", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Return the paginated result directly (consistent with other modules) + c.JSON(http.StatusOK, result) +} + +// GinTeamGet handles GET /teams/:team_id - Get user team details +func GinTeamGet(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(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("team_id") + if teamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get team details + teamData, err := provider.GetTeamDetail(c.Request.Context(), teamID) + if err != nil { + log.Error("Failed to get team details: %v", err) + // Check if it's a "team not found" error + if err.Error() == "team not found" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to retrieve team details", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Check if user owns this team + ownerID := toString(teamData["owner_id"]) + if ownerID != authInfo.UserID { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Access denied: you don't own this team", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Convert to response format + team := mapToTeamDetailResponse(teamData) + c.JSON(http.StatusOK, team) +} + +// GinTeamCreate handles POST /teams - Create user team +func GinTeamCreate(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + // Parse request body + var req CreateTeamRequest + 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 + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Prepare team data + teamData := maps.MapStrAny{ + "name": req.Name, + "description": req.Description, + "owner_id": authInfo.UserID, + "status": "active", + "is_verified": false, + "created_at": time.Now(), + "updated_at": time.Now(), + } + + // Add settings if provided + if req.Settings != nil { + teamData["settings"] = req.Settings + } + + // Create team + teamID, err := provider.CreateTeam(c.Request.Context(), teamData) + if err != nil { + log.Error("Failed to create team: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to create team", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get the created team details + createdTeam, err := provider.GetTeamDetail(c.Request.Context(), teamID) + if err != nil { + log.Error("Failed to get created team details: %v", err) + // Return basic response if we can't get details + c.JSON(http.StatusCreated, gin.H{"team_id": teamID}) + return + } + + // Convert to response format + team := mapToTeamDetailResponse(createdTeam) + c.JSON(http.StatusCreated, team) +} + +// GinTeamUpdate handles PUT /teams/:team_id - Update user team +func GinTeamUpdate(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(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("team_id") + if teamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse request body + var req UpdateTeamRequest + 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 + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check if team exists and user owns it + teamData, err := provider.GetTeam(c.Request.Context(), teamID) + if err != nil { + log.Error("Failed to get team: %v", err) + // Check if it's a "team not found" error + if err.Error() == "team not found" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get team", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Check ownership + ownerID := toString(teamData["owner_id"]) + if ownerID != authInfo.UserID { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Access denied: you don't own this team", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Prepare update data + updateData := maps.MapStrAny{ + "updated_at": time.Now(), + } + + if req.Name != "" { + updateData["name"] = req.Name + } + if req.Description != "" { + updateData["description"] = req.Description + } + if req.Settings != nil { + updateData["settings"] = req.Settings + } + + // Update team + err = provider.UpdateTeam(c.Request.Context(), teamID, updateData) + if err != nil { + log.Error("Failed to update team: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to update team", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get updated team details + updatedTeam, err := provider.GetTeamDetail(c.Request.Context(), teamID) + if err != nil { + log.Error("Failed to get updated team details: %v", err) + c.JSON(http.StatusOK, gin.H{"message": "Team updated successfully"}) + return + } + + // Convert to response format + team := mapToTeamDetailResponse(updatedTeam) + c.JSON(http.StatusOK, team) +} + +// GinTeamDelete handles DELETE /teams/:team_id - Delete user team +func GinTeamDelete(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(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("team_id") + if teamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check if team exists and user owns it + teamData, err := provider.GetTeam(c.Request.Context(), teamID) + if err != nil { + log.Error("Failed to get team: %v", err) + // Check if it's a "team not found" error + if err.Error() == "team not found" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Team not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + } else { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get team", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + } + return + } + + // Check ownership + ownerID := toString(teamData["owner_id"]) + if ownerID != authInfo.UserID { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Access denied: you don't own this team", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Delete team + err = provider.DeleteTeam(c.Request.Context(), teamID) + if err != nil { + log.Error("Failed to delete team: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to delete team", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Team deleted successfully"}) +} + +// Yao Process Handlers (for Yao application calls) + +// ProcessTeamList user.team.list Team list processor +// Args[0] map: Query parameters {"status": "active", "name": "search", "page": 1, "pagesize": 20} +// Return: map: Paginated team list +func ProcessTeamList(process *process.Process) interface{} { + process.ValidateArgNums(1) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + // Parse query parameters + queryMap := process.ArgsMap(0) + + // Build query parameters + param := model.QueryParam{} + + // Add filters + if status, ok := queryMap["status"].(string); ok && status != "" { + param.Wheres = append(param.Wheres, model.QueryWhere{ + Column: "status", + Value: status, + }) + } + + if name, ok := queryMap["name"].(string); ok && name != "" { + param.Wheres = append(param.Wheres, model.QueryWhere{ + Column: "name", + Value: "%" + name + "%", + OP: "like", + }) + } + + // Parse pagination + page := 1 + pagesize := 20 + + if p, ok := queryMap["page"]; ok { + if pageInt, ok := p.(int); ok && pageInt > 0 { + page = pageInt + } + } + + if ps, ok := queryMap["pagesize"]; ok { + if pagesizeInt, ok := ps.(int); ok && pagesizeInt > 0 && pagesizeInt <= 100 { + pagesize = pagesizeInt + } + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + result, err := teamList(ctx, userIDStr, param, page, pagesize) + if err != nil { + exception.New("failed to list teams: %s", 500, err.Error()).Throw() + } + + return result +} + +// ProcessTeamGet user.team.get Team get processor +// Args[0] string: team_id +// Return: map: Team details +func ProcessTeamGet(process *process.Process) interface{} { + process.ValidateArgNums(1) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + if teamID == "" { + exception.New("team_id is required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + result, err := teamGet(ctx, userIDStr, teamID) + if err != nil { + exception.New("failed to get team: %s", 500, err.Error()).Throw() + } + + return result +} + +// ProcessTeamCreate user.team.create Team create processor +// Args[0] map: Team data {"name": "Team Name", "description": "Description", "settings": {...}} +// Return: map: {"team_id": "created_team_id"} +func ProcessTeamCreate(process *process.Process) interface{} { + process.ValidateArgNums(1) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamData := maps.MapStrAny(process.ArgsMap(0)) + + // Validate required fields + if _, ok := teamData["name"]; !ok { + exception.New("name is required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + teamID, err := teamCreate(ctx, userIDStr, teamData) + if err != nil { + exception.New("failed to create team: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "team_id": teamID, + } +} + +// ProcessTeamUpdate user.team.update Team update processor +// Args[0] string: team_id +// Args[1] map: Update data {"name": "New Name", "description": "New Description", "settings": {...}} +// Return: map: {"message": "success"} +func ProcessTeamUpdate(process *process.Process) interface{} { + process.ValidateArgNums(2) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + updateData := maps.MapStrAny(process.ArgsMap(1)) + + if teamID == "" { + exception.New("team_id is required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + err := teamUpdate(ctx, userIDStr, teamID, updateData) + if err != nil { + exception.New("failed to update team: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "message": "success", + } +} + +// ProcessTeamDelete user.team.delete Team delete processor +// Args[0] string: team_id +// Return: map: {"message": "success"} +func ProcessTeamDelete(process *process.Process) interface{} { + process.ValidateArgNums(1) + + // Get user_id from session + userIDStr := GetUserIDFromSession(process) + + teamID := process.ArgsString(0) + if teamID == "" { + exception.New("team_id is required", 400).Throw() + } + + // Get context + ctx := process.Context + if ctx == nil { + ctx = context.Background() + } + + // Call business logic + err := teamDelete(ctx, userIDStr, teamID) + if err != nil { + exception.New("failed to delete team: %s", 500, err.Error()).Throw() + } + + return map[string]interface{}{ + "message": "success", + } +} + +// Private Business Logic Functions (internal use only) + +// teamList handles the business logic for listing user teams +func teamList(ctx context.Context, userID string, param model.QueryParam, page, pagesize int) (maps.MapStr, error) { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return nil, fmt.Errorf("failed to get user provider: %w", err) + } + + // Add owner filter to query parameters + param.Wheres = append(param.Wheres, model.QueryWhere{ + Column: "owner_id", + Value: userID, + }) + + // Set default ordering if not provided + if len(param.Orders) == 0 { + param.Orders = []model.QueryOrder{ + {Column: "created_at", Option: "desc"}, + } + } + + // Get paginated teams + result, err := provider.PaginateTeams(ctx, param, page, pagesize) + if err != nil { + return nil, fmt.Errorf("failed to retrieve teams: %w", err) + } + + return result, nil +} + +// teamGet handles the business logic for getting a specific user team +func teamGet(ctx context.Context, userID, teamID string) (maps.MapStrAny, error) { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return nil, fmt.Errorf("failed to get user provider: %w", err) + } + + // Get team details + teamData, err := provider.GetTeamDetail(ctx, teamID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve team details: %w", err) + } + + // Check if user owns this team + ownerID := toString(teamData["owner_id"]) + if ownerID != userID { + return nil, fmt.Errorf("access denied: user does not own this team") + } + + return teamData, nil +} + +// teamCreate handles the business logic for creating a user team +func teamCreate(ctx context.Context, userID string, teamData maps.MapStrAny) (string, error) { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return "", fmt.Errorf("failed to get user provider: %w", err) + } + + // Set owner and default values + teamData["owner_id"] = userID + teamData["status"] = "active" + teamData["is_verified"] = false + teamData["created_at"] = time.Now() + teamData["updated_at"] = time.Now() + + // Create team + teamID, err := provider.CreateTeam(ctx, teamData) + if err != nil { + return "", fmt.Errorf("failed to create team: %w", err) + } + + return teamID, nil +} + +// teamUpdate handles the business logic for updating a user team +func teamUpdate(ctx context.Context, userID, teamID string, updateData maps.MapStrAny) error { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + // Check if team exists and user owns it + teamData, err := provider.GetTeam(ctx, teamID) + if err != nil { + return fmt.Errorf("team not found or access denied: %w", err) + } + + // Check ownership + ownerID := toString(teamData["owner_id"]) + if ownerID != userID { + return fmt.Errorf("access denied: user does not own this team") + } + + // Add updated_at timestamp + updateData["updated_at"] = time.Now() + + // Update team + err = provider.UpdateTeam(ctx, teamID, updateData) + if err != nil { + return fmt.Errorf("failed to update team: %w", err) + } + + return nil +} + +// teamDelete handles the business logic for deleting a user team +func teamDelete(ctx context.Context, userID, teamID string) error { + // Get user provider instance + provider, err := getUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + // Check if team exists and user owns it + teamData, err := provider.GetTeam(ctx, teamID) + if err != nil { + return fmt.Errorf("team not found or access denied: %w", err) + } + + // Check ownership + ownerID := toString(teamData["owner_id"]) + if ownerID != userID { + return fmt.Errorf("access denied: user does not own this team") + } + + // Delete team + err = provider.DeleteTeam(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to delete team: %w", err) + } + + return nil +} + +// Private Helper Functions (internal use only) + +// getUserProvider gets the user provider from the global OAuth service +func getUserProvider() (*user.DefaultUser, error) { + // Check if global OAuth service is initialized + if oauth.OAuth == nil { + return nil, fmt.Errorf("OAuth service not initialized") + } + + // Get user provider from OAuth service + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return nil, fmt.Errorf("failed to get user provider: %w", err) + } + + // Type assert to DefaultUser (this should be safe based on the OAuth service implementation) + if defaultUser, ok := userProvider.(*user.DefaultUser); ok { + return defaultUser, nil + } + + return nil, fmt.Errorf("user provider is not of type DefaultUser") +} + +// mapToTeamResponse converts a map to TeamResponse +func mapToTeamResponse(data maps.MapStr) TeamResponse { + team := TeamResponse{ + ID: toInt64(data["id"]), + TeamID: toString(data["team_id"]), + Name: toString(data["name"]), + Description: toString(data["description"]), + OwnerID: toString(data["owner_id"]), + Status: toString(data["status"]), + IsVerified: toBool(data["is_verified"]), + VerifiedBy: toString(data["verified_by"]), + VerifiedAt: toTimeString(data["verified_at"]), + CreatedAt: toTimeString(data["created_at"]), + UpdatedAt: toTimeString(data["updated_at"]), + } + + return team +} + +// mapToTeamDetailResponse converts a map to TeamDetailResponse +func mapToTeamDetailResponse(data maps.MapStr) TeamDetailResponse { + team := TeamDetailResponse{ + TeamResponse: mapToTeamResponse(data), + } + + // Add settings if available + if settings, ok := data["settings"]; ok { + if settingsMap, ok := settings.(map[string]interface{}); ok { + team.Settings = settingsMap + } + } + + return team +} diff --git a/openapi/user/types.go b/openapi/user/types.go index 9644afd1..1d6c25a9 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -201,26 +201,40 @@ const ( UserInfoSourceAccessToken = "access_token" // Extract user info from access token response ) -// toBool converts various types to boolean -// Supports: bool, int, int64, float64, string -// Returns false for nil or unsupported types -func toBool(v interface{}) bool { - if v == nil { - return false - } +// ==== Team API Types ==== - switch val := v.(type) { - case bool: - return val - case int: - return val != 0 - case int64: - return val != 0 - case float64: - return val != 0 - case string: - return val == "true" || val == "1" - default: - return false - } +// TeamResponse represents a team in API responses +type TeamResponse struct { + ID int64 `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + OwnerID string `json:"owner_id"` + Status string `json:"status"` + IsVerified bool `json:"is_verified"` + VerifiedBy string `json:"verified_by,omitempty"` + VerifiedAt string `json:"verified_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// TeamDetailResponse represents detailed team information +type TeamDetailResponse struct { + TeamResponse + // Add additional fields that are only included in detailed responses + Settings map[string]interface{} `json:"settings,omitempty"` +} + +// CreateTeamRequest represents the request to create a team +type CreateTeamRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` +} + +// UpdateTeamRequest represents the request to update a team +type UpdateTeamRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` } diff --git a/openapi/user/user.go b/openapi/user/user.go index 4ec545d2..831d25b4 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -4,9 +4,21 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/process" "github.com/yaoapp/yao/openapi/oauth/types" ) +func init() { + // Register user process handlers + process.RegisterGroup("user", map[string]process.Handler{ + "team.list": ProcessTeamList, + "team.get": ProcessTeamGet, + "team.create": ProcessTeamCreate, + "team.update": ProcessTeamUpdate, + "team.delete": ProcessTeamDelete, + }) +} + // Attach attaches the signin handlers to the router func Attach(group *gin.RouterGroup, oauth types.OAuth) { @@ -43,11 +55,11 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { team.Use(oauth.Guard) // Team CRUD - team.GET("/", placeholder) // Get user teams - team.GET("/:team_id", placeholder) // Get user team details - team.POST("/", placeholder) // Create user team - team.PUT("/:team_id", placeholder) // Update user team - team.DELETE("/:team_id", placeholder) // Delete user team + team.GET("/", GinTeamList) // Get user teams + team.GET("/:team_id", GinTeamGet) // Get user team details + team.POST("/", GinTeamCreate) // Create user team + team.PUT("/:team_id", GinTeamUpdate) // Update user team + team.DELETE("/:team_id", GinTeamDelete) // Delete user team // Member Management team.GET("/:team_id/members", placeholder) // Get user team members diff --git a/openapi/user/utils.go b/openapi/user/utils.go new file mode 100644 index 00000000..6b6d9709 --- /dev/null +++ b/openapi/user/utils.go @@ -0,0 +1,149 @@ +package user + +import ( + "fmt" + "strconv" + "time" + + "github.com/yaoapp/gou/process" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/kun/exception" +) + +// Session Utilities + +// GetUserIDFromSession gets the current user ID from session +// Returns the user ID string or throws an exception if not authenticated +func GetUserIDFromSession(process *process.Process) string { + sessionData, err := session.Global().ID(process.Sid).Get("__user_id") + if err != nil || sessionData == nil { + exception.New("user not authenticated", 401).Throw() + } + + userIDStr, ok := sessionData.(string) + if !ok { + exception.New("invalid user_id in session", 401).Throw() + } + + return userIDStr +} + +// Type Conversion Utilities + +// toBool converts various types to boolean +// Supports: bool, int, int64, float64, string +// Returns false for nil or unsupported types +func toBool(v interface{}) bool { + if v == nil { + return false + } + + switch val := v.(type) { + case bool: + return val + case int: + return val != 0 + case int64: + return val != 0 + case float64: + return val != 0 + case string: + return val == "true" || val == "1" + default: + return false + } +} + +// toString converts various types to string +// Supports: string, int, int64, float64, bool +// Returns empty string for nil or unsupported types +func toString(v interface{}) string { + if v == nil { + return "" + } + + switch val := v.(type) { + case string: + return val + case int: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + case float64: + return fmt.Sprintf("%.0f", val) + case bool: + if val { + return "true" + } + return "false" + default: + return "" + } +} + +// toInt64 converts various types to int64 +// Supports: int, int64, float64, string +// Returns 0 for nil or unsupported types +func toInt64(v interface{}) int64 { + if v == nil { + return 0 + } + + switch val := v.(type) { + case int64: + return val + case int: + return int64(val) + case float64: + return int64(val) + case string: + if parsed, err := strconv.ParseInt(val, 10, 64); err == nil { + return parsed + } + return 0 + default: + return 0 + } +} + +// toTimeString converts various time types to RFC3339 string +// Supports: time.Time, string, int64 (unix timestamp) +// Returns empty string for nil or unsupported types +func toTimeString(v interface{}) string { + if v == nil { + return "" + } + + switch val := v.(type) { + case time.Time: + if val.IsZero() { + return "" + } + return val.Format(time.RFC3339) + case string: + // Try to parse as RFC3339 first + if t, err := time.Parse(time.RFC3339, val); err == nil { + return t.Format(time.RFC3339) + } + // Try to parse as other common formats + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000Z", + } + for _, format := range formats { + if t, err := time.Parse(format, val); err == nil { + return t.Format(time.RFC3339) + } + } + return val // Return as-is if can't parse + case int64: + // Assume unix timestamp + if val > 0 { + return time.Unix(val, 0).Format(time.RFC3339) + } + return "" + default: + return "" + } +}