Enhance invitation acceptance flow with invitation ID support

- Updated the AcceptInvitation method to require both invitation ID and token, improving the invitation acceptance process.
- Modified related tests to accommodate the new invitation ID parameter, ensuring comprehensive coverage of acceptance scenarios.
- Enhanced the invitation acceptance endpoint to validate invitation ID, providing clearer error handling for invalid or expired invitations.
- Refactored tests to include detailed scenarios for accepting invitations, including success and failure cases, ensuring robust testing of the invitation flow.
This commit is contained in:
Max 2025-10-14 11:58:20 +08:00
parent 0d83faeeca
commit 721f47c345
9 changed files with 652 additions and 38 deletions

View file

@ -238,12 +238,13 @@ func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID strin
}
// AcceptInvitation accepts a team invitation
func (u *DefaultUser) AcceptInvitation(ctx context.Context, invitationToken string) error {
// Find member by invitation token
func (u *DefaultUser) AcceptInvitation(ctx context.Context, invitationID string, invitationToken string) error {
// Find member by invitation_id and token
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id", "team_id", "user_id", "status", "invitation_expires_at"},
Wheres: []model.QueryWhere{
{Column: "invitation_id", Value: invitationID},
{Column: "invitation_token", Value: invitationToken},
{Column: "status", Value: "pending"},
},

View file

@ -243,6 +243,7 @@ func TestMemberInvitationFlow(t *testing.T) {
assert.NoError(t, err)
var invitationToken string
var invitationID string
// Test AddMember (invitation-based)
t.Run("AddMember", func(t *testing.T) {
@ -256,11 +257,13 @@ func TestMemberInvitationFlow(t *testing.T) {
assert.Equal(t, "pending", member["status"])
assert.Equal(t, ownerUser, member["invited_by"])
// Get invitation token for acceptance test
// Get invitation token and invitation_id for acceptance test
memberDetail, err := testProvider.GetMemberDetail(ctx, teamID, inviteeUser)
assert.NoError(t, err)
invitationToken = memberDetail["invitation_token"].(string)
assert.NotEmpty(t, invitationToken)
invitationID = memberDetail["invitation_id"].(string)
assert.NotEmpty(t, invitationID)
// Verify invitation expiry is set
assert.NotNil(t, memberDetail["invitation_expires_at"])
@ -275,7 +278,7 @@ func TestMemberInvitationFlow(t *testing.T) {
// Test AcceptInvitation
t.Run("AcceptInvitation", func(t *testing.T) {
err := testProvider.AcceptInvitation(ctx, invitationToken)
err := testProvider.AcceptInvitation(ctx, invitationID, invitationToken)
assert.NoError(t, err)
// Verify member status changed to active
@ -292,14 +295,14 @@ func TestMemberInvitationFlow(t *testing.T) {
// Test AcceptInvitation with invalid token
t.Run("AcceptInvitation_InvalidToken", func(t *testing.T) {
err := testProvider.AcceptInvitation(ctx, "invalid-token")
err := testProvider.AcceptInvitation(ctx, invitationID, "invalid-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invitation not found")
})
// Test AcceptInvitation with already accepted token
t.Run("AcceptInvitation_AlreadyAccepted", func(t *testing.T) {
err := testProvider.AcceptInvitation(ctx, invitationToken)
err := testProvider.AcceptInvitation(ctx, invitationID, invitationToken)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invitation not found")
})
@ -736,12 +739,18 @@ func TestMemberInvitationExpiry(t *testing.T) {
"invitation_expires_at": expiredTime, // Expired 2 hours ago
}
_, err = testProvider.CreateMember(ctx, memberData)
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the invitation_id
member, err := testProvider.GetMemberByID(ctx, memberID)
assert.NoError(t, err)
invitationID := member["invitation_id"].(string)
assert.NotEmpty(t, invitationID)
// Test AcceptInvitation with expired token
t.Run("AcceptInvitation_ExpiredToken", func(t *testing.T) {
err := testProvider.AcceptInvitation(ctx, "expired-token-"+testUUID)
err := testProvider.AcceptInvitation(ctx, invitationID, "expired-token-"+testUUID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invitation has expired")
})

View file

@ -372,14 +372,16 @@ func TestTeamMemberOperations(t *testing.T) {
// Test AcceptInvitation
t.Run("AcceptInvitation", func(t *testing.T) {
// First get the invitation token
// First get the invitation token and invitation_id
member, err := testProvider.GetMemberDetail(ctx, teamID, memberUser)
assert.NoError(t, err)
invitationToken := member["invitation_token"].(string)
assert.NotEmpty(t, invitationToken)
invitationID := member["invitation_id"].(string)
assert.NotEmpty(t, invitationID)
// Accept the invitation
err = testProvider.AcceptInvitation(ctx, invitationToken)
err = testProvider.AcceptInvitation(ctx, invitationID, invitationToken)
assert.NoError(t, err)
// Verify member status changed to active

View file

@ -308,7 +308,7 @@ type UserProvider interface {
// Member Invitation Management
AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (int64, error)
AcceptInvitation(ctx context.Context, invitationToken string) error
AcceptInvitation(ctx context.Context, invitationID string, invitationToken string) error
// Robot Member Operations
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (int64, error)

View file

@ -701,6 +701,107 @@ func generateCodeChallenge(codeVerifier string) string {
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
}
// ObtainTokenForUser creates a token for a specific user ID.
// This function creates the OAuth fingerprint mapping and issues tokens for the given userID.
//
// AI ASSISTANT INSTRUCTIONS:
// Use this function when you need to issue a token for a pre-existing user ID.
// This is useful for testing scenarios where user records already exist in the database.
//
// Usage pattern:
//
// func TestWithExistingUser(t *testing.T) {
// serverURL := Prepare(t)
// defer Clean()
//
// // Create user in database first
// userID := "user_123"
// // ... create user record in DB ...
//
// // Register a test client
// client := RegisterTestClient(t, "Test Client", []string{"https://localhost/callback"})
// defer CleanupTestClient(t, client.ClientID)
//
// // Obtain token for the existing user
// tokenInfo := ObtainTokenForUser(t, client.ClientID, client.ClientSecret, userID, "openid profile")
//
// // Now use tokenInfo.AccessToken to make authenticated requests
// }
//
// PARAMETERS:
// - t: The test instance for error reporting
// - clientID: The OAuth client ID (from RegisterTestClient)
// - clientSecret: The OAuth client secret (from RegisterTestClient)
// - userID: The user ID to issue the token for (must exist in database)
// - scope: The requested OAuth scope (e.g., "openid profile email")
//
// RETURN VALUE:
// Returns TokenInfo struct containing:
// - AccessToken: The access token for API calls
// - RefreshToken: The refresh token for token renewal
// - TokenType: The token type (usually "Bearer")
// - ExpiresIn: Token expiration time in seconds
// - Scope: The granted scope
// - ClientID: The client ID used to obtain the token
// - UserID: The user ID the token was issued for
//
// WHAT THIS FUNCTION DOES:
// 1. Creates a fingerprint mapping: clientID + subject -> userID
// 2. Issues access and refresh tokens for the subject
// 3. Returns all token information needed for authenticated API testing
//
// ERROR HANDLING:
// If token creation fails, the test will fail immediately with a descriptive error message.
func ObtainTokenForUser(t *testing.T, clientID, clientSecret, userID, scope string) *TokenInfo {
testMutex.RLock()
server := openapi.Server
testMutex.RUnlock()
if server == nil || server.OAuth == nil {
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
}
// Access the global OAuth service
oauthService := oauth.OAuth
if oauthService == nil {
t.Fatal("Global OAuth service not initialized")
}
// Create subject (fingerprint) for this user
// This sets up the fingerprint mapping: clientID:subject -> userID
subject, err := oauthService.Subject(clientID, userID)
if err != nil {
t.Fatalf("Failed to create user subject: %v", err)
}
t.Logf("Created fingerprint mapping: clientID=%s, userID=%s, subject=%s", clientID, userID, subject)
// Create access token
accessToken, err := oauthService.MakeAccessToken(clientID, scope, subject, 3600)
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Create refresh token
refreshToken, err := oauthService.MakeRefreshToken(clientID, scope, subject, 7200)
if err != nil {
t.Fatalf("Failed to create refresh token: %v", err)
}
tokenInfo := &TokenInfo{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: 3600,
Scope: scope,
ClientID: clientID,
UserID: userID,
}
t.Logf("Issued token for user %s (subject: %s)", userID, subject)
return tokenInfo
}
// 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 {

View file

@ -2,6 +2,7 @@ package user_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -13,6 +14,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/openapi/user"
)
@ -839,8 +841,367 @@ func TestInvitationDelete(t *testing.T) {
})
}
// TestInvitationAccept tests the POST /user/teams/invitations/:invitation_id/accept endpoint
func TestInvitationAccept(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Step 1: Create two users A and B in database
t.Logf("Step 1: Create users A and B")
userA := fmt.Sprintf("user_a_%s", testUUID)
userB := fmt.Sprintf("user_b_%s", testUUID)
// Create users and get actual user IDs returned by provider
actualUserA := createUserInDB(t, userA)
actualUserB := createUserInDB(t, userB)
// Use the actual user IDs returned by CreateUser
userA = actualUserA
userB = actualUserB
t.Logf(" - Created users: A=%s, B=%s", userA, userB)
// Step 2: Issue token for user A and create team
t.Logf("Step 2: Issue token for user A and create team")
clientA := testutils.RegisterTestClient(t, "User A Client "+testUUID, []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, clientA.ClientID)
tokenA := testutils.ObtainTokenForUser(t, clientA.ClientID, clientA.ClientSecret, userA, "openid profile")
teamID, invitationID := setupTeamAndInvitation(t, serverURL, baseURL, tokenA.AccessToken, userA, userB, testUUID)
t.Logf(" - Team created with ID: %s", teamID)
t.Logf(" - Invitation created with ID: %s", invitationID)
// Step 3: Issue token for user B
t.Logf("Step 3: Issue token for user B")
clientB := testutils.RegisterTestClient(t, "User B Client "+testUUID, []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, clientB.ClientID)
tokenB := testutils.ObtainTokenForUser(t, clientB.ClientID, clientB.ClientSecret, userB, "openid profile")
// Test successful accept invitation
t.Run("AcceptInvitation_Success", func(t *testing.T) {
// Get invitation details to retrieve token
getURL := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, teamID, invitationID)
getReq, err := http.NewRequest("GET", getURL, nil)
assert.NoError(t, err)
getReq.Header.Set("Authorization", "Bearer "+tokenA.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
defer getResp.Body.Close()
assert.Equal(t, http.StatusOK, getResp.StatusCode)
var invitation user.InvitationDetailResponse
err = json.NewDecoder(getResp.Body).Decode(&invitation)
assert.NoError(t, err)
invitationToken := invitation.InvitationToken
assert.NotEmpty(t, invitationToken)
// User B accepts the invitation
t.Logf(" - User B accepting invitation with token")
acceptData := map[string]interface{}{
"token": invitationToken,
}
jsonData, _ := json.Marshal(acceptData)
acceptURL := fmt.Sprintf("%s%s/user/teams/invitations/%s/accept", serverURL, baseURL, invitationID)
acceptReq, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq.Header.Set("Content-Type", "application/json")
acceptReq.Header.Set("Authorization", "Bearer "+tokenB.AccessToken)
acceptResp, err := client.Do(acceptReq)
assert.NoError(t, err)
defer acceptResp.Body.Close()
// Read response body first for better error message
var result map[string]interface{}
err = json.NewDecoder(acceptResp.Body).Decode(&result)
assert.NoError(t, err)
// Check status code and provide helpful error message if failed
if acceptResp.StatusCode != http.StatusOK {
t.Fatalf("Accept invitation failed: status=%d, body=%v", acceptResp.StatusCode, result)
}
// Check for standard LoginResponse fields
assert.Contains(t, result, "access_token")
assert.Contains(t, result, "refresh_token")
assert.Contains(t, result, "token_type")
assert.Contains(t, result, "expires_in")
assert.Contains(t, result, "user_id")
assert.Contains(t, result, "id_token")
// Verify user_id matches invitee
assert.Equal(t, userB, result["user_id"])
// Verify tokens are valid (non-empty)
assert.NotEmpty(t, result["access_token"])
assert.NotEmpty(t, result["refresh_token"])
assert.Equal(t, "Bearer", result["token_type"])
assert.Greater(t, int(result["expires_in"].(float64)), 0)
})
// Test accept invitation with invalid token
t.Run("AcceptInvitation_InvalidToken", func(t *testing.T) {
// Create new invitation for this test
_, invID := setupTeamAndInvitation(t, serverURL, baseURL, tokenA.AccessToken, userA, userB, testUUID+"_inv")
// Try to accept with invalid token
acceptData := map[string]interface{}{
"token": "invalid-token-12345",
}
jsonData, _ := json.Marshal(acceptData)
acceptURL := fmt.Sprintf("%s%s/user/teams/invitations/%s/accept", serverURL, baseURL, invID)
acceptReq, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq.Header.Set("Content-Type", "application/json")
acceptReq.Header.Set("Authorization", "Bearer "+tokenB.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
acceptResp, err := client.Do(acceptReq)
assert.NoError(t, err)
defer acceptResp.Body.Close()
assert.Equal(t, http.StatusNotFound, acceptResp.StatusCode)
})
// Test accept invitation with non-existent invitation_id
t.Run("AcceptInvitation_NonExistentInvitation", func(t *testing.T) {
acceptData := map[string]interface{}{
"token": "some-token",
}
jsonData, _ := json.Marshal(acceptData)
acceptURL := fmt.Sprintf("%s%s/user/teams/invitations/non-existent-inv/accept", serverURL, baseURL)
acceptReq, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq.Header.Set("Content-Type", "application/json")
acceptReq.Header.Set("Authorization", "Bearer "+tokenB.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
acceptResp, err := client.Do(acceptReq)
assert.NoError(t, err)
defer acceptResp.Body.Close()
assert.Equal(t, http.StatusNotFound, acceptResp.StatusCode)
})
// Test accept invitation without authentication
t.Run("AcceptInvitation_Unauthorized", func(t *testing.T) {
// Create new invitation for this test
_, invID := setupTeamAndInvitation(t, serverURL, baseURL, tokenA.AccessToken, userA, userB, testUUID+"_unauth")
acceptData := map[string]interface{}{
"token": "some-token",
}
jsonData, _ := json.Marshal(acceptData)
acceptURL := fmt.Sprintf("%s%s/user/teams/invitations/%s/accept", serverURL, baseURL, invID)
acceptReq, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq.Header.Set("Content-Type", "application/json")
// No Authorization header
client := &http.Client{Timeout: 10 * time.Second}
acceptResp, err := client.Do(acceptReq)
assert.NoError(t, err)
defer acceptResp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, acceptResp.StatusCode)
})
// Test accept invitation without token in request body
t.Run("AcceptInvitation_MissingToken", func(t *testing.T) {
// Create new invitation for this test
_, invID := setupTeamAndInvitation(t, serverURL, baseURL, tokenA.AccessToken, userA, userB, testUUID+"_missing")
acceptData := map[string]interface{}{
// Missing token field
}
jsonData, _ := json.Marshal(acceptData)
acceptURL := fmt.Sprintf("%s%s/user/teams/invitations/%s/accept", serverURL, baseURL, invID)
acceptReq, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq.Header.Set("Content-Type", "application/json")
acceptReq.Header.Set("Authorization", "Bearer "+tokenB.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
acceptResp, err := client.Do(acceptReq)
assert.NoError(t, err)
defer acceptResp.Body.Close()
assert.Equal(t, http.StatusBadRequest, acceptResp.StatusCode)
})
// Test accept already accepted invitation
t.Run("AcceptInvitation_AlreadyAccepted", func(t *testing.T) {
// Create new invitation for this test
tID, invID := setupTeamAndInvitation(t, serverURL, baseURL, tokenA.AccessToken, userA, userB, testUUID+"_accepted")
// Get invitation token
getURL := fmt.Sprintf("%s%s/user/teams/%s/invitations/%s", serverURL, baseURL, tID, invID)
getReq, err := http.NewRequest("GET", getURL, nil)
assert.NoError(t, err)
getReq.Header.Set("Authorization", "Bearer "+tokenA.AccessToken)
client := &http.Client{Timeout: 10 * time.Second}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
defer getResp.Body.Close()
var invitation user.InvitationDetailResponse
json.NewDecoder(getResp.Body).Decode(&invitation)
invitationToken := invitation.InvitationToken
// Accept the invitation first time
acceptData := map[string]interface{}{
"token": invitationToken,
}
jsonData, _ := json.Marshal(acceptData)
acceptURL := fmt.Sprintf("%s%s/user/teams/invitations/%s/accept", serverURL, baseURL, invID)
acceptReq, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq.Header.Set("Content-Type", "application/json")
acceptReq.Header.Set("Authorization", "Bearer "+tokenB.AccessToken)
acceptResp, err := client.Do(acceptReq)
assert.NoError(t, err)
acceptResp.Body.Close()
assert.Equal(t, http.StatusOK, acceptResp.StatusCode)
// Try to accept again (should fail)
acceptReq2, err := http.NewRequest("POST", acceptURL, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
acceptReq2.Header.Set("Content-Type", "application/json")
acceptReq2.Header.Set("Authorization", "Bearer "+tokenB.AccessToken)
acceptResp2, err := client.Do(acceptReq2)
assert.NoError(t, err)
defer acceptResp2.Body.Close()
assert.Equal(t, http.StatusNotFound, acceptResp2.StatusCode)
})
}
// Helper functions
// setupTeamAndInvitation creates a team and invitation for testing by calling HTTP APIs
// This simulates the complete flow including OAuth Guard middleware
// Returns teamID and invitationID
func setupTeamAndInvitation(t *testing.T, serverURL, baseURL, accessToken, ownerUserID, inviteeUserID, testUUID string) (string, string) {
client := &http.Client{Timeout: 10 * time.Second}
// Step 1: Create team via HTTP API
teamName := fmt.Sprintf("Team_%s", testUUID)
teamData := map[string]interface{}{
"name": teamName,
"description": "Test team for invitation acceptance",
}
teamJSON, _ := json.Marshal(teamData)
createTeamURL := fmt.Sprintf("%s%s/user/teams", serverURL, baseURL)
req, err := http.NewRequest("POST", createTeamURL, bytes.NewBuffer(teamJSON))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("Failed to create team: status=%d, body=%s", resp.StatusCode, string(body))
}
var team map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&team)
assert.NoError(t, err)
teamID := getTeamID(team)
// Step 2: Create invitation via HTTP API
invitationData := map[string]interface{}{
"user_id": inviteeUserID,
"email": inviteeUserID + "@test.com", // Provide email so GetUser is not needed
"member_type": "user",
"role_id": "user",
"message": "Test invitation",
}
invJSON, _ := json.Marshal(invitationData)
createInvURL := fmt.Sprintf("%s%s/user/teams/%s/invitations", serverURL, baseURL, teamID)
invReq, err := http.NewRequest("POST", createInvURL, bytes.NewBuffer(invJSON))
assert.NoError(t, err)
invReq.Header.Set("Content-Type", "application/json")
invReq.Header.Set("Authorization", "Bearer "+accessToken)
invResp, err := client.Do(invReq)
assert.NoError(t, err)
defer invResp.Body.Close()
if invResp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(invResp.Body)
t.Fatalf("Failed to create invitation: status=%d, body=%s", invResp.StatusCode, string(body))
}
var invitation map[string]interface{}
err = json.NewDecoder(invResp.Body).Decode(&invitation)
assert.NoError(t, err)
invitationID, ok := invitation["invitation_id"].(string)
if !ok {
t.Fatalf("invitation_id not found in response")
}
return teamID, invitationID
}
// createUserInDB creates a user record directly in the database using userProvider
// Returns the actual user_id created (which may differ from the requested userID)
func createUserInDB(t *testing.T, userID string) string {
userData := map[string]interface{}{
"user_id": userID,
"name": "Test User " + userID,
"email": userID + "@test.com",
"status": "enabled",
}
// Create user using userProvider.CreateUser
provider, err := oauth.OAuth.GetUserProvider()
if err != nil {
t.Fatalf("Failed to get user provider: %v", err)
}
ctx := context.Background()
createdUserID, err := provider.CreateUser(ctx, userData)
if err != nil {
t.Fatalf("Failed to create user via provider: %v", err)
}
if createdUserID != userID {
t.Logf("Note: Created user ID %s differs from requested %s", createdUserID, userID)
}
return createdUserID
}
// createTestInvitation creates a test invitation and returns its ID
func createTestInvitation(t *testing.T, serverURL, baseURL, accessToken, teamID, userID string) string {
return createTestInvitationWithMessage(t, serverURL, baseURL, accessToken, teamID, userID, "Test invitation")

View file

@ -116,24 +116,13 @@ func TestTeamList(t *testing.T) {
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")
// Parse response as array (TeamList returns array directly, not paginated)
var teams []interface{}
err = json.Unmarshal(body, &teams)
assert.NoError(t, err, "Should parse JSON response as array")
// 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")
}
// Verify it's an array
assert.IsType(t, []interface{}{}, teams, "Response should be an array")
}
t.Logf("Team list test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))
@ -415,16 +404,13 @@ func TestTeamGet(t *testing.T) {
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")
// Parse response as team list (returns array directly, not paginated)
var teams []interface{}
err = json.Unmarshal(body, &teams)
assert.NoError(t, err, "Should parse JSON response as array")
// 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")
// Verify it's an array
assert.IsType(t, []interface{}{}, teams, "Response should be an array")
} else {
// Parse response as team detail object
var team map[string]interface{}

View file

@ -451,6 +451,159 @@ func GinTeamInvitationDelete(c *gin.Context) {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation cancelled successfully"})
}
// GinTeamInvitationAccept handles POST /user/teams/invitations/:invitation_id/accept - Accept invitation and login to team
func GinTeamInvitationAccept(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
}
ctx := c.Request.Context()
// Use authInfo.UserID directly - it might be OAuth subject, but LoginByTeamID will handle user creation
userID := authInfo.UserID
invitationID := c.Param("invitation_id")
if invitationID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body to get token
var req struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: token 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 process invitation",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get invitation details first to retrieve team_id
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
if err != nil {
log.Error("Failed to get invitation: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Get team_id from invitation
teamID := toString(invitationData["team_id"])
if teamID == "" {
log.Error("Invalid invitation: missing team_id")
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Invalid invitation data",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// If invitation doesn't have a user_id (unregistered user invitation), update it with current user
if invitationData["user_id"] == nil || invitationData["user_id"] == "" {
updateData := maps.MapStrAny{
"user_id": userID,
}
err = provider.UpdateMemberByInvitationID(ctx, invitationID, updateData)
if err != nil {
log.Error("Failed to update invitation with user_id: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to process invitation",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
}
// Accept the invitation
err = provider.AcceptInvitation(ctx, invitationID, req.Token)
if err != nil {
log.Error("Failed to accept invitation: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already accepted") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation not found or already accepted",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
} else if strings.Contains(err.Error(), "expired") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invitation has expired",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to accept invitation",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Prepare login context with full device/platform information
loginCtx := makeLoginContext(c)
// Login with the team that was just joined
// Note: userID must exist in database (user table)
loginResponse, err := LoginByTeamID(userID, teamID, loginCtx)
if err != nil {
log.Error("Failed to login with team: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Invitation accepted but failed to login: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Revoke the current token if it exists (similar to team selection)
currentToken := oauth.OAuth.GetAccessToken(c)
if currentToken != "" {
if err := oauth.OAuth.Revoke(ctx, currentToken, "access_token"); err != nil {
// Log the error but don't fail the request
log.Warn("Failed to revoke previous token: %v", err)
}
}
// Send secure cookies (access token, refresh token, and session ID)
SendLoginCookies(c, loginResponse, "")
// Return the new tokens in response body
response.RespondWithSuccess(c, http.StatusOK, loginResponse)
}
// Yao Process Handlers (for Yao application calls)
// ProcessTeamInvitationList user.team.invitation.list Team invitation list processor

View file

@ -58,7 +58,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
// Public endpoint for viewing team invitations (no auth required)
// Must be registered BEFORE the team group with auth guard
group.GET("/teams/invitations/:invitation_id", GinTeamInvitationGetPublic) // GET /user/teams/invitations/:invitation_id - Get invitation details (public)
group.GET("/teams/invitations/:invitation_id", GinTeamInvitationGetPublic) // GET /user/teams/invitations/:invitation_id - Get invitation details (public)
group.POST("/teams/invitations/:invitation_id/accept", oauth.Guard, GinTeamInvitationAccept) // POST /user/teams/invitations/:invitation_id/accept - Accept invitation and login
team := group.Group("/teams")