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 // AcceptInvitation accepts a team invitation
func (u *DefaultUser) AcceptInvitation(ctx context.Context, invitationToken string) error { func (u *DefaultUser) AcceptInvitation(ctx context.Context, invitationID string, invitationToken string) error {
// Find member by invitation token // Find member by invitation_id and token
m := model.Select(u.memberModel) m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{ members, err := m.Get(model.QueryParam{
Select: []interface{}{"id", "team_id", "user_id", "status", "invitation_expires_at"}, Select: []interface{}{"id", "team_id", "user_id", "status", "invitation_expires_at"},
Wheres: []model.QueryWhere{ Wheres: []model.QueryWhere{
{Column: "invitation_id", Value: invitationID},
{Column: "invitation_token", Value: invitationToken}, {Column: "invitation_token", Value: invitationToken},
{Column: "status", Value: "pending"}, {Column: "status", Value: "pending"},
}, },

View file

@ -243,6 +243,7 @@ func TestMemberInvitationFlow(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
var invitationToken string var invitationToken string
var invitationID string
// Test AddMember (invitation-based) // Test AddMember (invitation-based)
t.Run("AddMember", func(t *testing.T) { 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, "pending", member["status"])
assert.Equal(t, ownerUser, member["invited_by"]) 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) memberDetail, err := testProvider.GetMemberDetail(ctx, teamID, inviteeUser)
assert.NoError(t, err) assert.NoError(t, err)
invitationToken = memberDetail["invitation_token"].(string) invitationToken = memberDetail["invitation_token"].(string)
assert.NotEmpty(t, invitationToken) assert.NotEmpty(t, invitationToken)
invitationID = memberDetail["invitation_id"].(string)
assert.NotEmpty(t, invitationID)
// Verify invitation expiry is set // Verify invitation expiry is set
assert.NotNil(t, memberDetail["invitation_expires_at"]) assert.NotNil(t, memberDetail["invitation_expires_at"])
@ -275,7 +278,7 @@ func TestMemberInvitationFlow(t *testing.T) {
// Test AcceptInvitation // Test AcceptInvitation
t.Run("AcceptInvitation", func(t *testing.T) { t.Run("AcceptInvitation", func(t *testing.T) {
err := testProvider.AcceptInvitation(ctx, invitationToken) err := testProvider.AcceptInvitation(ctx, invitationID, invitationToken)
assert.NoError(t, err) assert.NoError(t, err)
// Verify member status changed to active // Verify member status changed to active
@ -292,14 +295,14 @@ func TestMemberInvitationFlow(t *testing.T) {
// Test AcceptInvitation with invalid token // Test AcceptInvitation with invalid token
t.Run("AcceptInvitation_InvalidToken", func(t *testing.T) { 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.Error(t, err)
assert.Contains(t, err.Error(), "invitation not found") assert.Contains(t, err.Error(), "invitation not found")
}) })
// Test AcceptInvitation with already accepted token // Test AcceptInvitation with already accepted token
t.Run("AcceptInvitation_AlreadyAccepted", func(t *testing.T) { t.Run("AcceptInvitation_AlreadyAccepted", func(t *testing.T) {
err := testProvider.AcceptInvitation(ctx, invitationToken) err := testProvider.AcceptInvitation(ctx, invitationID, invitationToken)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "invitation not found") 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 "invitation_expires_at": expiredTime, // Expired 2 hours ago
} }
_, err = testProvider.CreateMember(ctx, memberData) memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err) 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 // Test AcceptInvitation with expired token
t.Run("AcceptInvitation_ExpiredToken", func(t *testing.T) { 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.Error(t, err)
assert.Contains(t, err.Error(), "invitation has expired") assert.Contains(t, err.Error(), "invitation has expired")
}) })

View file

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

View file

@ -308,7 +308,7 @@ type UserProvider interface {
// Member Invitation Management // Member Invitation Management
AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (int64, error) 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 // Robot Member Operations
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (int64, error) 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[:]) 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 // 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) { func createTestUser(t *testing.T, server *openapi.OpenAPI, clientID string) (string, string) {
if server.OAuth == nil { if server.OAuth == nil {

View file

@ -2,6 +2,7 @@ package user_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -13,6 +14,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi" "github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/tests/testutils" "github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/openapi/user" "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 // 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 // createTestInvitation creates a test invitation and returns its ID
func createTestInvitation(t *testing.T, serverURL, baseURL, accessToken, teamID, userID string) string { func createTestInvitation(t *testing.T, serverURL, baseURL, accessToken, teamID, userID string) string {
return createTestInvitationWithMessage(t, serverURL, baseURL, accessToken, teamID, userID, "Test invitation") 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") assert.NoError(t, err, "Should read response body")
if resp.StatusCode == 200 { if resp.StatusCode == 200 {
// Parse response as pagination result // Parse response as array (TeamList returns array directly, not paginated)
var response map[string]interface{} var teams []interface{}
err = json.Unmarshal(body, &response) err = json.Unmarshal(body, &teams)
assert.NoError(t, err, "Should parse JSON response") assert.NoError(t, err, "Should parse JSON response as array")
// Check pagination structure (consistent with other modules) // Verify it's an array
if data, ok := response["data"]; ok { assert.IsType(t, []interface{}{}, teams, "Response should be an array")
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)) 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 resp.StatusCode == 200 {
if tc.teamID == "" { if tc.teamID == "" {
// Parse response as team list (pagination result) // Parse response as team list (returns array directly, not paginated)
var response map[string]interface{} var teams []interface{}
err = json.Unmarshal(body, &response) err = json.Unmarshal(body, &teams)
assert.NoError(t, err, "Should parse JSON response") assert.NoError(t, err, "Should parse JSON response as array")
// Check pagination structure // Verify it's an array
assert.Contains(t, response, "data", "Should have data array") assert.IsType(t, []interface{}{}, teams, "Response should be an 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 { } else {
// Parse response as team detail object // Parse response as team detail object
var team map[string]interface{} 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"}) 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) // Yao Process Handlers (for Yao application calls)
// ProcessTeamInvitationList user.team.invitation.list Team invitation list processor // ProcessTeamInvitationList user.team.invitation.list Team invitation list processor

View file

@ -59,6 +59,7 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
// Public endpoint for viewing team invitations (no auth required) // Public endpoint for viewing team invitations (no auth required)
// Must be registered BEFORE the team group with auth guard // 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") team := group.Group("/teams")