⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。
钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值
internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
dingtalk_department);非 internal_only policy 在写入层与加载层均
coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
(钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义
附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
(Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults
前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
(拉取 user attribute definitions),展示 fieldEmail /
qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
766 lines
26 KiB
Go
766 lines
26 KiB
Go
//go:build unit
|
||
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
type settingRepoStub struct {
|
||
values map[string]string
|
||
err error
|
||
}
|
||
|
||
func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||
panic("unexpected Get call")
|
||
}
|
||
|
||
func (s *settingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||
if s.err != nil {
|
||
return "", s.err
|
||
}
|
||
if v, ok := s.values[key]; ok {
|
||
return v, nil
|
||
}
|
||
return "", ErrSettingNotFound
|
||
}
|
||
|
||
func (s *settingRepoStub) Set(ctx context.Context, key, value string) error {
|
||
panic("unexpected Set call")
|
||
}
|
||
|
||
func (s *settingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||
if s.err != nil {
|
||
return nil, s.err
|
||
}
|
||
result := make(map[string]string, len(keys))
|
||
for _, key := range keys {
|
||
if v, ok := s.values[key]; ok {
|
||
result[key] = v
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *settingRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||
panic("unexpected SetMultiple call")
|
||
}
|
||
|
||
func (s *settingRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||
panic("unexpected GetAll call")
|
||
}
|
||
|
||
func (s *settingRepoStub) Delete(ctx context.Context, key string) error {
|
||
panic("unexpected Delete call")
|
||
}
|
||
|
||
type emailCacheStub struct {
|
||
data *VerificationCodeData
|
||
err error
|
||
}
|
||
|
||
type defaultSubscriptionAssignerStub struct {
|
||
calls []AssignSubscriptionInput
|
||
err error
|
||
}
|
||
|
||
type refreshTokenCacheStub struct{}
|
||
|
||
func (s *defaultSubscriptionAssignerStub) AssignOrExtendSubscription(_ context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
|
||
if input != nil {
|
||
s.calls = append(s.calls, *input)
|
||
}
|
||
if s.err != nil {
|
||
return nil, false, s.err
|
||
}
|
||
return &UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, false, nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) StoreRefreshToken(context.Context, string, *RefreshTokenData, time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) GetRefreshToken(context.Context, string) (*RefreshTokenData, error) {
|
||
return nil, ErrRefreshTokenNotFound
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) DeleteUserRefreshTokens(context.Context, int64) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *refreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) {
|
||
return false, nil
|
||
}
|
||
|
||
func (s *emailCacheStub) GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error) {
|
||
if s.err != nil {
|
||
return nil, s.err
|
||
}
|
||
return s.data, nil
|
||
}
|
||
|
||
func (s *emailCacheStub) SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) DeleteVerificationCode(ctx context.Context, email string) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *emailCacheStub) SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) DeleteNotifyVerifyCode(ctx context.Context, email string) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *emailCacheStub) SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) DeletePasswordResetToken(ctx context.Context, email string) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool {
|
||
return false
|
||
}
|
||
|
||
func (s *emailCacheStub) SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error {
|
||
return nil
|
||
}
|
||
|
||
func (s *emailCacheStub) GetNotifyCodeUserRate(ctx context.Context, userID int64) (int64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (s *emailCacheStub) IncrNotifyCodeUserRate(ctx context.Context, userID int64, window time.Duration) (int64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func newAuthService(repo *userRepoStub, settings map[string]string, emailCache EmailCache) *AuthService {
|
||
cfg := &config.Config{
|
||
JWT: config.JWTConfig{
|
||
Secret: "test-secret",
|
||
ExpireHour: 1,
|
||
},
|
||
Default: config.DefaultConfig{
|
||
UserBalance: 3.5,
|
||
UserConcurrency: 2,
|
||
},
|
||
}
|
||
|
||
var settingService *SettingService
|
||
if settings != nil {
|
||
settingService = NewSettingService(&settingRepoStub{values: settings}, cfg)
|
||
}
|
||
|
||
var emailService *EmailService
|
||
if emailCache != nil {
|
||
emailService = NewEmailService(&settingRepoStub{values: settings}, emailCache)
|
||
}
|
||
|
||
return NewAuthService(
|
||
nil, // entClient
|
||
repo,
|
||
nil, // redeemRepo
|
||
nil, // refreshTokenCache
|
||
cfg,
|
||
settingService,
|
||
emailService,
|
||
nil,
|
||
nil,
|
||
nil, // promoService
|
||
nil, // defaultSubAssigner
|
||
nil, // affiliateService
|
||
)
|
||
}
|
||
|
||
func TestAuthService_Register_Disabled(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "false",
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.ErrorIs(t, err, ErrRegDisabled)
|
||
}
|
||
|
||
func TestAuthService_Register_DisabledByDefault(t *testing.T) {
|
||
// 当 settings 为 nil(设置项不存在)时,注册应该默认关闭
|
||
repo := &userRepoStub{}
|
||
service := newAuthService(repo, nil, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.ErrorIs(t, err, ErrRegDisabled)
|
||
}
|
||
|
||
func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
// 邮件验证开启但 emailCache 为 nil(emailService 未配置)
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyEmailVerifyEnabled: "true",
|
||
}, nil)
|
||
|
||
// 应返回服务不可用错误,而不是允许绕过验证
|
||
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "", "", "")
|
||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||
}
|
||
|
||
func TestAuthService_Register_EmailVerifyRequired(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
cache := &emailCacheStub{} // 配置 emailService
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyEmailVerifyEnabled: "true",
|
||
}, cache)
|
||
|
||
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "", "")
|
||
require.ErrorIs(t, err, ErrEmailVerifyRequired)
|
||
}
|
||
|
||
func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
cache := &emailCacheStub{
|
||
data: &VerificationCodeData{Code: "expected", Attempts: 0},
|
||
}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyEmailVerifyEnabled: "true",
|
||
}, cache)
|
||
|
||
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "", "", "")
|
||
require.ErrorIs(t, err, ErrInvalidVerifyCode)
|
||
require.ErrorContains(t, err, "verify code")
|
||
}
|
||
|
||
func TestAuthService_Register_EmailExists(t *testing.T) {
|
||
repo := &userRepoStub{exists: true}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.ErrorIs(t, err, ErrEmailExists)
|
||
}
|
||
|
||
func TestAuthService_Register_CheckEmailError(t *testing.T) {
|
||
repo := &userRepoStub{existsErr: errors.New("db down")}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||
}
|
||
|
||
func TestAuthService_Register_ReservedEmail(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "password")
|
||
require.ErrorIs(t, err, ErrEmailReserved)
|
||
}
|
||
|
||
func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@other.com", "password")
|
||
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
|
||
appErr := infraerrors.FromError(err)
|
||
require.Contains(t, appErr.Message, "@example.com")
|
||
require.Contains(t, appErr.Message, "@company.com")
|
||
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason)
|
||
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
|
||
require.Equal(t, "@example.com,@company.com", appErr.Metadata["allowed_suffixes"])
|
||
}
|
||
|
||
func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 8}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyRegistrationEmailSuffixWhitelist: `["example.com"]`,
|
||
}, nil)
|
||
|
||
_, user, err := service.Register(context.Background(), "user@example.com", "password")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, user)
|
||
require.Equal(t, int64(8), user.ID)
|
||
}
|
||
|
||
func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
|
||
}, nil)
|
||
|
||
err := service.SendVerifyCode(context.Background(), "user@other.com")
|
||
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
|
||
appErr := infraerrors.FromError(err)
|
||
require.Contains(t, appErr.Message, "@example.com")
|
||
require.Contains(t, appErr.Message, "@company.com")
|
||
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
|
||
}
|
||
|
||
func TestAuthService_Register_CreateError(t *testing.T) {
|
||
repo := &userRepoStub{createErr: errors.New("create failed")}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||
}
|
||
|
||
func TestAuthService_Register_CreateEmailExistsRace(t *testing.T) {
|
||
// 模拟竞态条件:ExistsByEmail 返回 false,但 Create 时因唯一约束失败
|
||
repo := &userRepoStub{createErr: ErrEmailExists}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
}, nil)
|
||
|
||
_, _, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.ErrorIs(t, err, ErrEmailExists)
|
||
}
|
||
|
||
func TestAuthService_Register_Success(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 5}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
|
||
}, nil)
|
||
|
||
token, user, err := service.Register(context.Background(), "user@test.com", "password")
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, token)
|
||
require.NotNil(t, user)
|
||
require.Equal(t, int64(5), user.ID)
|
||
require.Equal(t, "user@test.com", user.Email)
|
||
require.Equal(t, RoleUser, user.Role)
|
||
require.Equal(t, StatusActive, user.Status)
|
||
require.Equal(t, 3.5, user.Balance)
|
||
require.Equal(t, 2, user.Concurrency)
|
||
require.Len(t, repo.created, 1)
|
||
require.True(t, user.CheckPassword("password"))
|
||
}
|
||
|
||
func TestAuthService_ValidateToken_ExpiredReturnsClaimsWithError(t *testing.T) {
|
||
repo := &userRepoStub{}
|
||
service := newAuthService(repo, nil, nil)
|
||
|
||
// 创建用户并生成 token
|
||
user := &User{
|
||
ID: 1,
|
||
Email: "test@test.com",
|
||
Role: RoleUser,
|
||
Status: StatusActive,
|
||
TokenVersion: 1,
|
||
}
|
||
token, err := service.GenerateToken(user)
|
||
require.NoError(t, err)
|
||
|
||
// 验证有效 token
|
||
claims, err := service.ValidateToken(token)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, claims)
|
||
require.Equal(t, int64(1), claims.UserID)
|
||
|
||
// 模拟过期 token(通过创建一个过期很久的 token)
|
||
service.cfg.JWT.ExpireHour = -1 // 设置为负数使 token 立即过期
|
||
expiredToken, err := service.GenerateToken(user)
|
||
require.NoError(t, err)
|
||
service.cfg.JWT.ExpireHour = 1 // 恢复
|
||
|
||
// 验证过期 token 应返回 claims 和 ErrTokenExpired
|
||
claims, err = service.ValidateToken(expiredToken)
|
||
require.ErrorIs(t, err, ErrTokenExpired)
|
||
require.NotNil(t, claims, "claims should not be nil when token is expired")
|
||
require.Equal(t, int64(1), claims.UserID)
|
||
require.Equal(t, "test@test.com", claims.Email)
|
||
}
|
||
|
||
func TestAuthService_RefreshToken_ExpiredTokenNoPanic(t *testing.T) {
|
||
user := &User{
|
||
ID: 1,
|
||
Email: "test@test.com",
|
||
Role: RoleUser,
|
||
Status: StatusActive,
|
||
TokenVersion: 1,
|
||
}
|
||
repo := &userRepoStub{user: user}
|
||
service := newAuthService(repo, nil, nil)
|
||
|
||
// 创建过期 token
|
||
service.cfg.JWT.ExpireHour = -1
|
||
expiredToken, err := service.GenerateToken(user)
|
||
require.NoError(t, err)
|
||
service.cfg.JWT.ExpireHour = 1
|
||
|
||
// RefreshToken 使用过期 token 不应 panic
|
||
require.NotPanics(t, func() {
|
||
newToken, err := service.RefreshToken(context.Background(), expiredToken)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, newToken)
|
||
})
|
||
}
|
||
|
||
func TestAuthService_GetAccessTokenExpiresIn_FallbackToExpireHour(t *testing.T) {
|
||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||
service.cfg.JWT.ExpireHour = 24
|
||
service.cfg.JWT.AccessTokenExpireMinutes = 0
|
||
|
||
require.Equal(t, 24*3600, service.GetAccessTokenExpiresIn())
|
||
}
|
||
|
||
func TestAuthService_GetAccessTokenExpiresIn_MinutesHasPriority(t *testing.T) {
|
||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||
service.cfg.JWT.ExpireHour = 24
|
||
service.cfg.JWT.AccessTokenExpireMinutes = 90
|
||
|
||
require.Equal(t, 90*60, service.GetAccessTokenExpiresIn())
|
||
}
|
||
|
||
func TestAuthService_GenerateToken_UsesExpireHourWhenMinutesZero(t *testing.T) {
|
||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||
service.cfg.JWT.ExpireHour = 24
|
||
service.cfg.JWT.AccessTokenExpireMinutes = 0
|
||
|
||
user := &User{
|
||
ID: 1,
|
||
Email: "test@test.com",
|
||
Role: RoleUser,
|
||
Status: StatusActive,
|
||
TokenVersion: 1,
|
||
}
|
||
|
||
token, err := service.GenerateToken(user)
|
||
require.NoError(t, err)
|
||
|
||
claims, err := service.ValidateToken(token)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, claims)
|
||
require.NotNil(t, claims.IssuedAt)
|
||
require.NotNil(t, claims.ExpiresAt)
|
||
|
||
require.WithinDuration(t, claims.IssuedAt.Time.Add(24*time.Hour), claims.ExpiresAt.Time, 2*time.Second)
|
||
}
|
||
|
||
func TestAuthService_GenerateToken_UsesMinutesWhenConfigured(t *testing.T) {
|
||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||
service.cfg.JWT.ExpireHour = 24
|
||
service.cfg.JWT.AccessTokenExpireMinutes = 90
|
||
|
||
user := &User{
|
||
ID: 2,
|
||
Email: "test2@test.com",
|
||
Role: RoleUser,
|
||
Status: StatusActive,
|
||
TokenVersion: 1,
|
||
}
|
||
|
||
token, err := service.GenerateToken(user)
|
||
require.NoError(t, err)
|
||
|
||
claims, err := service.ValidateToken(token)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, claims)
|
||
require.NotNil(t, claims.IssuedAt)
|
||
require.NotNil(t, claims.ExpiresAt)
|
||
|
||
require.WithinDuration(t, claims.IssuedAt.Time.Add(90*time.Minute), claims.ExpiresAt.Time, 2*time.Second)
|
||
}
|
||
|
||
func TestAuthService_Register_AssignsDefaultSubscriptions(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 42}
|
||
assigner := &defaultSubscriptionAssignerStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`,
|
||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
|
||
}, nil)
|
||
service.defaultSubAssigner = assigner
|
||
|
||
_, user, err := service.Register(context.Background(), "default-sub@test.com", "password")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, user)
|
||
require.Len(t, assigner.calls, 2)
|
||
require.Equal(t, int64(42), assigner.calls[0].UserID)
|
||
require.Equal(t, int64(11), assigner.calls[0].GroupID)
|
||
require.Equal(t, 30, assigner.calls[0].ValidityDays)
|
||
require.Equal(t, int64(12), assigner.calls[1].GroupID)
|
||
require.Equal(t, 7, assigner.calls[1].ValidityDays)
|
||
}
|
||
|
||
func TestAuthService_Register_UsesEmailAuthSourceDefaultsWhenGrantEnabled(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 52}
|
||
assigner := &defaultSubscriptionAssignerStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyDefaultSubscriptions: `[{"group_id":91,"validity_days":3}]`,
|
||
SettingKeyAuthSourceDefaultEmailBalance: "12.5",
|
||
SettingKeyAuthSourceDefaultEmailConcurrency: "7",
|
||
SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":11,"validity_days":30}]`,
|
||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "true",
|
||
}, nil)
|
||
service.defaultSubAssigner = assigner
|
||
|
||
_, user, err := service.Register(context.Background(), "email-defaults@test.com", "password")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, user)
|
||
require.Equal(t, 12.5, user.Balance)
|
||
require.Equal(t, 7, user.Concurrency)
|
||
require.Len(t, assigner.calls, 1)
|
||
require.Equal(t, int64(11), assigner.calls[0].GroupID)
|
||
require.Equal(t, 30, assigner.calls[0].ValidityDays)
|
||
}
|
||
|
||
func TestAuthService_Register_GrantOnSignupFalseFallsBackToGlobalDefaults(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 53}
|
||
assigner := &defaultSubscriptionAssignerStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyDefaultSubscriptions: `[{"group_id":31,"validity_days":5}]`,
|
||
SettingKeyAuthSourceDefaultEmailBalance: "99",
|
||
SettingKeyAuthSourceDefaultEmailConcurrency: "88",
|
||
SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":32,"validity_days":9}]`,
|
||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
|
||
}, nil)
|
||
service.defaultSubAssigner = assigner
|
||
|
||
_, user, err := service.Register(context.Background(), "email-global@test.com", "password")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, user)
|
||
require.Equal(t, 3.5, user.Balance)
|
||
require.Equal(t, 2, user.Concurrency)
|
||
require.Len(t, assigner.calls, 1)
|
||
require.Equal(t, int64(31), assigner.calls[0].GroupID)
|
||
require.Equal(t, 5, assigner.calls[0].ValidityDays)
|
||
}
|
||
|
||
func TestAuthService_Register_GrantOnSignupMergesSourceOverridesWithGlobalDefaults(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 54}
|
||
assigner := &defaultSubscriptionAssignerStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyDefaultSubscriptions: `[{"group_id":31,"validity_days":5}]`,
|
||
SettingKeyAuthSourceDefaultEmailBalance: "9.5",
|
||
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
|
||
SettingKeyAuthSourceDefaultEmailSubscriptions: `[]`,
|
||
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "true",
|
||
}, nil)
|
||
service.defaultSubAssigner = assigner
|
||
|
||
_, user, err := service.Register(context.Background(), "email-merged@test.com", "password")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, user)
|
||
require.Equal(t, 9.5, user.Balance)
|
||
require.Equal(t, 5, user.Concurrency)
|
||
require.Len(t, assigner.calls, 1)
|
||
require.Equal(t, int64(31), assigner.calls[0].GroupID)
|
||
require.Equal(t, 5, assigner.calls[0].ValidityDays)
|
||
}
|
||
|
||
func TestAuthService_LoginOrRegisterOAuthWithTokenPair_UsesLinuxDoAuthSourceDefaultsOnSignup(t *testing.T) {
|
||
repo := &userRepoStub{nextID: 61}
|
||
assigner := &defaultSubscriptionAssignerStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyDefaultSubscriptions: `[{"group_id":81,"validity_days":1}]`,
|
||
SettingKeyAuthSourceDefaultLinuxDoBalance: "21.75",
|
||
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "9",
|
||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: `[{"group_id":22,"validity_days":14}]`,
|
||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "true",
|
||
}, nil)
|
||
service.defaultSubAssigner = assigner
|
||
service.refreshTokenCache = &refreshTokenCacheStub{}
|
||
|
||
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "", "linuxdo")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, tokenPair)
|
||
require.NotNil(t, user)
|
||
require.Equal(t, int64(61), user.ID)
|
||
require.Equal(t, 21.75, user.Balance)
|
||
require.Equal(t, 9, user.Concurrency)
|
||
require.Len(t, repo.created, 1)
|
||
require.Len(t, assigner.calls, 1)
|
||
require.Equal(t, int64(22), assigner.calls[0].GroupID)
|
||
require.Equal(t, 14, assigner.calls[0].ValidityDays)
|
||
}
|
||
|
||
func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantAgain(t *testing.T) {
|
||
existing := &User{
|
||
ID: 88,
|
||
Email: "linuxdo-123@linuxdo-connect.invalid",
|
||
Username: "existing-linuxdo",
|
||
Role: RoleUser,
|
||
Status: StatusActive,
|
||
Balance: 4,
|
||
Concurrency: 1,
|
||
TokenVersion: 2,
|
||
}
|
||
repo := &userRepoStub{user: existing}
|
||
assigner := &defaultSubscriptionAssignerStub{}
|
||
service := newAuthService(repo, map[string]string{
|
||
SettingKeyRegistrationEnabled: "true",
|
||
SettingKeyAuthSourceDefaultLinuxDoBalance: "21.75",
|
||
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "9",
|
||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: `[{"group_id":22,"validity_days":14}]`,
|
||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "true",
|
||
}, nil)
|
||
service.defaultSubAssigner = assigner
|
||
service.refreshTokenCache = &refreshTokenCacheStub{}
|
||
|
||
tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "", "linuxdo")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, tokenPair)
|
||
require.Equal(t, existing.ID, user.ID)
|
||
require.Equal(t, 4.0, user.Balance)
|
||
require.Equal(t, 1, user.Concurrency)
|
||
require.Empty(t, repo.created)
|
||
require.Empty(t, assigner.calls)
|
||
}
|
||
|
||
// newAuthServiceWithDingTalkCfg 构建一个含完整 DingTalk config 的 AuthService,
|
||
// 用于测试 canBypassRegistrationDisabledForOAuth。
|
||
func newAuthServiceWithDingTalkCfg(settings map[string]string, dtCfg config.DingTalkConnectConfig) *AuthService {
|
||
cfg := &config.Config{
|
||
JWT: config.JWTConfig{Secret: "test-secret", ExpireHour: 1},
|
||
Default: config.DefaultConfig{UserBalance: 3.5, UserConcurrency: 2},
|
||
DingTalk: dtCfg,
|
||
}
|
||
settingService := NewSettingService(&settingRepoStub{values: settings}, cfg)
|
||
return NewAuthService(nil, nil, nil, nil, cfg, settingService, nil, nil, nil, nil, nil, nil)
|
||
}
|
||
|
||
// minDingTalkURLs 返回一个包含必填字段的基础 DingTalkConnectConfig(不设 Enabled/BypassRegistration/Policy)。
|
||
func minDingTalkURLs() config.DingTalkConnectConfig {
|
||
return config.DingTalkConnectConfig{
|
||
ClientID: "test-client",
|
||
ClientSecret: "test-secret",
|
||
AuthorizeURL: "https://example.com/oauth2/auth",
|
||
TokenURL: "https://example.com/oauth2/token",
|
||
UserInfoURL: "https://example.com/oauth2/userinfo",
|
||
RedirectURL: "https://example.com/callback",
|
||
FrontendRedirectURL: "https://example.com/auth/callback",
|
||
DingTalkAppKind: "internal_app",
|
||
AppType: "internal",
|
||
}
|
||
}
|
||
|
||
func TestCanBypassRegistrationDisabledForOAuth(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
signupSource string
|
||
settings map[string]string
|
||
dtCfg config.DingTalkConnectConfig
|
||
want bool
|
||
}{
|
||
{
|
||
name: "non-dingtalk source → false",
|
||
signupSource: "linuxdo",
|
||
settings: map[string]string{},
|
||
dtCfg: minDingTalkURLs(),
|
||
want: false,
|
||
},
|
||
{
|
||
name: "dingtalk but cfg.Enabled=false → false",
|
||
signupSource: "dingtalk",
|
||
settings: map[string]string{
|
||
SettingKeyDingTalkConnectEnabled: "false",
|
||
SettingKeyDingTalkConnectBypassRegistration: "true",
|
||
SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only",
|
||
},
|
||
dtCfg: minDingTalkURLs(),
|
||
want: false,
|
||
},
|
||
{
|
||
name: "dingtalk enabled but BypassRegistration=false → false",
|
||
signupSource: "dingtalk",
|
||
settings: map[string]string{
|
||
SettingKeyDingTalkConnectEnabled: "true",
|
||
SettingKeyDingTalkConnectBypassRegistration: "false",
|
||
SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only",
|
||
},
|
||
dtCfg: minDingTalkURLs(),
|
||
want: false,
|
||
},
|
||
{
|
||
name: "dingtalk enabled + bypass=true but policy=none → false",
|
||
signupSource: "dingtalk",
|
||
settings: map[string]string{
|
||
SettingKeyDingTalkConnectEnabled: "true",
|
||
SettingKeyDingTalkConnectBypassRegistration: "true",
|
||
SettingKeyDingTalkConnectCorpRestrictionPolicy: "none",
|
||
},
|
||
dtCfg: minDingTalkURLs(),
|
||
want: false,
|
||
},
|
||
{
|
||
name: "dingtalk enabled + bypass=true + policy=internal_only → true",
|
||
signupSource: "dingtalk",
|
||
settings: map[string]string{
|
||
SettingKeyDingTalkConnectEnabled: "true",
|
||
SettingKeyDingTalkConnectBypassRegistration: "true",
|
||
SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only",
|
||
},
|
||
dtCfg: minDingTalkURLs(),
|
||
want: true,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
svc := newAuthServiceWithDingTalkCfg(tc.settings, tc.dtCfg)
|
||
got := svc.canBypassRegistrationDisabledForOAuth(context.Background(), tc.signupSource)
|
||
require.Equal(t, tc.want, got)
|
||
})
|
||
}
|
||
}
|