diff --git a/openapi/oauth/providers/user/removed_func_ref/default.go b/openapi/oauth/providers/user/removed_func_ref/default.go deleted file mode 100644 index 0314efd2..00000000 --- a/openapi/oauth/providers/user/removed_func_ref/default.go +++ /dev/null @@ -1,864 +0,0 @@ -package removedfuncref - -import ( - "context" - "crypto/rand" - "crypto/sha1" - "crypto/sha256" - "crypto/sha512" - "encoding/base32" - "encoding/binary" - "fmt" - "hash" - "math" - "net/url" - "reflect" - "strings" - "time" - - "github.com/yaoapp/gou/model" - "github.com/yaoapp/gou/store" -) - -// Safe user fields that can be displayed to users -var ( - // PublicUserFields contains fields that can be safely returned to users - PublicUserFields = []interface{}{ - "id", "subject", "username", "email", "first_name", "last_name", - "full_name", "avatar_url", "mobile", "address", "scopes", "status", - "email_verified", "mobile_verified", "two_factor_enabled", - "last_login_at", "metadata", "preferences", "created_at", "updated_at", - } - - // BasicUserFields contains minimal fields for basic user info - BasicUserFields = []interface{}{ - "id", "subject", "username", "email", "first_name", "last_name", - "full_name", "avatar_url", "status", "email_verified", "mobile_verified", - } - - // AuthUserFields contains fields needed for authentication - AuthUserFields = []interface{}{ - "id", "subject", "username", "email", "password_hash", "scopes", "status", - "email_verified", "mobile_verified", "two_factor_enabled", "last_login_at", - } - - // TwoFactorUserFields contains fields needed for two-factor authentication - TwoFactorUserFields = []interface{}{ - "id", "two_factor_enabled", "two_factor_secret", "two_factor_algorithm", - "two_factor_digits", "two_factor_period", "two_factor_recovery_codes", - } -) - -// DefaultUser provides a default implementation of UserProvider -type DefaultUser struct { - prefix string - model string - cache store.Store - tokenStore store.Store -} - -// DefaultUserOptions provides options for the DefaultUser -type DefaultUserOptions struct { - Prefix string - Model string // bind to a specific user model - Cache store.Store - TokenStore store.Store // store for OAuth tokens -} - -// NewDefaultUser creates a new DefaultUser -func NewDefaultUser(options *DefaultUserOptions) *DefaultUser { - // Set default model name if not specified - modelName := options.Model - if modelName == "" { - modelName = "__yao.user" - } - - return &DefaultUser{ - prefix: options.Prefix, - model: modelName, - cache: options.Cache, - tokenStore: options.TokenStore, - } -} - -// Key generation methods - -func (u *DefaultUser) tokenKey(accessToken string) string { - return fmt.Sprintf("%s:token:%s", u.prefix, accessToken) -} - -func (u *DefaultUser) cacheKey(userID string) string { - return fmt.Sprintf("%s:user:%s", u.prefix, userID) -} - -func (u *DefaultUser) subjectCacheKey(subject string) string { - return fmt.Sprintf("%s:user:subject:%s", u.prefix, subject) -} - -func (u *DefaultUser) usernameCacheKey(username string) string { - return fmt.Sprintf("%s:user:username:%s", u.prefix, username) -} - -func (u *DefaultUser) emailCacheKey(email string) string { - return fmt.Sprintf("%s:user:email:%s", u.prefix, email) -} - -// GetUserByAccessToken retrieves user information using an access token -func (u *DefaultUser) GetUserByAccessToken(ctx context.Context, accessToken string) (interface{}, error) { - // Get token information from tokenStore - tokenData, exists := u.tokenStore.Get(u.tokenKey(accessToken)) - if !exists { - return nil, fmt.Errorf("token not found") - } - - // Parse token data to get user subject - var tokenInfo map[string]interface{} - var ok bool - - // Try to convert to map[string]interface{} directly - if tokenInfo, ok = tokenData.(map[string]interface{}); !ok { - // If direct conversion fails, try to handle other possible types - switch v := tokenData.(type) { - case map[interface{}]interface{}: - // Convert map[interface{}]interface{} to map[string]interface{} - tokenInfo = make(map[string]interface{}) - for key, val := range v { - if keyStr, ok := key.(string); ok { - tokenInfo[keyStr] = val - } - } - default: - // Try to convert using map[string]interface{} casting - // This handles primitive.M and other MongoDB types - if reflect.TypeOf(v).Kind() == reflect.Map { - tokenInfo = make(map[string]interface{}) - rv := reflect.ValueOf(v) - for _, key := range rv.MapKeys() { - if keyStr, ok := key.Interface().(string); ok { - tokenInfo[keyStr] = rv.MapIndex(key).Interface() - } - } - if len(tokenInfo) == 0 { - return nil, fmt.Errorf("invalid token data format: %T", tokenData) - } - } else { - return nil, fmt.Errorf("invalid token data format: %T", tokenData) - } - } - } - - subject, ok := tokenInfo["subject"].(string) - if !ok { - return nil, fmt.Errorf("invalid subject in token") - } - - // Get user by subject - return u.GetUserBySubject(ctx, subject) -} - -// GetUserBySubject retrieves user information using a subject identifier -func (u *DefaultUser) GetUserBySubject(ctx context.Context, subject string) (interface{}, error) { - // Try cache first if available - if u.cache != nil { - if cached, ok := u.cache.Get(u.subjectCacheKey(subject)); ok { - return cached, nil - } - } - - // Get user from database using the model - m := model.Select(u.model) - - user, err := m.Get(model.QueryParam{ - Select: PublicUserFields, - Wheres: []model.QueryWhere{ - {Column: "subject", Value: subject}, - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to get user by subject: %w", err) - } - - if len(user) == 0 { - return nil, fmt.Errorf("user not found") - } - - userData := user[0] - - // Cache the result if cache is available - if u.cache != nil { - u.cache.Set(u.subjectCacheKey(subject), userData, 5*time.Minute) - } - - return userData, nil -} - -// ValidateUserScope validates if a user has access to requested scopes -func (u *DefaultUser) ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) { - var user interface{} - var err error - - // Try cache first if available - if u.cache != nil { - if cached, ok := u.cache.Get(u.cacheKey(userID)); ok { - user = cached - } - } - - // If not in cache, get from database - if user == nil { - m := model.Select(u.model) - user, err = m.Find(userID, model.QueryParam{ - Select: []interface{}{"scopes", "status"}, - }) - - if err != nil { - return false, fmt.Errorf("failed to get user: %w", err) - } - - // Cache the result if cache is available - if u.cache != nil { - u.cache.Set(u.cacheKey(userID), user, 5*time.Minute) - } - } - - // Check if user data is valid - if user == nil { - return false, fmt.Errorf("user not found") - } - - // Convert user to map for indexing - var userMap map[string]interface{} - switch v := user.(type) { - case map[string]interface{}: - userMap = v - default: - // Try to convert using reflection if it's a map-like type - if reflect.TypeOf(v).Kind() == reflect.Map { - userMap = make(map[string]interface{}) - rv := reflect.ValueOf(v) - for _, key := range rv.MapKeys() { - if keyStr, ok := key.Interface().(string); ok { - userMap[keyStr] = rv.MapIndex(key).Interface() - } - } - } else { - return false, fmt.Errorf("invalid user data format") - } - } - - // Check if user is active - if status, ok := userMap["status"].(string); ok && status != "active" { - return false, fmt.Errorf("user is not active") - } - - // Get user scopes - userScopes, ok := userMap["scopes"].([]interface{}) - if !ok { - // If no scopes defined, deny access - return false, nil - } - - // Convert user scopes to string slice - userScopeStrings := make([]string, len(userScopes)) - for i, scope := range userScopes { - if scopeStr, ok := scope.(string); ok { - userScopeStrings[i] = scopeStr - } - } - - // Check if user has all requested scopes - for _, requestedScope := range scopes { - hasScope := false - for _, userScope := range userScopeStrings { - if userScope == requestedScope { - hasScope = true - break - } - } - if !hasScope { - return false, nil - } - } - - return true, nil -} - -// // StoreToken stores a token in the token store with expiration time -// func (u *DefaultUser) StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error { -// return u.tokenStore.Set(u.tokenKey(accessToken), tokenData, expiration) -// } - -// // RevokeToken revokes a token by removing it from the token store -// func (u *DefaultUser) RevokeToken(accessToken string) error { -// u.tokenStore.Del(u.tokenKey(accessToken)) -// return nil -// } - -// // TokenExists checks if a token exists in the token store -// func (u *DefaultUser) TokenExists(accessToken string) bool { -// _, exists := u.tokenStore.Get(u.tokenKey(accessToken)) -// return exists -// } - -// // GetTokenData retrieves token data from the token store -// func (u *DefaultUser) GetTokenData(accessToken string) (map[string]interface{}, error) { -// tokenData, exists := u.tokenStore.Get(u.tokenKey(accessToken)) -// if !exists { -// return nil, fmt.Errorf("token not found") -// } - -// // Try to convert to map[string]interface{} directly -// if tokenInfo, ok := tokenData.(map[string]interface{}); ok { -// return tokenInfo, nil -// } - -// // If direct conversion fails, try to handle other possible types -// // This handles cases where MongoDB might return different types -// switch v := tokenData.(type) { -// case map[string]interface{}: -// return v, nil -// case map[interface{}]interface{}: -// // Convert map[interface{}]interface{} to map[string]interface{} -// result := make(map[string]interface{}) -// for key, val := range v { -// if keyStr, ok := key.(string); ok { -// result[keyStr] = val -// } -// } -// return result, nil -// default: -// // Try to convert using map[string]interface{} casting -// // This handles primitive.M and other MongoDB types -// if reflect.TypeOf(v).Kind() == reflect.Map { -// result := make(map[string]interface{}) -// rv := reflect.ValueOf(v) -// for _, key := range rv.MapKeys() { -// if keyStr, ok := key.Interface().(string); ok { -// result[keyStr] = rv.MapIndex(key).Interface() -// } -// } -// if len(result) > 0 { -// return result, nil -// } -// } -// return nil, fmt.Errorf("invalid token data format: %T", tokenData) -// } -// } - -// CreateUser creates a new user in the database -func (u *DefaultUser) CreateUser(userData map[string]interface{}) (interface{}, error) { - m := model.Select(u.model) - userID, err := m.Create(userData) - if err != nil { - return nil, err - } - - // Note: No need to cache newly created user data since it will be cached - // when accessed for the first time through other methods - - return userID, nil -} - -// UpdateUserLastLogin updates the user's last login timestamp -func (u *DefaultUser) UpdateUserLastLogin(userID interface{}) error { - m := model.Select(u.model) - err := m.Update(userID, map[string]interface{}{ - "last_login_at": time.Now(), - }) - - if err != nil { - return err - } - - // Clear cache for this user since data has changed - if u.cache != nil { - userIDStr := fmt.Sprintf("%v", userID) - u.cache.Del(u.cacheKey(userIDStr)) - } - - return nil -} - -// GetUserByUsername retrieves user by username -func (u *DefaultUser) GetUserByUsername(username string) (interface{}, error) { - // Try cache first if available - if u.cache != nil { - if cached, ok := u.cache.Get(u.usernameCacheKey(username)); ok { - return cached, nil - } - } - - m := model.Select(u.model) - - users, err := m.Get(model.QueryParam{ - Select: PublicUserFields, - Wheres: []model.QueryWhere{ - {Column: "username", Value: username}, - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to get user by username: %w", err) - } - - if len(users) == 0 { - return nil, fmt.Errorf("user not found") - } - - userData := users[0] - - // Cache the result if cache is available - if u.cache != nil { - u.cache.Set(u.usernameCacheKey(username), userData, 5*time.Minute) - } - - return userData, nil -} - -// GetUserByEmail retrieves user by email -func (u *DefaultUser) GetUserByEmail(email string) (interface{}, error) { - // Try cache first if available - if u.cache != nil { - if cached, ok := u.cache.Get(u.emailCacheKey(email)); ok { - return cached, nil - } - } - - m := model.Select(u.model) - - users, err := m.Get(model.QueryParam{ - Select: PublicUserFields, - Wheres: []model.QueryWhere{ - {Column: "email", Value: email}, - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to get user by email: %w", err) - } - - if len(users) == 0 { - return nil, fmt.Errorf("user not found") - } - - userData := users[0] - - // Cache the result if cache is available - if u.cache != nil { - u.cache.Set(u.emailCacheKey(email), userData, 5*time.Minute) - } - - return userData, nil -} - -// GenerateTOTPSecret generates a new TOTP secret for user -func (u *DefaultUser) GenerateTOTPSecret(ctx context.Context, userID string, issuer string, accountName string) (string, string, error) { - // Generate a random 20-byte secret - secret := make([]byte, 20) - if _, err := rand.Read(secret); err != nil { - return "", "", fmt.Errorf("failed to generate secret: %w", err) - } - - // Encode secret as Base32 - secretBase32 := base32.StdEncoding.EncodeToString(secret) - secretBase32 = strings.TrimRight(secretBase32, "=") // Remove padding - - // Set default values - if issuer == "" { - issuer = "YAO OAuth" - } - if accountName == "" { - accountName = userID - } - - // Generate QR code URL - qrURL := u.generateQRCodeURL(secretBase32, issuer, accountName) - - return secretBase32, qrURL, nil -} - -// EnableTwoFactor enables two-factor authentication for user -func (u *DefaultUser) EnableTwoFactor(ctx context.Context, userID string, secret string, code string) error { - // Verify the provided code with the secret - if !u.verifyTOTPWithSecret(secret, code, "SHA1", 6, 30) { - return fmt.Errorf("invalid verification code") - } - - // Generate recovery codes - recoveryCodes, err := u.generateRecoveryCodesList() - if err != nil { - return fmt.Errorf("failed to generate recovery codes: %w", err) - } - - // Update user record - m := model.Select(u.model) - now := time.Now() - err = m.Update(userID, map[string]interface{}{ - "two_factor_enabled": true, - "two_factor_secret": secret, - "two_factor_recovery_codes": recoveryCodes, - "two_factor_enabled_at": now, - "two_factor_last_verified_at": now, - }) - - if err != nil { - return fmt.Errorf("failed to enable two-factor authentication: %w", err) - } - - // Clear user cache - if u.cache != nil { - u.cache.Del(u.cacheKey(userID)) - } - - return nil -} - -// DisableTwoFactor disables two-factor authentication for user -func (u *DefaultUser) DisableTwoFactor(ctx context.Context, userID string, code string) error { - // Get current user data - m := model.Select(u.model) - user, err := m.Find(userID, model.QueryParam{ - Select: []interface{}{"two_factor_secret", "two_factor_recovery_codes"}, - }) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - if user == nil { - return fmt.Errorf("user not found") - } - - // Verify code (either TOTP or recovery code) - verified := false - if secret, ok := user["two_factor_secret"].(string); ok && secret != "" { - verified = u.verifyTOTPWithSecret(secret, code, "SHA1", 6, 30) - } - - if !verified { - // Try recovery code - if recoveryCodes, ok := user["two_factor_recovery_codes"].([]interface{}); ok { - for _, rc := range recoveryCodes { - if rcStr, ok := rc.(string); ok && rcStr == code { - verified = true - break - } - } - } - } - - if !verified { - return fmt.Errorf("invalid verification code") - } - - // Disable two-factor authentication - err = m.Update(userID, map[string]interface{}{ - "two_factor_enabled": false, - "two_factor_secret": nil, - "two_factor_recovery_codes": nil, - "two_factor_enabled_at": nil, - "two_factor_last_verified_at": nil, - }) - - if err != nil { - return fmt.Errorf("failed to disable two-factor authentication: %w", err) - } - - // Clear user cache - if u.cache != nil { - u.cache.Del(u.cacheKey(userID)) - } - - return nil -} - -// VerifyTOTPCode verifies a TOTP code for user -func (u *DefaultUser) VerifyTOTPCode(ctx context.Context, userID string, code string) (bool, error) { - // Get user data - m := model.Select(u.model) - user, err := m.Find(userID, model.QueryParam{ - Select: []interface{}{"two_factor_enabled", "two_factor_secret", "two_factor_algorithm", "two_factor_digits", "two_factor_period"}, - }) - if err != nil { - return false, fmt.Errorf("failed to get user: %w", err) - } - - if user == nil { - return false, fmt.Errorf("user not found") - } - - // Check if two-factor is enabled - if enabled, ok := user["two_factor_enabled"].(bool); !ok || !enabled { - return false, fmt.Errorf("two-factor authentication is not enabled") - } - - // Get TOTP parameters - secret, _ := user["two_factor_secret"].(string) - algorithm, _ := user["two_factor_algorithm"].(string) - digits, _ := user["two_factor_digits"].(int) - period, _ := user["two_factor_period"].(int) - - // Set defaults - if algorithm == "" { - algorithm = "SHA1" - } - if digits == 0 { - digits = 6 - } - if period == 0 { - period = 30 - } - - // Verify code - verified := u.verifyTOTPWithSecret(secret, code, algorithm, digits, period) - - if verified { - // Update last verified time - m.Update(userID, map[string]interface{}{ - "two_factor_last_verified_at": time.Now(), - }) - - // Clear user cache - if u.cache != nil { - u.cache.Del(u.cacheKey(userID)) - } - } - - return verified, nil -} - -// GenerateRecoveryCodes generates new recovery codes for user -func (u *DefaultUser) GenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error) { - // Generate new recovery codes - recoveryCodes, err := u.generateRecoveryCodesList() - if err != nil { - return nil, fmt.Errorf("failed to generate recovery codes: %w", err) - } - - // Update user record - m := model.Select(u.model) - err = m.Update(userID, map[string]interface{}{ - "two_factor_recovery_codes": recoveryCodes, - }) - - if err != nil { - return nil, fmt.Errorf("failed to update recovery codes: %w", err) - } - - // Clear user cache - if u.cache != nil { - u.cache.Del(u.cacheKey(userID)) - } - - // Convert to string slice for return - result := make([]string, len(recoveryCodes)) - for i, code := range recoveryCodes { - result[i] = code.(string) - } - - return result, nil -} - -// VerifyRecoveryCode verifies and consumes a recovery code -func (u *DefaultUser) VerifyRecoveryCode(ctx context.Context, userID string, code string) (bool, error) { - // Get user data - m := model.Select(u.model) - user, err := m.Find(userID, model.QueryParam{ - Select: []interface{}{"two_factor_enabled", "two_factor_recovery_codes"}, - }) - if err != nil { - return false, fmt.Errorf("failed to get user: %w", err) - } - - if user == nil { - return false, fmt.Errorf("user not found") - } - - // Check if two-factor is enabled - if enabled, ok := user["two_factor_enabled"].(bool); !ok || !enabled { - return false, fmt.Errorf("two-factor authentication is not enabled") - } - - // Get recovery codes - recoveryCodes, ok := user["two_factor_recovery_codes"].([]interface{}) - if !ok { - return false, fmt.Errorf("no recovery codes found") - } - - // Find and remove the used code - var newRecoveryCodes []interface{} - found := false - for _, rc := range recoveryCodes { - if rcStr, ok := rc.(string); ok && rcStr == code { - found = true - // Don't add this code to the new list (consume it) - } else { - newRecoveryCodes = append(newRecoveryCodes, rc) - } - } - - if !found { - return false, nil - } - - // Update user record with remaining codes - err = m.Update(userID, map[string]interface{}{ - "two_factor_recovery_codes": newRecoveryCodes, - "two_factor_last_verified_at": time.Now(), - }) - - if err != nil { - return false, fmt.Errorf("failed to update recovery codes: %w", err) - } - - // Clear user cache - if u.cache != nil { - u.cache.Del(u.cacheKey(userID)) - } - - return true, nil -} - -// Helper methods for TOTP - -// generateQRCodeURL generates a QR code URL for TOTP setup -func (u *DefaultUser) generateQRCodeURL(secret, issuer, accountName string) string { - // Build the otpauth URL - params := url.Values{} - params.Set("secret", secret) - params.Set("issuer", issuer) - params.Set("algorithm", "SHA1") - params.Set("digits", "6") - params.Set("period", "30") - - label := fmt.Sprintf("%s:%s", issuer, accountName) - qrURL := fmt.Sprintf("otpauth://totp/%s?%s", url.QueryEscape(label), params.Encode()) - - return qrURL -} - -// generateRecoveryCodesList generates a list of recovery codes -func (u *DefaultUser) generateRecoveryCodesList() ([]interface{}, error) { - codes := make([]interface{}, 10) // Generate 10 recovery codes - - for i := 0; i < 10; i++ { - // Generate 8-character recovery code - code := make([]byte, 8) - if _, err := rand.Read(code); err != nil { - return nil, err - } - - // Convert to hex string - codeStr := fmt.Sprintf("%x", code) - codes[i] = codeStr - } - - return codes, nil -} - -// verifyTOTPWithSecret verifies a TOTP code with given parameters -func (u *DefaultUser) verifyTOTPWithSecret(secret, code, algorithm string, digits, period int) bool { - // Decode secret - secretBytes, err := base32.StdEncoding.DecodeString(secret) - if err != nil { - return false - } - - // Get current time - now := time.Now().Unix() - - // Check current time window and previous/next windows for clock skew - for i := -1; i <= 1; i++ { - timeCounter := (now + int64(i*period)) / int64(period) - expectedCode := u.generateTOTPCode(secretBytes, timeCounter, algorithm, digits) - - if expectedCode == code { - return true - } - } - - return false -} - -// generateTOTPCode generates a TOTP code -func (u *DefaultUser) generateTOTPCode(secret []byte, timeCounter int64, algorithm string, digits int) string { - // Convert time counter to byte array - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, uint64(timeCounter)) - - // Choose hash algorithm - var h hash.Hash - switch algorithm { - case "SHA256": - h = sha256.New() - case "SHA512": - h = sha512.New() - default: - h = sha1.New() - } - - // HMAC - for i := 0; i < len(secret); i++ { - h.Write([]byte{secret[i] ^ 0x36}) - } - for i := len(secret); i < h.BlockSize(); i++ { - h.Write([]byte{0x36}) - } - h.Write(buf) - innerHash := h.Sum(nil) - - h.Reset() - for i := 0; i < len(secret); i++ { - h.Write([]byte{secret[i] ^ 0x5c}) - } - for i := len(secret); i < h.BlockSize(); i++ { - h.Write([]byte{0x5c}) - } - h.Write(innerHash) - hmacHash := h.Sum(nil) - - // Dynamic truncation - offset := hmacHash[len(hmacHash)-1] & 0x0f - binCode := binary.BigEndian.Uint32(hmacHash[offset:offset+4]) & 0x7fffffff - - // Generate digits - code := binCode % uint32(math.Pow10(digits)) - - return fmt.Sprintf("%0*d", digits, code) -} - -// GetUserForAuth retrieves user information for authentication purposes (internal use only) -// This method includes sensitive fields like password_hash and should not be exposed to external APIs -func (u *DefaultUser) GetUserForAuth(ctx context.Context, identifier string, identifierType string) (interface{}, error) { - // Get user from database using the model - m := model.Select(u.model) - - var column string - switch identifierType { - case "username": - column = "username" - case "email": - column = "email" - case "subject": - column = "subject" - default: - return nil, fmt.Errorf("invalid identifier type: %s", identifierType) - } - - user, err := m.Get(model.QueryParam{ - Select: AuthUserFields, - Wheres: []model.QueryWhere{ - {Column: column, Value: identifier}, - }, - }) - - if err != nil { - return nil, fmt.Errorf("failed to get user for auth: %w", err) - } - - if len(user) == 0 { - return nil, fmt.Errorf("user not found") - } - - return user[0], nil -} diff --git a/openapi/oauth/providers/user/removed_func_ref/default_test.go b/openapi/oauth/providers/user/removed_func_ref/default_test.go deleted file mode 100644 index a31f2f52..00000000 --- a/openapi/oauth/providers/user/removed_func_ref/default_test.go +++ /dev/null @@ -1,1009 +0,0 @@ -package removedfuncref - -import ( - "context" - "fmt" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/yaoapp/gou/connector" - "github.com/yaoapp/gou/model" - "github.com/yaoapp/gou/store" - "github.com/yaoapp/gou/store/badger" - "github.com/yaoapp/gou/store/lru" - "github.com/yaoapp/yao/config" - "github.com/yaoapp/yao/test" -) - -// Store configuration for parameterized tests -type StoreConfig struct { - Name string - GetFunc func(*testing.T) store.Store -} - -// Test user data -type TestUserData struct { - ID int64 `json:"id"` - Subject string `json:"subject"` - Username string `json:"username"` - Email string `json:"email"` - PasswordHash string `json:"password_hash"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - FullName string `json:"full_name"` - AvatarURL string `json:"avatar_url"` - Mobile string `json:"mobile"` - Address string `json:"address"` - Scopes []string `json:"scopes"` - Status string `json:"status"` - EmailVerified bool `json:"email_verified"` - MobileVerified bool `json:"mobile_verified"` - TwoFactorEnabled bool `json:"two_factor_enabled"` - TwoFactorSecret string `json:"two_factor_secret"` - Metadata map[string]interface{} `json:"metadata"` - Preferences map[string]interface{} `json:"preferences"` -} - -var testUserData = &TestUserData{ - Subject: "test-subject-123", - Username: "testuser123", - Email: "test@example.com", - PasswordHash: "hashed_password_123", - FirstName: "Test", - LastName: "User", - FullName: "Test User", - AvatarURL: "https://example.com/avatar.jpg", - Mobile: "+1234567890", - Address: "123 Test Street", - Scopes: []string{"openid", "profile", "email"}, - Status: "active", - EmailVerified: true, - MobileVerified: false, - TwoFactorEnabled: false, - // TwoFactorSecret: "", - Metadata: map[string]interface{}{"test": "data"}, - Preferences: map[string]interface{}{"theme": "dark"}, -} - -// Helper function to convert various map types to map[string]interface{} -func convertToStringMap(t *testing.T, data interface{}) map[string]interface{} { - switch v := data.(type) { - case map[string]interface{}: - return v - default: - // Try to convert using reflection if it's a map-like type - if reflect.TypeOf(v).Kind() == reflect.Map { - result := make(map[string]interface{}) - rv := reflect.ValueOf(v) - for _, key := range rv.MapKeys() { - if keyStr, ok := key.Interface().(string); ok { - result[keyStr] = rv.MapIndex(key).Interface() - } - } - return result - } - t.Fatalf("Unexpected data type: %T", v) - return nil - } -} - -func TestMain(m *testing.M) { - // Setup - test.Prepare(&testing.T{}, config.Conf) - defer test.Clean() - - // Run tests - code := m.Run() - os.Exit(code) -} - -// Test helpers -func getMongoStore(t *testing.T) store.Store { - // Skip test if MongoDB is not available - host := os.Getenv("MONGO_TEST_HOST") - if host == "" { - t.Skip("MongoDB not available - set MONGO_TEST_HOST environment variable") - } - - // Create MongoDB store using connector - mongoConnector, err := connector.New("mongo", "oauth_user_test", []byte(`{ - "name": "OAuth User Test MongoDB", - "type": "mongo", - "options": { - "db": "oauth_user_test", - "hosts": [{ - "host": "`+host+`", - "port": "`+os.Getenv("MONGO_TEST_PORT")+`", - "user": "`+os.Getenv("MONGO_TEST_USER")+`", - "pass": "`+os.Getenv("MONGO_TEST_PASS")+`" - }] - } - }`)) - require.NoError(t, err) - - mongoStore, err := store.New(mongoConnector, nil) - require.NoError(t, err) - - return mongoStore -} - -func getBadgerStore(t *testing.T) store.Store { - // Create temporary directory for test database - tempDir := t.TempDir() - dbPath := filepath.Join(tempDir, "test_oauth_user_badger") - - badgerStore, err := badger.New(dbPath) - require.NoError(t, err) - - // Clean up on test completion - t.Cleanup(func() { - badgerStore.Close() - }) - - return badgerStore -} - -func getLRUCache(t *testing.T) store.Store { - cache, err := lru.New(1000) - require.NoError(t, err) - return cache -} - -// Get all available store configurations -func getStoreConfigs() []StoreConfig { - return []StoreConfig{ - {Name: "MongoDB", GetFunc: getMongoStore}, - {Name: "Badger", GetFunc: getBadgerStore}, - } -} - -// Create test user data with unique identifier -func createTestUser(id string) *TestUserData { - timestamp := time.Now().UnixNano() - uniqueID := fmt.Sprintf("%s-%d", id, timestamp) - - return &TestUserData{ - Subject: "test-subject-" + uniqueID, - Username: "testuser" + uniqueID, - Email: "test" + uniqueID + "@example.com", - PasswordHash: "hashed-password-" + uniqueID, - FirstName: "Test", - LastName: "User " + uniqueID, - FullName: "Test User " + uniqueID, - AvatarURL: "https://example.com/avatar" + uniqueID + ".jpg", - Mobile: "1234567890", - Address: "Test Address " + uniqueID, - Scopes: []string{"openid", "profile", "email"}, - Status: "active", - EmailVerified: true, - MobileVerified: true, - TwoFactorEnabled: false, - Metadata: map[string]interface{}{"test": "data"}, - Preferences: map[string]interface{}{"theme": "dark"}, - } -} - -// Create test token data -func createTestToken(subject string) map[string]interface{} { - return map[string]interface{}{ - "subject": subject, - "client_id": "test-client", - "scopes": []string{"openid", "profile", "email"}, - "expires_at": time.Now().Add(1 * time.Hour).Unix(), - "issued_at": time.Now().Unix(), - } -} - -// Setup test user in database -func setupTestUser(t *testing.T, userData *TestUserData) { - m := model.Select("__yao.user") - - // Create user - userMap := map[string]interface{}{ - "subject": userData.Subject, - "username": userData.Username, - "email": userData.Email, - "password_hash": userData.PasswordHash, - "first_name": userData.FirstName, - "last_name": userData.LastName, - "full_name": userData.FullName, - "avatar_url": userData.AvatarURL, - "mobile": userData.Mobile, - "address": userData.Address, - "scopes": userData.Scopes, - "status": userData.Status, - "email_verified": userData.EmailVerified, - "mobile_verified": userData.MobileVerified, - "two_factor_enabled": userData.TwoFactorEnabled, - "two_factor_secret": userData.TwoFactorSecret, - "metadata": userData.Metadata, - "preferences": userData.Preferences, - } - - id, err := m.Create(userMap) - require.NoError(t, err) - userData.ID = int64(id) -} - -// Clean up test data -func cleanupTestData(t *testing.T) { - m := model.Select("__yao.user") - - // Delete all test users (be more aggressive in cleanup) - _, err := m.DeleteWhere(model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "subject", OP: "like", Value: "test-subject-%"}, - }, - }) - if err != nil { - t.Logf("Warning: Failed to clean up test users by subject: %v", err) - } - - // Also clean up by username pattern - _, err = m.DeleteWhere(model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "username", OP: "like", Value: "testuser%"}, - }, - }) - if err != nil { - t.Logf("Warning: Failed to clean up test users by username: %v", err) - } -} - -func TestNewDefaultUser(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - t.Run("valid options", func(t *testing.T) { - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - assert.NotNil(t, user) - assert.Equal(t, "test:", user.prefix) - assert.Equal(t, "__yao.user", user.model) - assert.Equal(t, cache, user.cache) - assert.Equal(t, tokenStore, user.tokenStore) - }) - - t.Run("without cache", func(t *testing.T) { - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - TokenStore: tokenStore, - }) - - assert.NotNil(t, user) - assert.Nil(t, user.cache) - }) - - t.Run("without token store", func(t *testing.T) { - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - Cache: cache, - }) - - assert.NotNil(t, user) - assert.Nil(t, user.tokenStore) - }) - }) - } -} - -func TestKeyGeneration(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - t.Run("token key", func(t *testing.T) { - key := user.tokenKey("test-token") - expected := "test::token:test-token" - assert.Equal(t, expected, key) - }) - - t.Run("cache key", func(t *testing.T) { - key := user.cacheKey("123") - expected := "test::user:123" - assert.Equal(t, expected, key) - }) - - t.Run("subject cache key", func(t *testing.T) { - key := user.subjectCacheKey("test-subject") - expected := "test::user:subject:test-subject" - assert.Equal(t, expected, key) - }) - - t.Run("username cache key", func(t *testing.T) { - key := user.usernameCacheKey("testuser") - expected := "test::user:username:testuser" - assert.Equal(t, expected, key) - }) - - t.Run("email cache key", func(t *testing.T) { - key := user.emailCacheKey("test@example.com") - expected := "test::user:email:test@example.com" - assert.Equal(t, expected, key) - }) - }) - } -} - -func TestGetUserBySubject(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("subject1") - setupTestUser(t, testUser) - - ctx := context.Background() - - t.Run("get user by subject", func(t *testing.T) { - retrievedUser, err := user.GetUserBySubject(ctx, testUser.Subject) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - userMap := convertToStringMap(t, retrievedUser) - - assert.Equal(t, testUser.Subject, userMap["subject"]) - assert.Equal(t, testUser.Username, userMap["username"]) - assert.Equal(t, testUser.Email, userMap["email"]) - }) - - t.Run("get user by subject with cache", func(t *testing.T) { - // Clear cache first - cache.Clear() - - // First call should hit database - retrievedUser, err := user.GetUserBySubject(ctx, testUser.Subject) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - // Second call should hit cache - retrievedUser2, err := user.GetUserBySubject(ctx, testUser.Subject) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser2) - - userMap := convertToStringMap(t, retrievedUser2) - - assert.Equal(t, testUser.Subject, userMap["subject"]) - }) - - t.Run("non-existent subject", func(t *testing.T) { - retrievedUser, err := user.GetUserBySubject(ctx, "non-existent-subject") - assert.Error(t, err) - assert.Nil(t, retrievedUser) - assert.Contains(t, err.Error(), "user not found") - }) - }) - } -} - -func TestGetUserByUsername(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("username1") - setupTestUser(t, testUser) - - t.Run("get user by username", func(t *testing.T) { - retrievedUser, err := user.GetUserByUsername(testUser.Username) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - userMap := convertToStringMap(t, retrievedUser) - assert.Equal(t, testUser.Username, userMap["username"]) - assert.Equal(t, testUser.Email, userMap["email"]) - }) - - t.Run("non-existent username", func(t *testing.T) { - retrievedUser, err := user.GetUserByUsername("non-existent-user") - assert.Error(t, err) - assert.Nil(t, retrievedUser) - assert.Contains(t, err.Error(), "user not found") - }) - }) - } -} - -func TestGetUserByEmail(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("email1") - setupTestUser(t, testUser) - - t.Run("get user by email", func(t *testing.T) { - retrievedUser, err := user.GetUserByEmail(testUser.Email) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - userMap := convertToStringMap(t, retrievedUser) - assert.Equal(t, testUser.Email, userMap["email"]) - assert.Equal(t, testUser.Username, userMap["username"]) - }) - - t.Run("non-existent email", func(t *testing.T) { - retrievedUser, err := user.GetUserByEmail("non-existent@example.com") - assert.Error(t, err) - assert.Nil(t, retrievedUser) - assert.Contains(t, err.Error(), "user not found") - }) - }) - } -} - -func TestValidateUserScope(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("scope1") - setupTestUser(t, testUser) - - ctx := context.Background() - - t.Run("validate user scope - valid", func(t *testing.T) { - valid, err := user.ValidateUserScope(ctx, fmt.Sprintf("%d", testUser.ID), []string{"openid", "profile"}) - assert.NoError(t, err) - assert.True(t, valid) - }) - - t.Run("validate user scope - invalid", func(t *testing.T) { - valid, err := user.ValidateUserScope(ctx, fmt.Sprintf("%d", testUser.ID), []string{"admin"}) - assert.NoError(t, err) - assert.False(t, valid) - }) - - t.Run("validate user scope - inactive user", func(t *testing.T) { - // Create inactive user - inactiveUser := createTestUser("inactive") - inactiveUser.Status = "inactive" - setupTestUser(t, inactiveUser) - - valid, err := user.ValidateUserScope(ctx, fmt.Sprintf("%d", inactiveUser.ID), []string{"openid"}) - assert.Error(t, err) - assert.False(t, valid) - assert.Contains(t, err.Error(), "user is not active") - }) - - t.Run("validate user scope - non-existent user", func(t *testing.T) { - valid, err := user.ValidateUserScope(ctx, "999999", []string{"openid"}) - assert.Error(t, err) - assert.False(t, valid) - assert.Contains(t, err.Error(), "数据不存在") - }) - }) - } -} - -func TestGetUserForAuth(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("auth1") - setupTestUser(t, testUser) - - ctx := context.Background() - - t.Run("get user for auth by username", func(t *testing.T) { - retrievedUser, err := user.GetUserForAuth(ctx, testUser.Username, "username") - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - userMap := convertToStringMap(t, retrievedUser) - assert.Equal(t, testUser.Username, userMap["username"]) - // Password should be encrypted, not equal to original - assert.NotEmpty(t, userMap["password_hash"]) - assert.NotEqual(t, testUser.PasswordHash, userMap["password_hash"]) - }) - - t.Run("get user for auth by email", func(t *testing.T) { - retrievedUser, err := user.GetUserForAuth(ctx, testUser.Email, "email") - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - userMap := convertToStringMap(t, retrievedUser) - assert.Equal(t, testUser.Email, userMap["email"]) - // Password should be encrypted, not equal to original - assert.NotEmpty(t, userMap["password_hash"]) - assert.NotEqual(t, testUser.PasswordHash, userMap["password_hash"]) - }) - - t.Run("get user for auth by subject", func(t *testing.T) { - retrievedUser, err := user.GetUserForAuth(ctx, testUser.Subject, "subject") - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - userMap := convertToStringMap(t, retrievedUser) - assert.Equal(t, testUser.Subject, userMap["subject"]) - // Password should be encrypted, not equal to original - assert.NotEmpty(t, userMap["password_hash"]) - assert.NotEqual(t, testUser.PasswordHash, userMap["password_hash"]) - }) - - t.Run("get user for auth - invalid identifier type", func(t *testing.T) { - retrievedUser, err := user.GetUserForAuth(ctx, testUser.Username, "invalid") - assert.Error(t, err) - assert.Nil(t, retrievedUser) - assert.Contains(t, err.Error(), "invalid identifier type") - }) - - t.Run("get user for auth - non-existent user", func(t *testing.T) { - retrievedUser, err := user.GetUserForAuth(ctx, "non-existent", "username") - assert.Error(t, err) - assert.Nil(t, retrievedUser) - assert.Contains(t, err.Error(), "user not found") - }) - }) - } -} - -func TestCreateUser(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - t.Run("create user", func(t *testing.T) { - testUser := createTestUser("create") - - userData := map[string]interface{}{ - "subject": testUser.Subject, - "username": testUser.Username, - "email": testUser.Email, - "password_hash": testUser.PasswordHash, - "first_name": testUser.FirstName, - "last_name": testUser.LastName, - "full_name": testUser.FullName, - "status": testUser.Status, - "email_verified": testUser.EmailVerified, - "mobile_verified": testUser.MobileVerified, - "scopes": testUser.Scopes, - } - - // Create user - userID, err := user.CreateUser(userData) - assert.NoError(t, err) - assert.NotNil(t, userID) - - // Verify user was created - m := model.Select("__yao.user") - createdUser, err := m.Find(userID, model.QueryParam{}) - assert.NoError(t, err) - assert.Equal(t, userData["username"], createdUser["username"]) - assert.Equal(t, userData["email"], createdUser["email"]) - - // Verify user was created with correct default model name - user2 := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - assert.Equal(t, "__yao.user", user2.model) - }) - }) - } -} - -func TestUpdateUserLastLogin(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("login1") - setupTestUser(t, testUser) - - t.Run("update user last login", func(t *testing.T) { - err := user.UpdateUserLastLogin(testUser.ID) - assert.NoError(t, err) - - // Verify last login was updated - m := model.Select("__yao.user") - updatedUser, err := m.Find(testUser.ID, model.QueryParam{}) - assert.NoError(t, err) - assert.NotNil(t, updatedUser) - assert.NotNil(t, updatedUser["last_login_at"]) - }) - }) - } -} - -func TestTOTPGeneration(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - Model: "__yao.user", - Cache: cache, - TokenStore: tokenStore, - }) - - ctx := context.Background() - - t.Run("generate TOTP secret", func(t *testing.T) { - secret, qrURL, err := user.GenerateTOTPSecret(ctx, "test-user", "Test App", "testuser@example.com") - assert.NoError(t, err) - assert.NotEmpty(t, secret) - assert.NotEmpty(t, qrURL) - assert.Contains(t, qrURL, "otpauth://totp/") - assert.Contains(t, qrURL, "secret=") - assert.Contains(t, qrURL, "issuer=Test+App") - }) - - t.Run("generate TOTP secret with defaults", func(t *testing.T) { - secret, qrURL, err := user.GenerateTOTPSecret(ctx, "test-user", "", "") - assert.NoError(t, err) - assert.NotEmpty(t, secret) - assert.NotEmpty(t, qrURL) - assert.Contains(t, qrURL, "issuer=YAO+OAuth") - }) - }) - } -} - -func TestTOTPVerification(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - Model: "__yao.user", - Cache: cache, - TokenStore: tokenStore, - }) - - t.Run("verify TOTP with secret", func(t *testing.T) { - secret := "JBSWY3DPEHPK3PXP" // Test secret - - // Generate code for current time - now := time.Now().Unix() - timeCounter := now / 30 - expectedCode := user.generateTOTPCode([]byte("Hello!\xDE\xAD\xBE\xEF"), timeCounter, "SHA1", 6) - - // This test might be flaky due to time, so we'll test the method exists - result := user.verifyTOTPWithSecret(secret, expectedCode, "SHA1", 6, 30) - // We can't assert the exact result due to time dependencies - assert.IsType(t, false, result) - }) - - t.Run("generate TOTP code", func(t *testing.T) { - secret := []byte("Hello!\xDE\xAD\xBE\xEF") - timeCounter := int64(1234567890) - - code := user.generateTOTPCode(secret, timeCounter, "SHA1", 6) - assert.Len(t, code, 6) - assert.Regexp(t, `^\d{6}$`, code) - }) - }) - } -} - -func TestTOTPEnabledUserFlow(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("2fa1") - setupTestUser(t, testUser) - - ctx := context.Background() - - t.Run("enable two factor with invalid code", func(t *testing.T) { - // This test is limited because we can't easily generate a valid TOTP code - // In a real scenario, we'd need to coordinate the secret generation and verification - - secret := "JBSWY3DPEHPK3PXP" - // Using a mock code - in real tests, you'd generate a proper TOTP code - code := "123456" - - err := user.EnableTwoFactor(ctx, fmt.Sprintf("%d", testUser.ID), secret, code) - // This will fail with invalid code, which is expected - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid verification code") - }) - - t.Run("generate recovery codes", func(t *testing.T) { - codes, err := user.GenerateRecoveryCodes(ctx, fmt.Sprintf("%d", testUser.ID)) - assert.NoError(t, err) - assert.Len(t, codes, 10) - - for _, code := range codes { - assert.Len(t, code, 16) // 8 bytes hex = 16 characters - assert.Regexp(t, `^[0-9a-f]{16}$`, code) - } - }) - }) - } -} - -func TestHelperMethods(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - t.Run("generate QR code URL", func(t *testing.T) { - qrURL := user.generateQRCodeURL("JBSWY3DPEHPK3PXP", "Test App", "testuser@example.com") - assert.Contains(t, qrURL, "otpauth://totp/") - assert.Contains(t, qrURL, "secret=JBSWY3DPEHPK3PXP") - assert.Contains(t, qrURL, "issuer=Test+App") - assert.Contains(t, qrURL, "algorithm=SHA1") - assert.Contains(t, qrURL, "digits=6") - assert.Contains(t, qrURL, "period=30") - }) - - t.Run("generate recovery codes list", func(t *testing.T) { - codes, err := user.generateRecoveryCodesList() - assert.NoError(t, err) - assert.Len(t, codes, 10) - - for _, code := range codes { - codeStr := code.(string) - assert.Len(t, codeStr, 16) // 8 bytes hex = 16 characters - assert.Regexp(t, `^[0-9a-f]{16}$`, codeStr) - } - }) - }) - } -} - -func TestErrorHandling(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - ctx := context.Background() - - t.Run("get user by invalid subject", func(t *testing.T) { - retrievedUser, err := user.GetUserBySubject(ctx, "") - assert.Error(t, err) - assert.Nil(t, retrievedUser) - }) - - t.Run("verify TOTP code - user not found", func(t *testing.T) { - verified, err := user.VerifyTOTPCode(ctx, "999999", "123456") - assert.Error(t, err) - assert.False(t, verified) - }) - - t.Run("verify recovery code - user not found", func(t *testing.T) { - verified, err := user.VerifyRecoveryCode(ctx, "999999", "test-code") - assert.Error(t, err) - assert.False(t, verified) - }) - - t.Run("disable two factor - user not found", func(t *testing.T) { - err := user.DisableTwoFactor(ctx, "999999", "123456") - assert.Error(t, err) - }) - }) - } -} - -func TestCacheConsistency(t *testing.T) { - storeConfigs := getStoreConfigs() - - for _, config := range storeConfigs { - t.Run(config.Name, func(t *testing.T) { - cleanupTestData(t) - defer cleanupTestData(t) - - tokenStore := config.GetFunc(t) - cache := getLRUCache(t) - - user := NewDefaultUser(&DefaultUserOptions{ - Prefix: "test:", - - Cache: cache, - TokenStore: tokenStore, - }) - - // Create test user - testUser := createTestUser("cache1") - setupTestUser(t, testUser) - - ctx := context.Background() - - t.Run("cache invalidation on update", func(t *testing.T) { - // First, load user into cache - retrievedUser, err := user.GetUserBySubject(ctx, testUser.Subject) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - // Update user last login (should clear cache) - err = user.UpdateUserLastLogin(testUser.ID) - assert.NoError(t, err) - - // Verify cache was cleared by checking if the key exists - cacheKey := user.cacheKey(fmt.Sprintf("%d", testUser.ID)) - _, exists := cache.Get(cacheKey) - assert.False(t, exists) - }) - - t.Run("cache invalidation on two factor operations", func(t *testing.T) { - // Load user into cache - retrievedUser, err := user.GetUserBySubject(ctx, testUser.Subject) - assert.NoError(t, err) - assert.NotNil(t, retrievedUser) - - // Generate recovery codes (should clear cache) - codes, err := user.GenerateRecoveryCodes(ctx, fmt.Sprintf("%d", testUser.ID)) - assert.NoError(t, err) - assert.Len(t, codes, 10) - - // Verify cache was cleared - cacheKey := user.cacheKey(fmt.Sprintf("%d", testUser.ID)) - _, exists := cache.Get(cacheKey) - assert.False(t, exists) - }) - }) - } -}