diff --git a/openapi/oauth/providers/user/default.go b/openapi/oauth/providers/user/default.go index c9b5045a..1b1af8f4 100644 --- a/openapi/oauth/providers/user/default.go +++ b/openapi/oauth/providers/user/default.go @@ -61,6 +61,21 @@ var ( "id", "user_id", "mfa_enabled", "mfa_secret", "mfa_issuer", "mfa_algorithm", "mfa_digits", "mfa_period", "mfa_recovery_hash", "mfa_enabled_at", } + + // DefaultOAuthAccountFields contains basic OAuth account fields + DefaultOAuthAccountFields = []interface{}{ + "id", "user_id", "provider", "sub", "preferred_username", "email", "email_verified", + "name", "given_name", "family_name", "picture", "last_login_at", "is_active", + "created_at", "updated_at", + } + + // DefaultOAuthAccountDetailFields contains all OAuth account fields including OIDC claims + DefaultOAuthAccountDetailFields = []interface{}{ + "id", "user_id", "provider", "sub", "preferred_username", "email", "email_verified", + "name", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", + "website", "gender", "birthdate", "zoneinfo", "locale", "phone_number", "phone_number_verified", + "address", "raw", "last_login_at", "is_active", "created_at", "updated_at", + } ) // DefaultUser provides a default implementation of UserProvider @@ -81,6 +96,10 @@ type DefaultUser struct { basicUserFields []interface{} // configurable authUserFields []interface{} // fixed for security mfaUserFields []interface{} // fixed for security + + // OAuth Account Field lists + oauthAccountFields []interface{} // configurable + oauthAccountDetailFields []interface{} // configurable } // IDStrategy defines the strategy for generating user IDs @@ -109,6 +128,10 @@ type DefaultUserOptions struct { PublicUserFields []interface{} // fields returned in public APIs BasicUserFields []interface{} // minimal fields for basic user info // Note: AuthUserFields and MFAUserFields are fixed for security reasons + + // OAuth Account field lists (use defaults if not specified) + OAuthAccountFields []interface{} // basic OAuth account fields + OAuthAccountDetailFields []interface{} // detailed OAuth account fields with OIDC claims } // NewDefaultUser creates a new DefaultUser @@ -154,6 +177,17 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser { basicUserFields = DefaultBasicUserFields } + // Set OAuth account field lists with defaults if not specified + oauthAccountFields := options.OAuthAccountFields + if oauthAccountFields == nil { + oauthAccountFields = DefaultOAuthAccountFields + } + + oauthAccountDetailFields := options.OAuthAccountDetailFields + if oauthAccountDetailFields == nil { + oauthAccountDetailFields = DefaultOAuthAccountDetailFields + } + return &DefaultUser{ prefix: options.Prefix, model: model, @@ -167,5 +201,9 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser { basicUserFields: basicUserFields, authUserFields: DefaultAuthUserFields, // fixed for security mfaUserFields: DefaultMFAUserFields, // fixed for security + + // OAuth Account field lists + oauthAccountFields: oauthAccountFields, + oauthAccountDetailFields: oauthAccountDetailFields, } } diff --git a/openapi/oauth/providers/user/oauth_account.go b/openapi/oauth/providers/user/oauth_account.go index c5c6f047..80330ec0 100644 --- a/openapi/oauth/providers/user/oauth_account.go +++ b/openapi/oauth/providers/user/oauth_account.go @@ -2,6 +2,8 @@ package user import ( "context" + "fmt" + "time" "github.com/yaoapp/gou/model" "github.com/yaoapp/kun/maps" @@ -11,48 +13,213 @@ import ( // CreateOAuthAccount creates a new OAuth account association func (u *DefaultUser) CreateOAuthAccount(ctx context.Context, userID string, oauthData maps.MapStrAny) (interface{}, error) { - // TODO: implement - return nil, nil + // Set required fields + oauthData["user_id"] = userID + + // Set default status if not provided + if _, exists := oauthData["is_active"]; !exists { + oauthData["is_active"] = true + } + + // Set last login time if not provided + if _, exists := oauthData["last_login_at"]; !exists { + oauthData["last_login_at"] = time.Now() + } + + m := model.Select(u.oauthAccountModel) + id, err := m.Create(oauthData) + if err != nil { + return nil, fmt.Errorf(ErrFailedToCreateOAuth, err) + } + + return id, nil } // GetOAuthAccount retrieves OAuth account by provider and subject func (u *DefaultUser) GetOAuthAccount(ctx context.Context, provider string, subject string) (maps.MapStrAny, error) { - // TODO: implement - return nil, nil + m := model.Select(u.oauthAccountModel) + accounts, err := m.Get(model.QueryParam{ + Select: u.oauthAccountFields, + Wheres: []model.QueryWhere{ + {Column: "provider", Value: provider}, + {Column: "sub", Value: subject}, + }, + Limit: 1, + }) + + if err != nil { + return nil, fmt.Errorf(ErrFailedToGetOAuthAccount, err) + } + + if len(accounts) == 0 { + return nil, fmt.Errorf("oauth account not found for provider %s with subject %s", provider, subject) + } + + return accounts[0], nil } // GetUserOAuthAccounts retrieves all OAuth accounts for a user func (u *DefaultUser) GetUserOAuthAccounts(ctx context.Context, userID string) ([]maps.MapStrAny, error) { - // TODO: implement - return nil, nil + m := model.Select(u.oauthAccountModel) + accounts, err := m.Get(model.QueryParam{ + Select: u.oauthAccountFields, + Wheres: []model.QueryWhere{ + {Column: "user_id", Value: userID}, + }, + Orders: []model.QueryOrder{ + {Column: "last_login_at", Option: "desc"}, + }, + }) + + if err != nil { + return nil, fmt.Errorf(ErrFailedToGetOAuthAccount, err) + } + + return accounts, nil } // UpdateOAuthAccount updates OAuth account information func (u *DefaultUser) UpdateOAuthAccount(ctx context.Context, provider string, subject string, oauthData maps.MapStrAny) error { - // TODO: implement + // Remove sensitive fields that should not be updated directly + sensitiveFields := []string{"id", "user_id", "provider", "sub", "created_at"} + for _, field := range sensitiveFields { + delete(oauthData, field) + } + + // Skip update if no valid fields remain + if len(oauthData) == 0 { + return nil + } + + m := model.Select(u.oauthAccountModel) + affected, err := m.UpdateWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "provider", Value: provider}, + {Column: "sub", Value: subject}, + }, + Limit: 1, // Safety: ensure only one record is updated + }, oauthData) + + if err != nil { + return fmt.Errorf(ErrFailedToUpdateOAuth, err) + } + + if affected == 0 { + return fmt.Errorf("oauth account not found for provider %s with subject %s", provider, subject) + } + return nil } // DeleteOAuthAccount removes an OAuth account association func (u *DefaultUser) DeleteOAuthAccount(ctx context.Context, provider string, subject string) error { - // TODO: implement + m := model.Select(u.oauthAccountModel) + affected, err := m.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "provider", Value: provider}, + {Column: "sub", Value: subject}, + }, + Limit: 1, // Safety: ensure only one record is deleted + }) + + if err != nil { + return fmt.Errorf(ErrFailedToDeleteOAuth, err) + } + + if affected == 0 { + return fmt.Errorf("oauth account not found for provider %s with subject %s", provider, subject) + } + + return nil +} + +// DeleteUserOAuthAccounts removes all OAuth accounts for a specific user +func (u *DefaultUser) DeleteUserOAuthAccounts(ctx context.Context, userID string) error { + m := model.Select(u.oauthAccountModel) + + // Use batch soft delete (the Gou library bug has been fixed) + _, err := m.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "user_id", Value: userID}, + }, + }) + + if err != nil { + return fmt.Errorf(ErrFailedToDeleteOAuth, err) + } + + // Note: We don't check affected count here because it's valid for a user to have no OAuth accounts + // This method is typically called during user deletion as a cleanup operation + return nil } // GetOAuthAccounts retrieves OAuth accounts by query parameters func (u *DefaultUser) GetOAuthAccounts(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) { - // TODO: implement - return nil, nil + // Set default select fields if not provided + if param.Select == nil { + param.Select = u.oauthAccountFields + } + + m := model.Select(u.oauthAccountModel) + accounts, err := m.Get(param) + if err != nil { + return nil, fmt.Errorf(ErrFailedToGetOAuthAccount, err) + } + + return accounts, nil } // PaginateOAuthAccounts retrieves paginated list of OAuth accounts func (u *DefaultUser) PaginateOAuthAccounts(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error) { - // TODO: implement - return nil, nil + // Set default select fields if not provided + if param.Select == nil { + param.Select = u.oauthAccountFields + } + + m := model.Select(u.oauthAccountModel) + result, err := m.Paginate(param, page, pagesize) + if err != nil { + return nil, fmt.Errorf(ErrFailedToGetOAuthAccount, err) + } + + return result, nil } // CountOAuthAccounts returns total count of OAuth accounts with optional filters func (u *DefaultUser) CountOAuthAccounts(ctx context.Context, param model.QueryParam) (int64, error) { - // TODO: implement - return 0, nil + // Use Paginate with a small page size to get the total count + // This is more reliable than manual COUNT(*) queries + m := model.Select(u.oauthAccountModel) + result, err := m.Paginate(param, 1, 1) // Get first page with 1 item to get total + if err != nil { + return 0, fmt.Errorf(ErrFailedToGetOAuthAccount, err) + } + + // Extract total from pagination result + if total, ok := result["total"].(int64); ok { + return total, nil + } + + // Handle different total types returned by Paginate + if totalInterface, ok := result["total"]; ok { + switch v := totalInterface.(type) { + case int: + return int64(v), nil + case int32: + return int64(v), nil + case int64: + return v, nil + case uint: + return int64(v), nil + case uint32: + return int64(v), nil + case uint64: + return int64(v), nil + default: + return 0, fmt.Errorf("unexpected total type: %T", totalInterface) + } + } + + return 0, fmt.Errorf("total not found in pagination result") } diff --git a/openapi/oauth/providers/user/oauth_account_test.go b/openapi/oauth/providers/user/oauth_account_test.go new file mode 100644 index 00000000..40185765 --- /dev/null +++ b/openapi/oauth/providers/user/oauth_account_test.go @@ -0,0 +1,702 @@ +package user_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/kun/maps" +) + +// TestOAuthAccountData represents test OAuth account data structure +type TestOAuthAccountData struct { + Provider string `json:"provider"` + Sub string `json:"sub"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Picture string `json:"picture"` + IsActive bool `json:"is_active"` + Raw map[string]interface{} `json:"raw"` +} + +func TestOAuthAccountBasicOperations(t *testing.T) { + prepare(t) + defer clean() + + ctx := context.Background() + + // Step 1: Create a test user first (OAuth accounts need a user_id) + // Use UUID to ensure unique identifiers + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] // 8 char UUID + testUser := createTestUserData("oauthtest" + testUUID) + _, testUserID := setupTestUser(t, ctx, testUser) + + // Step 2: Create test OAuth account data dynamically + testOAuth := &TestOAuthAccountData{ + Provider: "google", + Sub: "google_" + testUUID + "_123456789", + PreferredUsername: "oauth_testuser" + testUUID, + Email: "oauth_testuser" + testUUID + "@gmail.com", + EmailVerified: true, + Name: "OAuth Test User " + testUUID, + GivenName: "OAuth", + FamilyName: "User", + Picture: "https://example.com/avatar.jpg", + IsActive: true, + Raw: map[string]interface{}{ + "iss": "https://accounts.google.com", + "aud": "your-client-id.apps.googleusercontent.com", + "locale": "en", + }, + } + + // Test CreateOAuthAccount + t.Run("CreateOAuthAccount", func(t *testing.T) { + oauthData := maps.MapStrAny{ + "provider": testOAuth.Provider, + "sub": testOAuth.Sub, + "preferred_username": testOAuth.PreferredUsername, + "email": testOAuth.Email, + "email_verified": testOAuth.EmailVerified, + "name": testOAuth.Name, + "given_name": testOAuth.GivenName, + "family_name": testOAuth.FamilyName, + "picture": testOAuth.Picture, + "raw": testOAuth.Raw, + } + + id, err := testProvider.CreateOAuthAccount(ctx, testUserID, oauthData) + assert.NoError(t, err) + assert.NotNil(t, id) + + // Verify user_id was automatically set + assert.Equal(t, testUserID, oauthData["user_id"]) + + // Verify default values were set + assert.Equal(t, true, oauthData["is_active"]) + assert.NotNil(t, oauthData["last_login_at"]) + }) + + // Test GetOAuthAccount + t.Run("GetOAuthAccount", func(t *testing.T) { + account, err := testProvider.GetOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub) + assert.NoError(t, err) + assert.NotNil(t, account) + + // Verify key fields + assert.Equal(t, testUserID, account["user_id"]) + assert.Equal(t, testOAuth.Provider, account["provider"]) + assert.Equal(t, testOAuth.Sub, account["sub"]) + assert.Equal(t, testOAuth.Email, account["email"]) + assert.Equal(t, testOAuth.Name, account["name"]) + + // Handle different boolean representations from database + isActive := account["is_active"] + switch v := isActive.(type) { + case bool: + assert.True(t, v) + case int, int32, int64: + assert.NotEqual(t, 0, v) // Any non-zero value is true + default: + t.Errorf("unexpected is_active type: %T, value: %v", isActive, isActive) + } + + assert.NotNil(t, account["last_login_at"]) + }) + + // Test GetUserOAuthAccounts + t.Run("GetUserOAuthAccounts", func(t *testing.T) { + accounts, err := testProvider.GetUserOAuthAccounts(ctx, testUserID) + assert.NoError(t, err) + assert.NotNil(t, accounts) + assert.GreaterOrEqual(t, len(accounts), 1) // At least our test account + + // Find our test account + var testAccount maps.MapStrAny + for _, account := range accounts { + if account["provider"] == testOAuth.Provider && account["sub"] == testOAuth.Sub { + testAccount = account + break + } + } + + assert.NotNil(t, testAccount, "Test OAuth account should be found") + assert.Equal(t, testUserID, testAccount["user_id"]) + assert.Equal(t, testOAuth.Email, testAccount["email"]) + }) + + // Test UpdateOAuthAccount + t.Run("UpdateOAuthAccount", func(t *testing.T) { + updateData := maps.MapStrAny{ + "name": "Updated OAuth User", + "given_name": "Updated", + "family_name": "OAuth User", + "picture": "https://example.com/new_avatar.jpg", + "raw": map[string]interface{}{ + "iss": "https://accounts.google.com", + "aud": "your-client-id.apps.googleusercontent.com", + "locale": "zh-CN", // Updated locale + "updated": true, + }, + } + + err := testProvider.UpdateOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub, updateData) + assert.NoError(t, err) + + // Verify update + account, err := testProvider.GetOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub) + assert.NoError(t, err) + assert.Equal(t, "Updated OAuth User", account["name"]) + assert.Equal(t, "Updated", account["given_name"]) + assert.Equal(t, "https://example.com/new_avatar.jpg", account["picture"]) + + // Test updating sensitive fields (should be ignored) + sensitiveData := maps.MapStrAny{ + "id": 999, + "user_id": "malicious_user_id", + "provider": "malicious_provider", + "sub": "malicious_sub", + "created_at": time.Now(), + } + + err = testProvider.UpdateOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub, sensitiveData) + assert.NoError(t, err) // Should not error, just ignore sensitive fields + + // Verify sensitive fields were not changed + account, err = testProvider.GetOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub) + assert.NoError(t, err) + assert.Equal(t, testUserID, account["user_id"]) // Should remain unchanged + assert.Equal(t, testOAuth.Provider, account["provider"]) // Should remain unchanged + assert.Equal(t, testOAuth.Sub, account["sub"]) // Should remain unchanged + }) + + // Create another OAuth account for the same user (different provider) to test GetUserOAuthAccounts + t.Run("CreateSecondOAuthAccount", func(t *testing.T) { + secondOAuthData := maps.MapStrAny{ + "provider": "github", + "sub": "github_" + testUUID + "_987654321", + "preferred_username": "oauth_testuser" + testUUID + "_gh", + "email": "oauth_testuser" + testUUID + "@users.noreply.github.com", + "email_verified": true, + "name": "OAuth Test User (GitHub) " + testUUID, + "given_name": "OAuth", + "family_name": "User", + "picture": "https://avatars.githubusercontent.com/u/123456", + } + + id, err := testProvider.CreateOAuthAccount(ctx, testUserID, secondOAuthData) + assert.NoError(t, err) + assert.NotNil(t, id) + + // Verify user now has 2 OAuth accounts + accounts, err := testProvider.GetUserOAuthAccounts(ctx, testUserID) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(accounts), 2) // At least 2 accounts now + + // Verify accounts are ordered by last_login_at desc (newest first) + if len(accounts) >= 2 { + // The GitHub account should be newer (created later), so it should come first + foundGitHub := false + for _, account := range accounts { + if account["provider"] == "github" { + foundGitHub = true + break + } + } + assert.True(t, foundGitHub, "GitHub OAuth account should be found") + } + }) + + // Test DeleteOAuthAccount (delete the second account first) + t.Run("DeleteSecondOAuthAccount", func(t *testing.T) { + githubSub := "github_" + testUUID + "_987654321" + err := testProvider.DeleteOAuthAccount(ctx, "github", githubSub) + assert.NoError(t, err) + + // Verify account was deleted + _, err = testProvider.GetOAuthAccount(ctx, "github", githubSub) + assert.Error(t, err) + assert.Contains(t, err.Error(), "oauth account not found") + + // Verify user still has the first OAuth account + accounts, err := testProvider.GetUserOAuthAccounts(ctx, testUserID) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(accounts), 1) // Still has at least 1 account + }) + + // Test DeleteOAuthAccount (delete the first account at the end) + t.Run("DeleteOAuthAccount", func(t *testing.T) { + err := testProvider.DeleteOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub) + assert.NoError(t, err) + + // Verify account was deleted + _, err = testProvider.GetOAuthAccount(ctx, testOAuth.Provider, testOAuth.Sub) + assert.Error(t, err) + assert.Contains(t, err.Error(), "oauth account not found") + }) + + // Test DeleteUserOAuthAccounts + t.Run("DeleteUserOAuthAccounts", func(t *testing.T) { + // First create a new user with multiple OAuth accounts for testing + testUserForDelete := createTestUserData("deletetest" + testUUID) + _, deleteTestUserID := setupTestUser(t, ctx, testUserForDelete) + + // Create multiple OAuth accounts for this user (using different providers to avoid conflicts) + oauthAccounts := []maps.MapStrAny{ + { + "provider": "discord", + "sub": "discord_delete_" + testUUID, + "email": "deletetest" + testUUID + "@discord.com", + "name": "Delete Test User Discord", + "email_verified": true, + }, + { + "provider": "linkedin", + "sub": "linkedin_delete_" + testUUID, + "email": "deletetest" + testUUID + "@linkedin.com", + "name": "Delete Test User LinkedIn", + "email_verified": true, + }, + { + "provider": "twitter", + "sub": "twitter_delete_" + testUUID, + "email": "deletetest" + testUUID + "@twitter.com", + "name": "Delete Test User Twitter", + "email_verified": true, + }, + } + + // Create all OAuth accounts + for _, oauthData := range oauthAccounts { + _, err := testProvider.CreateOAuthAccount(ctx, deleteTestUserID, oauthData) + assert.NoError(t, err) + } + + // Verify accounts were created + accounts, err := testProvider.GetUserOAuthAccounts(ctx, deleteTestUserID) + assert.NoError(t, err) + assert.Len(t, accounts, 3) // Should have 3 accounts + + // Delete all OAuth accounts for this user + err = testProvider.DeleteUserOAuthAccounts(ctx, deleteTestUserID) + assert.NoError(t, err) + + // Verify all accounts were deleted + accounts, err = testProvider.GetUserOAuthAccounts(ctx, deleteTestUserID) + assert.NoError(t, err) + assert.Len(t, accounts, 0) // Should have no accounts + + // Test deleting OAuth accounts for user with no OAuth accounts (should not error) + err = testProvider.DeleteUserOAuthAccounts(ctx, deleteTestUserID) + assert.NoError(t, err) // Should not error even if no accounts exist + }) +} + +func TestOAuthAccountListOperations(t *testing.T) { + prepare(t) + defer clean() + + ctx := context.Background() + + // Create test users and OAuth accounts for list operations + // Use UUID to ensure unique identifiers + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] // 8 char UUID + + // Create multiple test users + testUsers := make([]string, 5) // Store user IDs - one for each OAuth account + for i := 0; i < 5; i++ { + userData := createTestUserData("oauthlist" + testUUID + string('0'+rune(i))) + _, userID := setupTestUser(t, ctx, userData) + testUsers[i] = userID + } + + // Create multiple OAuth accounts for testing + // Each account is assigned to a different user to avoid unique constraint violations + oauthAccounts := []TestOAuthAccountData{ + { + Provider: "google", + Sub: "google_list_" + testUUID + "_1", + Email: "listtest1_" + testUUID + "@gmail.com", + Name: "OAuth List Test 1", + IsActive: true, + }, + { + Provider: "github", + Sub: "github_list_" + testUUID + "_2", + Email: "listtest2_" + testUUID + "@users.noreply.github.com", + Name: "OAuth List Test 2", + IsActive: true, + }, + { + Provider: "apple", + Sub: "apple_list_" + testUUID + "_3", + Email: "listtest3_" + testUUID + "@privaterelay.appleid.com", + Name: "OAuth List Test 3", + IsActive: false, // Different status for filtering + }, + { + Provider: "google", + Sub: "google_list_" + testUUID + "_4", + Email: "listtest4_" + testUUID + "@gmail.com", + Name: "OAuth List Test 4", + IsActive: true, + }, + { + Provider: "github", + Sub: "github_list_" + testUUID + "_5", + Email: "listtest5_" + testUUID + "@users.noreply.github.com", + Name: "OAuth List Test 5", + IsActive: true, + }, + } + + // Create OAuth accounts in database + // Each account gets its own user to avoid user_id + provider unique constraint violations + for i, oauthData := range oauthAccounts { + oauthMap := maps.MapStrAny{ + "provider": oauthData.Provider, + "sub": oauthData.Sub, + "email": oauthData.Email, + "name": oauthData.Name, + "is_active": oauthData.IsActive, + "email_verified": true, + } + + _, err := testProvider.CreateOAuthAccount(ctx, testUsers[i], oauthMap) + assert.NoError(t, err) + } + + // Test GetOAuthAccounts + t.Run("GetOAuthAccounts_All", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + }, + } + accounts, err := testProvider.GetOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(accounts), 5) // At least our 5 test accounts + + // Check that basic fields are returned by default + if len(accounts) > 0 { + account := accounts[0] + assert.Contains(t, account, "user_id") + assert.Contains(t, account, "provider") + assert.Contains(t, account, "sub") + assert.Contains(t, account, "email") + assert.Contains(t, account, "is_active") + } + }) + + t.Run("GetOAuthAccounts_WithFilters", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + {Column: "provider", Value: "google"}, + }, + } + accounts, err := testProvider.GetOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(accounts), 2) // At least 2 Google accounts + + // All returned accounts should be Google + for _, account := range accounts { + if strings.Contains(account["sub"].(string), "_list_"+testUUID+"_") { + assert.Equal(t, "google", account["provider"]) + } + } + }) + + t.Run("GetOAuthAccounts_WithCustomFields", func(t *testing.T) { + param := model.QueryParam{ + Select: []interface{}{"provider", "sub", "email", "is_active"}, + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + }, + Limit: 3, + } + accounts, err := testProvider.GetOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.LessOrEqual(t, len(accounts), 3) // Respects limit + + if len(accounts) > 0 { + account := accounts[0] + assert.Contains(t, account, "provider") + assert.Contains(t, account, "sub") + assert.Contains(t, account, "email") + assert.Contains(t, account, "is_active") + } + }) + + // Test PaginateOAuthAccounts + t.Run("PaginateOAuthAccounts_FirstPage", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + }, + Orders: []model.QueryOrder{ + {Column: "provider", Option: "asc"}, + }, + } + result, err := testProvider.PaginateOAuthAccounts(ctx, param, 1, 3) + assert.NoError(t, err) + assert.NotNil(t, result) + + // Check pagination structure + assert.Contains(t, result, "data") + assert.Contains(t, result, "total") + assert.Contains(t, result, "page") + assert.Contains(t, result, "pagesize") + + data, ok := result["data"].([]maps.MapStr) + assert.True(t, ok) + assert.LessOrEqual(t, len(data), 3) // Page size limit + + // Handle different total types + totalInterface, exists := result["total"] + assert.True(t, exists) + + var total int64 + switch v := totalInterface.(type) { + case int: + total = int64(v) + case int32: + total = int64(v) + case int64: + total = v + case uint: + total = int64(v) + case uint32: + total = int64(v) + case uint64: + total = int64(v) + default: + t.Errorf("unexpected total type: %T, value: %v", totalInterface, totalInterface) + } + assert.GreaterOrEqual(t, total, int64(5)) // At least 5 accounts + + assert.Equal(t, 1, result["page"]) + assert.Equal(t, 3, result["pagesize"]) + }) + + t.Run("PaginateOAuthAccounts_WithFilters", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + {Column: "is_active", Value: true}, + }, + } + result, err := testProvider.PaginateOAuthAccounts(ctx, param, 1, 10) + assert.NoError(t, err) + assert.NotNil(t, result) + + data, ok := result["data"].([]maps.MapStr) + assert.True(t, ok) + assert.GreaterOrEqual(t, len(data), 4) // At least 4 active accounts + + // Verify is_active filter works + for _, account := range data { + if strings.Contains(account["sub"].(string), "_list_"+testUUID+"_") { + // Handle different boolean representations from database + isActive := account["is_active"] + switch v := isActive.(type) { + case bool: + assert.True(t, v) + case int, int32, int64: + assert.NotEqual(t, 0, v) // Any non-zero value is true + default: + t.Errorf("unexpected is_active type: %T, value: %v", isActive, isActive) + } + } + } + }) + + // Test CountOAuthAccounts + t.Run("CountOAuthAccounts_All", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + }, + } + count, err := testProvider.CountOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, int64(5)) // At least 5 accounts + }) + + t.Run("CountOAuthAccounts_WithFilters", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + {Column: "provider", Value: "github"}, + }, + } + count, err := testProvider.CountOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, int64(2)) // At least 2 GitHub accounts + }) + + t.Run("CountOAuthAccounts_SpecificStatus", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: "%_list_" + testUUID + "_%"}, + {Column: "is_active", Value: false}, + }, + } + count, err := testProvider.CountOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, int64(1)) // At least 1 inactive account (Apple) + }) + + t.Run("CountOAuthAccounts_NoResults", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "provider", Value: "nonexistent_provider"}, + }, + } + count, err := testProvider.CountOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.Equal(t, int64(0), count) + }) +} + +func TestOAuthAccountErrorHandling(t *testing.T) { + prepare(t) + defer clean() + + ctx := context.Background() + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] + nonExistentProvider := "nonexistent_provider" + nonExistentSub := "nonexistent_sub_" + testUUID + + // Create a test user for valid user_id + testUser := createTestUserData("oautherror" + testUUID) + _, testUserID := setupTestUser(t, ctx, testUser) + + t.Run("GetOAuthAccount_NotFound", func(t *testing.T) { + _, err := testProvider.GetOAuthAccount(ctx, nonExistentProvider, nonExistentSub) + assert.Error(t, err) + assert.Contains(t, err.Error(), "oauth account not found") + }) + + t.Run("GetUserOAuthAccounts_NoAccounts", func(t *testing.T) { + accounts, err := testProvider.GetUserOAuthAccounts(ctx, testUserID) + assert.NoError(t, err) + assert.Equal(t, 0, len(accounts)) // Empty slice, not nil + }) + + t.Run("UpdateOAuthAccount_NotFound", func(t *testing.T) { + updateData := maps.MapStrAny{"name": "Test"} + err := testProvider.UpdateOAuthAccount(ctx, nonExistentProvider, nonExistentSub, updateData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "oauth account not found") + }) + + t.Run("DeleteOAuthAccount_NotFound", func(t *testing.T) { + err := testProvider.DeleteOAuthAccount(ctx, nonExistentProvider, nonExistentSub) + assert.Error(t, err) + assert.Contains(t, err.Error(), "oauth account not found") + }) + + t.Run("DeleteUserOAuthAccounts_NonExistentUser", func(t *testing.T) { + nonExistentUserID := "nonexistent_user_" + testUUID + err := testProvider.DeleteUserOAuthAccounts(ctx, nonExistentUserID) + assert.NoError(t, err) // Should not error even if user doesn't exist (cleanup operation) + }) + + t.Run("GetOAuthAccounts_EmptyResult", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "provider", Value: nonExistentProvider}, + }, + } + accounts, err := testProvider.GetOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.Equal(t, 0, len(accounts)) // Empty slice, not nil + }) + + t.Run("PaginateOAuthAccounts_EmptyResult", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "provider", Value: nonExistentProvider}, + }, + } + result, err := testProvider.PaginateOAuthAccounts(ctx, param, 1, 10) + assert.NoError(t, err) + assert.NotNil(t, result) + + data, ok := result["data"].([]maps.MapStr) + assert.True(t, ok) + assert.Equal(t, 0, len(data)) + + // Handle different total types + totalInterface, exists := result["total"] + assert.True(t, exists) + + var total int64 + switch v := totalInterface.(type) { + case int: + total = int64(v) + case int32: + total = int64(v) + case int64: + total = v + case uint: + total = int64(v) + case uint32: + total = int64(v) + case uint64: + total = int64(v) + default: + t.Errorf("unexpected total type: %T, value: %v", totalInterface, totalInterface) + } + assert.Equal(t, int64(0), total) + }) + + t.Run("CreateOAuthAccount_InvalidUserID", func(t *testing.T) { + oauthData := maps.MapStrAny{ + "provider": "google", + "sub": "test_sub_" + testUUID, + "email": "test_" + testUUID + "@gmail.com", + } + + // Note: Currently this does not fail due to foreign key constraints not being enforced + // In a production environment, this should be validated at the application level + _, err := testProvider.CreateOAuthAccount(ctx, "nonexistent_user_id", oauthData) + if err != nil { + // If foreign key constraints are enforced, this should fail + assert.Error(t, err) + } else { + // If no constraints, creation succeeds but user_id is invalid + // This is acceptable behavior for this test environment + assert.NoError(t, err) + } + }) + + t.Run("UpdateOAuthAccount_EmptyData", func(t *testing.T) { + // Test with empty update data (should not error, just do nothing) + emptyData := maps.MapStrAny{} + err := testProvider.UpdateOAuthAccount(ctx, "google", "test_sub", emptyData) + assert.NoError(t, err) // Should not error, just skip update + }) + + t.Run("CountOAuthAccounts_ComplexFilters", func(t *testing.T) { + param := model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "provider", OP: "in", Value: []interface{}{"google", "github", "apple"}}, + {Column: "is_active", Value: true}, + {Column: "email_verified", Value: true}, + }, + } + count, err := testProvider.CountOAuthAccounts(ctx, param) + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, int64(0)) // Should handle complex filters without error + }) +} diff --git a/openapi/oauth/providers/user/user_basic_test.go b/openapi/oauth/providers/user/user_basic_test.go index e578517a..8ec669d3 100644 --- a/openapi/oauth/providers/user/user_basic_test.go +++ b/openapi/oauth/providers/user/user_basic_test.go @@ -2,8 +2,10 @@ package user_test import ( "context" + "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/yaoapp/kun/maps" ) @@ -14,12 +16,15 @@ func TestUserBasicOperations(t *testing.T) { ctx := context.Background() + // Use UUID to ensure unique identifiers + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] // 8 char UUID + // Create test user data dynamically testUser := &TestUserData{ - PreferredUsername: "testuser001", - Email: "testuser001@example.com", + PreferredUsername: "testuser" + testUUID, + Email: "testuser" + testUUID + "@example.com", Password: "TestPass123!", - Name: "Test User 001", + Name: "Test User " + testUUID, GivenName: "Test", FamilyName: "User", Status: "active", @@ -234,7 +239,9 @@ func TestUserErrorHandling(t *testing.T) { defer clean() ctx := context.Background() - nonExistentUserID := "non-existent-user-id" + // Use UUID to avoid conflicts + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] + nonExistentUserID := "non-existent-user-id-" + testUUID t.Run("GetUser_NotFound", func(t *testing.T) { _, err := testProvider.GetUser(ctx, nonExistentUserID) diff --git a/openapi/oauth/providers/user/user_test.go b/openapi/oauth/providers/user/user_test.go index 953bd542..7a404458 100644 --- a/openapi/oauth/providers/user/user_test.go +++ b/openapi/oauth/providers/user/user_test.go @@ -96,11 +96,32 @@ func cleanupTestData() { return } - m := model.Select("__yao.user") + // Use DestroyWhere (hard delete) to avoid soft delete complications + // Clean OAuth accounts first (due to foreign key constraints) + oauthModel := model.Select("__yao.user_oauth_account") + oauthPatterns := []string{ + "%oauth_test%", "%_list_%", "%oauthlist%", "%oautherror%", + "%google_%", "%github_%", "%apple_%", "%_delete_%", + "%discord_%", "%linkedin_%", "%twitter_%", + } + for _, pattern := range oauthPatterns { + oauthModel.DestroyWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "sub", OP: "like", Value: pattern}, + }, + }) + } - // Delete test users by pattern - for _, pattern := range []string{"test-user-%", "test_%"} { - m.DeleteWhere(model.QueryParam{ + // Clean users + userModel := model.Select("__yao.user") + + // Delete test users by pattern (using hard delete) + userPatterns := []string{ + "test-%", "test_%", "%testuser%", "%oauthtest%", "%oauthlist%", + "%oautherror%", "%deletetest%", + } + for _, pattern := range userPatterns { + userModel.DestroyWhere(model.QueryParam{ Wheres: []model.QueryWhere{ {Column: "user_id", OP: "like", Value: pattern}, }, @@ -108,11 +129,14 @@ func cleanupTestData() { } // Also clean by username pattern - m.DeleteWhere(model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "preferred_username", OP: "like", Value: "testuser%"}, - }, - }) + usernamePatterns := []string{"testuser%", "%oauth_%", "%deletetest%"} + for _, pattern := range usernamePatterns { + userModel.DestroyWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "preferred_username", OP: "like", Value: pattern}, + }, + }) + } } // setupTestUser creates a user in database for testing diff --git a/openapi/oauth/types/interfaces.go b/openapi/oauth/types/interfaces.go index d2a7d99f..591f9b9b 100644 --- a/openapi/oauth/types/interfaces.go +++ b/openapi/oauth/types/interfaces.go @@ -197,6 +197,7 @@ type UserProvider interface { GetUserOAuthAccounts(ctx context.Context, userID string) ([]maps.MapStrAny, error) UpdateOAuthAccount(ctx context.Context, provider string, subject string, oauthData maps.MapStrAny) error DeleteOAuthAccount(ctx context.Context, provider string, subject string) error + DeleteUserOAuthAccounts(ctx context.Context, userID string) error GetOAuthAccounts(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) PaginateOAuthAccounts(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)