P1-02: OAuth ExchangeCode and GetUserInfo now accept context parameter
to properly propagate request context to HTTP calls
P1-16: AuthProvider isAuthenticated now uses single source of truth
(effectiveUser !== null) instead of double-checking both
React state and module-level function
450 lines
14 KiB
Go
450 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/user-management-system/internal/auth"
|
|
"github.com/user-management-system/internal/cache"
|
|
"github.com/user-management-system/internal/domain"
|
|
"github.com/user-management-system/internal/repository"
|
|
gormsqlite "gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Mock OAuth Manager
|
|
// =============================================================================
|
|
|
|
type mockOAuthManager struct {
|
|
authURL string
|
|
exchangeErr error
|
|
userInfoErr error
|
|
oauthUser *auth.OAuthUser
|
|
providers []auth.OAuthProviderInfo
|
|
config *auth.OAuthConfig
|
|
}
|
|
|
|
func (m *mockOAuthManager) GetAuthURL(provider auth.OAuthProvider, state string) (string, error) {
|
|
return m.authURL, nil
|
|
}
|
|
|
|
func (m *mockOAuthManager) ExchangeCode(ctx context.Context, provider auth.OAuthProvider, code string) (*auth.OAuthToken, error) {
|
|
if m.exchangeErr != nil {
|
|
return nil, m.exchangeErr
|
|
}
|
|
return &auth.OAuthToken{AccessToken: "mock-token"}, nil
|
|
}
|
|
|
|
func (m *mockOAuthManager) GetUserInfo(ctx context.Context, provider auth.OAuthProvider, token *auth.OAuthToken) (*auth.OAuthUser, error) {
|
|
if m.userInfoErr != nil {
|
|
return nil, m.userInfoErr
|
|
}
|
|
if m.oauthUser != nil {
|
|
return m.oauthUser, nil
|
|
}
|
|
return &auth.OAuthUser{
|
|
OpenID: "mock-openid",
|
|
UnionID: "mock-unionid",
|
|
Nickname: "Mock User",
|
|
Email: "mock@test.com",
|
|
Avatar: "https://example.com/avatar.png",
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockOAuthManager) ValidateToken(token string) (bool, error) {
|
|
return token != "", nil
|
|
}
|
|
|
|
func (m *mockOAuthManager) GetConfig(provider auth.OAuthProvider) (*auth.OAuthConfig, bool) {
|
|
if m.config != nil {
|
|
return m.config, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (m *mockOAuthManager) GetEnabledProviders() []auth.OAuthProviderInfo {
|
|
return m.providers
|
|
}
|
|
|
|
// =============================================================================
|
|
// LoginByCode Internal Tests
|
|
// =============================================================================
|
|
|
|
func setupLoginByCodeInternalTestEnv(t *testing.T) (*AuthService, *gorm.DB) {
|
|
t.Helper()
|
|
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
DriverName: "sqlite",
|
|
DSN: fmt.Sprintf("file:logincode_internal_test_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
|
}), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
if err := db.AutoMigrate(&domain.User{}, &domain.LoginLog{}); err != nil {
|
|
t.Fatalf("failed to migrate: %v", err)
|
|
}
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
loginLogRepo := repository.NewLoginLogRepository(db)
|
|
socialRepo, _ := repository.NewSocialAccountRepository(db)
|
|
jwtManager, _ := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
HS256Secret: fmt.Sprintf("test-secret-%d", time.Now().UnixNano()),
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
})
|
|
|
|
l1Cache := cache.NewL1Cache()
|
|
l2Cache := cache.NewRedisCache(false)
|
|
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
|
|
|
|
svc := NewAuthService(userRepo, socialRepo, jwtManager, cacheManager, 8, 5, 15*time.Minute)
|
|
svc.SetLoginLogRepository(loginLogRepo)
|
|
|
|
return svc, db
|
|
}
|
|
|
|
func TestLoginByCode_Internal(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("LoginByCode with nil service", func(t *testing.T) {
|
|
var nilSvc *AuthService
|
|
_, err := nilSvc.LoginByCode(ctx, "13800138000", "123456", "127.0.0.1")
|
|
if err == nil {
|
|
t.Error("Expected error for nil service")
|
|
}
|
|
})
|
|
|
|
t.Run("LoginByCode without SMS service configured", func(t *testing.T) {
|
|
svc, _ := setupLoginByCodeInternalTestEnv(t)
|
|
_, err := svc.LoginByCode(ctx, "13800138000", "123456", "127.0.0.1")
|
|
if err == nil {
|
|
t.Error("Expected error when SMS service not configured")
|
|
}
|
|
})
|
|
|
|
t.Run("LoginByCode with empty phone", func(t *testing.T) {
|
|
svc, _ := setupLoginByCodeInternalTestEnv(t)
|
|
smsProvider := &mockSMSProvider{}
|
|
smsCodeSvc := NewSMSCodeService(smsProvider, &mockCacheForSMS{}, DefaultSMSCodeConfig())
|
|
svc.SetSMSCodeService(smsCodeSvc)
|
|
|
|
_, err := svc.LoginByCode(ctx, "", "123456", "127.0.0.1")
|
|
if err == nil {
|
|
t.Error("Expected error for empty phone")
|
|
}
|
|
})
|
|
|
|
t.Run("LoginByCode for non-existent phone", func(t *testing.T) {
|
|
svc, _ := setupLoginByCodeInternalTestEnv(t)
|
|
smsProvider := &mockSMSProvider{}
|
|
smsCodeSvc := NewSMSCodeService(smsProvider, &mockCacheForSMS{}, DefaultSMSCodeConfig())
|
|
svc.SetSMSCodeService(smsCodeSvc)
|
|
|
|
_, err := svc.LoginByCode(ctx, "19999999999", "123456", "127.0.0.1")
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent phone")
|
|
}
|
|
})
|
|
|
|
t.Run("LoginByCode for locked user", func(t *testing.T) {
|
|
svc, db := setupLoginByCodeInternalTestEnv(t)
|
|
smsProvider := &mockSMSProvider{}
|
|
smsCodeSvc := NewSMSCodeService(smsProvider, &mockCacheForSMS{}, DefaultSMSCodeConfig())
|
|
svc.SetSMSCodeService(smsCodeSvc)
|
|
|
|
phone := "13800138002"
|
|
user := &domain.User{
|
|
Username: "lockeduser",
|
|
Phone: &phone,
|
|
Password: "$2a$10$hash",
|
|
Status: domain.UserStatusLocked,
|
|
}
|
|
db.Create(user)
|
|
|
|
_, err := svc.LoginByCode(ctx, "13800138002", "123456", "127.0.0.1")
|
|
if err == nil {
|
|
t.Error("Expected error for locked user")
|
|
}
|
|
})
|
|
|
|
t.Run("LoginByCode for inactive user", func(t *testing.T) {
|
|
svc, db := setupLoginByCodeInternalTestEnv(t)
|
|
smsProvider := &mockSMSProvider{}
|
|
smsCodeSvc := NewSMSCodeService(smsProvider, &mockCacheForSMS{}, DefaultSMSCodeConfig())
|
|
svc.SetSMSCodeService(smsCodeSvc)
|
|
|
|
phone := "13800138003"
|
|
user := &domain.User{
|
|
Username: "inactiveuser",
|
|
Phone: &phone,
|
|
Password: "$2a$10$hash",
|
|
Status: domain.UserStatusInactive,
|
|
}
|
|
db.Create(user)
|
|
|
|
_, err := svc.LoginByCode(ctx, "13800138003", "123456", "127.0.0.1")
|
|
if err == nil {
|
|
t.Error("Expected error for inactive user")
|
|
}
|
|
})
|
|
|
|
t.Run("LoginByCode success", func(t *testing.T) {
|
|
svc, db := setupLoginByCodeInternalTestEnv(t)
|
|
cacheWithCode := &mockCacheWithGet{getResult: "123456", getFound: true}
|
|
smsCodeSvc := NewSMSCodeService(&mockSMSProvider{}, cacheWithCode, DefaultSMSCodeConfig())
|
|
svc.SetSMSCodeService(smsCodeSvc)
|
|
|
|
phone := "13800138004"
|
|
user := &domain.User{
|
|
Username: "successuser",
|
|
Phone: &phone,
|
|
Password: "$2a$10$hash",
|
|
Status: domain.UserStatusActive,
|
|
}
|
|
db.Create(user)
|
|
|
|
resp, err := svc.LoginByCode(ctx, "13800138004", "123456", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("LoginByCode failed: %v", err)
|
|
}
|
|
if resp.AccessToken == "" {
|
|
t.Error("Expected access token")
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// OAuthCallback Internal Tests
|
|
// =============================================================================
|
|
|
|
func TestOAuthCallback_Internal(t *testing.T) {
|
|
t.Run("OAuthCallback with nil service", func(t *testing.T) {
|
|
var nilSvc *AuthService
|
|
_, err := nilSvc.OAuthCallback(context.Background(), "github", "code123")
|
|
if err == nil {
|
|
t.Error("Expected error for nil service")
|
|
}
|
|
})
|
|
|
|
t.Run("OAuthCallback without OAuth manager", func(t *testing.T) {
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
DriverName: "sqlite",
|
|
DSN: fmt.Sprintf("file:oauth_no_manager_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
|
}), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
db.AutoMigrate(&domain.User{}, &domain.SocialAccount{})
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
socialRepo, _ := repository.NewSocialAccountRepository(db)
|
|
jwtManager, _ := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
HS256Secret: "test-secret",
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
})
|
|
|
|
svc := NewAuthService(userRepo, socialRepo, jwtManager, nil, 8, 5, 15*time.Minute)
|
|
|
|
_, err = svc.OAuthCallback(context.Background(), "github", "code123")
|
|
if err == nil {
|
|
t.Error("Expected error when OAuth manager not configured")
|
|
}
|
|
})
|
|
|
|
t.Run("OAuthCallback with exchange error", func(t *testing.T) {
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
DriverName: "sqlite",
|
|
DSN: fmt.Sprintf("file:oauth_exchange_err_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
|
}), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
db.AutoMigrate(&domain.User{}, &domain.SocialAccount{})
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
socialRepo, _ := repository.NewSocialAccountRepository(db)
|
|
jwtManager, _ := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
HS256Secret: "test-secret",
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
})
|
|
|
|
svc := NewAuthService(userRepo, socialRepo, jwtManager, nil, 8, 5, 15*time.Minute)
|
|
svc.oauthManager = &mockOAuthManager{exchangeErr: fmt.Errorf("exchange failed")}
|
|
|
|
_, err = svc.OAuthCallback(context.Background(), "github", "code123")
|
|
if err == nil {
|
|
t.Error("Expected error when exchange fails")
|
|
}
|
|
})
|
|
|
|
t.Run("OAuthCallback with user info error", func(t *testing.T) {
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
DriverName: "sqlite",
|
|
DSN: fmt.Sprintf("file:oauth_userinfo_err_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
|
}), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
db.AutoMigrate(&domain.User{}, &domain.SocialAccount{})
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
socialRepo, _ := repository.NewSocialAccountRepository(db)
|
|
jwtManager, _ := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
HS256Secret: "test-secret",
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
})
|
|
|
|
svc := NewAuthService(userRepo, socialRepo, jwtManager, nil, 8, 5, 15*time.Minute)
|
|
svc.oauthManager = &mockOAuthManager{userInfoErr: fmt.Errorf("user info failed")}
|
|
|
|
_, err = svc.OAuthCallback(context.Background(), "github", "code123")
|
|
if err == nil {
|
|
t.Error("Expected error when user info fails")
|
|
}
|
|
})
|
|
|
|
t.Run("OAuthCallback success with new user", func(t *testing.T) {
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
DriverName: "sqlite",
|
|
DSN: fmt.Sprintf("file:oauth_new_user_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
|
}), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
db.AutoMigrate(&domain.User{}, &domain.SocialAccount{}, &domain.LoginLog{})
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
socialRepo, _ := repository.NewSocialAccountRepository(db)
|
|
loginLogRepo := repository.NewLoginLogRepository(db)
|
|
jwtManager, _ := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
HS256Secret: "test-secret",
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
})
|
|
|
|
l1Cache := cache.NewL1Cache()
|
|
l2Cache := cache.NewRedisCache(false)
|
|
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
|
|
|
|
svc := NewAuthService(userRepo, socialRepo, jwtManager, cacheManager, 8, 5, 15*time.Minute)
|
|
svc.oauthManager = &mockOAuthManager{}
|
|
svc.SetLoginLogRepository(loginLogRepo)
|
|
|
|
resp, err := svc.OAuthCallback(context.Background(), "github", "code123")
|
|
if err != nil {
|
|
t.Fatalf("OAuthCallback failed: %v", err)
|
|
}
|
|
if resp.AccessToken == "" {
|
|
t.Error("Expected access token")
|
|
}
|
|
})
|
|
|
|
t.Run("OAuthCallback success with existing social account", func(t *testing.T) {
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
DriverName: "sqlite",
|
|
DSN: fmt.Sprintf("file:oauth_existing_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
|
}), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
db.AutoMigrate(&domain.User{}, &domain.SocialAccount{}, &domain.LoginLog{})
|
|
|
|
// Create existing user and social account
|
|
user := &domain.User{
|
|
Username: "existinguser",
|
|
Password: "$2a$10$hash",
|
|
Status: domain.UserStatusActive,
|
|
}
|
|
db.Create(user)
|
|
|
|
socialAccount := &domain.SocialAccount{
|
|
UserID: user.ID,
|
|
Provider: "github",
|
|
OpenID: "mock-openid",
|
|
Status: domain.SocialAccountStatusActive,
|
|
}
|
|
db.Create(socialAccount)
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
socialRepo, _ := repository.NewSocialAccountRepository(db)
|
|
loginLogRepo := repository.NewLoginLogRepository(db)
|
|
jwtManager, _ := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
HS256Secret: "test-secret",
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
})
|
|
|
|
l1Cache := cache.NewL1Cache()
|
|
l2Cache := cache.NewRedisCache(false)
|
|
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
|
|
|
|
svc := NewAuthService(userRepo, socialRepo, jwtManager, cacheManager, 8, 5, 15*time.Minute)
|
|
svc.oauthManager = &mockOAuthManager{}
|
|
svc.SetLoginLogRepository(loginLogRepo)
|
|
|
|
resp, err := svc.OAuthCallback(context.Background(), "github", "code123")
|
|
if err != nil {
|
|
t.Fatalf("OAuthCallback failed: %v", err)
|
|
}
|
|
if resp.AccessToken == "" {
|
|
t.Error("Expected access token")
|
|
}
|
|
if resp.User.Username != "existinguser" {
|
|
t.Errorf("Expected username 'existinguser', got %s", resp.User.Username)
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// OAuthBindCallback Tests
|
|
// =============================================================================
|
|
|
|
func TestOAuthBindCallback_Internal(t *testing.T) {
|
|
t.Run("OAuthBindCallback with nil service", func(t *testing.T) {
|
|
var nilSvc *AuthService
|
|
_, err := nilSvc.OAuthBindCallback(context.Background(), 1, "github", "code123")
|
|
if err == nil {
|
|
t.Error("Expected error for nil service")
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// StartSocialAccountBinding Tests
|
|
// =============================================================================
|
|
|
|
func TestStartSocialAccountBinding_Internal(t *testing.T) {
|
|
t.Run("StartSocialAccountBinding with nil service", func(t *testing.T) {
|
|
var nilSvc *AuthService
|
|
_, _, err := nilSvc.StartSocialAccountBinding(context.Background(), 1, "github", "", "", "")
|
|
if err == nil {
|
|
t.Error("Expected error for nil service")
|
|
}
|
|
})
|
|
}
|