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.
This commit is contained in:
Max 2025-09-23 10:22:52 +08:00
parent 774ebe5c7d
commit 5a894c11ab
8 changed files with 2061 additions and 38 deletions

View file

@ -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 ""
}

View file

@ -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

View file

@ -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
}

View file

@ -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 ""
}

844
openapi/user/team.go Normal file
View file

@ -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
}

View file

@ -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"`
}

View file

@ -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

149
openapi/user/utils.go Normal file
View file

@ -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 ""
}
}