yao/openapi/oauth/providers/user/default_test.go
Max fb20eb54c5 Add user model integration and enhance OAuth user provider
- Introduced a new user model `yao/models/user.mod.yao` to support user management functionalities.
- Updated the OAuth service to utilize the new `DefaultUser` provider, enhancing user authentication and management capabilities.
- Refactored user retrieval methods to align with the new user model structure, ensuring compatibility and improved functionality.
- Added token management methods to the user provider interface, streamlining token handling processes.
- Enhanced test utilities to include the new user model for comprehensive testing coverage.
2025-07-18 11:31:39 +08:00

1131 lines
31 KiB
Go

package user
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 TestTokenOperations(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,
})
// Clean up
tokenStore.Clear()
t.Run("store and get token", func(t *testing.T) {
tokenData := createTestToken("test-subject")
err := user.StoreToken("test-token", tokenData, 1*time.Hour)
assert.NoError(t, err)
exists := user.TokenExists("test-token")
assert.True(t, exists)
retrievedData, err := user.GetTokenData("test-token")
assert.NoError(t, err)
assert.Equal(t, tokenData["subject"], retrievedData["subject"])
})
t.Run("revoke token", func(t *testing.T) {
tokenData := createTestToken("test-subject")
err := user.StoreToken("test-token-revoke", tokenData, 1*time.Hour)
assert.NoError(t, err)
exists := user.TokenExists("test-token-revoke")
assert.True(t, exists)
err = user.RevokeToken("test-token-revoke")
assert.NoError(t, err)
exists = user.TokenExists("test-token-revoke")
assert.False(t, exists)
})
t.Run("non-existent token", func(t *testing.T) {
exists := user.TokenExists("non-existent")
assert.False(t, exists)
_, err := user.GetTokenData("non-existent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "token not found")
})
})
}
}
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 TestGetUserByAccessToken(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,
})
// Clean up
tokenStore.Clear()
// Create test user
testUser := createTestUser("token1")
setupTestUser(t, testUser)
ctx := context.Background()
t.Run("get user by access token", func(t *testing.T) {
// Store token
tokenData := createTestToken(testUser.Subject)
err := user.StoreToken("test-access-token", tokenData, 1*time.Hour)
require.NoError(t, err)
// Get user by token
retrievedUser, err := user.GetUserByAccessToken(ctx, "test-access-token")
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"])
})
t.Run("non-existent token", func(t *testing.T) {
retrievedUser, err := user.GetUserByAccessToken(ctx, "non-existent-token")
assert.Error(t, err)
assert.Nil(t, retrievedUser)
assert.Contains(t, err.Error(), "token not found")
})
t.Run("invalid token format", func(t *testing.T) {
// Store invalid token data
invalidTokenData := "invalid-token-data"
tokenStore.Set(user.tokenKey("invalid-token"), invalidTokenData, 1*time.Hour)
retrievedUser, err := user.GetUserByAccessToken(ctx, "invalid-token")
assert.Error(t, err)
assert.Nil(t, retrievedUser)
assert.Contains(t, err.Error(), "invalid token data format")
})
})
}
}
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)
})
})
}
}