Implement OAuth account management methods and enhance user provider functionality

- Added methods for creating, retrieving, updating, and deleting OAuth accounts, improving user authentication capabilities.
- Introduced default OAuth account fields and options for better configuration management.
- Enhanced error handling for OAuth account operations, ensuring robust feedback for failures.
- Updated tests to ensure unique identifiers and improved cleanup processes for test data, maintaining a clean testing environment.
This commit is contained in:
Max 2025-08-02 18:49:27 +08:00
parent 11f69b8c38
commit 7442c942db
6 changed files with 966 additions and 27 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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