// Package auth_test 验证 user-service 认证用例的登录、注册、密码、refresh 和靓号登录语义。 package auth_test import ( "context" "strings" "sync" "testing" "time" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" userdomain "hyapp/services/user-service/internal/domain/user" authservice "hyapp/services/user-service/internal/service/auth" userservice "hyapp/services/user-service/internal/service/user" "hyapp/services/user-service/internal/testutil/mysqltest" ) type memoryDecisionCache struct { mu sync.Mutex ip map[string]authservice.IPDecision revoked map[string]string } func newMemoryDecisionCache() *memoryDecisionCache { return &memoryDecisionCache{ip: make(map[string]authservice.IPDecision), revoked: make(map[string]string)} } func (c *memoryDecisionCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (authservice.IPDecision, bool, error) { c.mu.Lock() defer c.mu.Unlock() decision, ok := c.ip[appCode+":"+clientIPHash] return decision, ok, nil } func (c *memoryDecisionCache) SetIPDecision(_ context.Context, appCode string, clientIPHash string, decision authservice.IPDecision, _ time.Duration) error { c.mu.Lock() defer c.mu.Unlock() c.ip[appCode+":"+clientIPHash] = decision return nil } func (c *memoryDecisionCache) SetRevokedSession(_ context.Context, appCode string, sessionID string, reason string, _ time.Duration) error { c.mu.Lock() defer c.mu.Unlock() c.revoked[appCode+":"+sessionID] = reason return nil } type sequenceIDGenerator struct { // values 是测试预设 user_id 序列。 values []int64 // index 指向当前要返回的 user_id。 index int } func (g *sequenceIDGenerator) NewInt64() int64 { // 到达末尾后保持返回最后一个值,便于测试重试耗尽路径。 value := g.values[g.index] if g.index < len(g.values)-1 { g.index++ } return value } type sequenceDisplayUserIDAllocator struct { // values 是测试预设 display_user_id 候选序列。 values []string } func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int) string { if attempt >= len(a.values) { // 超出候选数量后复用最后一个值,便于构造持续冲突。 return a.values[len(a.values)-1] } return a.values[attempt] } func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service { base := []userservice.Option{ userservice.WithIdentityRepository(repository), userservice.WithCountryRegionRepository(repository), userservice.WithCountryAdminRepository(repository), userservice.WithRegionAdminRepository(repository), userservice.WithRegionRebuildRepository(repository), userservice.WithDeviceRepository(repository), } return userservice.New(repository, append(base, options...)...) } func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int64, displayIDs []string, extra ...authservice.Option) *authservice.Service { // 测试服务固定 JWT、发号器、短号候选和时钟,保证 token/session 语义可断言。 options := []authservice.Option{ authservice.WithAuthRepository(repository), authservice.WithUserRepository(repository), authservice.WithIdentityRepository(repository), authservice.WithCountryRegionRepository(repository), authservice.WithIDGenerator(&sequenceIDGenerator{values: ids}), authservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: displayIDs}), authservice.WithClock(func() time.Time { return *now }), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})), } options = append(options, extra...) return authservice.New(authservice.Config{ Issuer: "hyapp-test", AccessTokenTTLSec: 1800, RefreshTokenTTLSec: 2592000, SigningAlg: "HS256", SigningSecret: "test-secret", }, options...) } func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration { // 三方登录是注册入口,测试默认同时提供设备和平台,避免生成缺失注册上下文的用户。 return authdomain.ThirdPartyRegistration{DeviceID: "dev-" + platform, Platform: platform} } func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country { t.Helper() if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil { t.Fatalf("resolve country %s failed: %v", code, err) } else if ok { return country } return repository.PutCountry(userdomain.Country{ CountryName: code + " Name", CountryCode: code, CountryDisplayName: code + " Display", Enabled: true, }) } func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region { t.Helper() return repository.PutRegion(userdomain.Region{ RegionCode: code, Name: code + " Region", Status: userdomain.RegionStatusActive, Countries: countries, CreatedByUserID: 1, UpdatedByUserID: 1, }) } func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region { t.Helper() region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country) if err != nil { t.Fatalf("resolve region for country %s failed: %v", country, err) } if !ok { t.Fatalf("country %s should have seeded region mapping", country) } return region } func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { // 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) thirdToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" { t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser) } if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { t.Fatalf("new third-party user must require onboarding: %+v", thirdToken) } if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 未设置密码前不能通过短号密码登录,且错误必须统一 AUTH_FAILED。 t.Fatalf("expected AUTH_FAILED before password is set, got %v", err) } if err := svc.SetPassword(ctx, thirdToken.UserID, "secret-pass", authservice.Meta{RequestID: "req-set-password"}); err != nil { t.Fatalf("SetPassword failed: %v", err) } if err := svc.SetPassword(ctx, thirdToken.UserID, "another-pass", authservice.Meta{}); !xerr.IsCode(err, xerr.PasswordAlreadySet) { // v1 只允许首次设置密码,暂不提供重置密码流程。 t.Fatalf("expected PASSWORD_ALREADY_SET, got %v", err) } loginToken, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{RequestID: "req-login"}) if err != nil { t.Fatalf("LoginPassword failed: %v", err) } if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != "163000" || loginToken.RefreshToken == thirdToken.RefreshToken { t.Fatalf("unexpected login token: %+v", loginToken) } if _, err := svc.LoginPassword(ctx, "163000", "wrong", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { t.Fatalf("expected AUTH_FAILED for wrong password, got %v", err) } now = now.Add(time.Second) // refresh 成功后必须返回新的 refresh token,并吊销旧 token。 refreshToken, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{RequestID: "req-refresh"}) if err != nil { t.Fatalf("RefreshToken failed: %v", err) } if refreshToken.RefreshToken == loginToken.RefreshToken || refreshToken.DisplayUserID != "163000" { t.Fatalf("refresh token must rotate") } if _, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) { t.Fatalf("expected old refresh token revoked, got %v", err) } revoked, err := svc.Logout(ctx, refreshToken.SessionID, refreshToken.RefreshToken, authservice.Meta{RequestID: "req-logout"}) if err != nil || !revoked { t.Fatalf("Logout failed: revoked=%v err=%v", revoked, err) } if _, err := svc.RefreshToken(ctx, refreshToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) { t.Fatalf("expected logout refresh token revoked, got %v", err) } } func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) { // gateway profile gate 依赖 access token 快照;refresh 必须在用户完成注册后带出最新状态。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"}) userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { t.Fatalf("new token should require onboarding: %+v", token) } now = time.UnixMilli(2000) if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } now = time.UnixMilli(3000) refreshed, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{RequestID: "req-refresh"}) if err != nil { t.Fatalf("RefreshToken failed: %v", err) } if !refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) { t.Fatalf("refreshed token must carry completed onboarding: %+v", refreshed) } } func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") now := time.UnixMilli(1000) cache := newMemoryDecisionCache() authSvc := newAuthService(repository, &now, []int64{900020}, []string{"100020"}, authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{ Enabled: true, UnsupportedCountries: []string{"CN"}, BlockedTTL: time.Hour, AllowedTTL: time.Hour, UnknownTTL: time.Minute, ProviderTimeout: 50 * time.Millisecond, ProviderNames: []string{"edge_country"}, DenylistTTL: time.Hour, }), authservice.WithIPDecisionCache(cache), authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})), ) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-blocked", thirdPartyRegistration("ios"), authservice.Meta{ AppCode: "lalu", RequestID: "req-risk-blocked", ClientIP: "203.0.113.77", CountryByIP: "CN", Platform: "ios", Language: "en-US", Timezone: "America/Los_Angeles", }) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } now = time.UnixMilli(2000) processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{ WorkerID: "risk-test", LockTTL: time.Second, BatchSize: 10, }) if err != nil || processed != 1 { t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err) } if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-revoked"}); !xerr.IsCode(err, xerr.SessionRevoked) { t.Fatalf("expected revoked refresh session, got %v", err) } if got := cache.revoked["lalu:"+token.SessionID]; got != "GEO_POLICY_BLOCKED" { t.Fatalf("revoked denylist mismatch: %q", got) } decision, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.77")] if !ok || decision.Decision != "blocked" || decision.Country != "CN" { t.Fatalf("blocked IP cache mismatch: ok=%v decision=%+v", ok, decision) } var result string var blocked bool var blockReason string err = repository.RawDB().QueryRowContext(ctx, ` SELECT result, blocked, block_reason FROM login_audit WHERE app_code = 'lalu' AND request_id = 'req-risk-blocked' AND user_id = ? `, token.UserID).Scan(&result, &blocked, &blockReason) if err != nil { t.Fatalf("query login audit failed: %v", err) } if result != "revoked" || !blocked || blockReason != "ip_async" { t.Fatalf("login audit risk mark mismatch: result=%s blocked=%v reason=%s", result, blocked, blockReason) } } func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "MY") now := time.UnixMilli(1000) if _, err := repository.RawDB().ExecContext(ctx, ` INSERT INTO login_risk_country_blocks ( app_code, keyword, country_code, enabled, created_at_ms, updated_at_ms ) VALUES ('lalu', 'Malaysia', 'MY', 1, 1000, 1000) `); err != nil { t.Fatalf("seed dynamic country block failed: %v", err) } authSvc := newAuthService(repository, &now, []int64{900021}, []string{"100021"}, authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{ Enabled: true, UnsupportedCountries: nil, BlockedTTL: time.Hour, AllowedTTL: time.Hour, UnknownTTL: time.Minute, ProviderTimeout: 50 * time.Millisecond, ProviderNames: []string{"edge_country"}, DenylistTTL: time.Hour, }), authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})), ) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-dynamic", thirdPartyRegistration("ios"), authservice.Meta{ AppCode: "lalu", RequestID: "req-risk-dynamic", ClientIP: "203.0.113.88", CountryByIP: "MY", Platform: "ios", }) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } now = time.UnixMilli(2000) processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-test", LockTTL: time.Second, BatchSize: 10}) if err != nil || processed != 1 { t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err) } if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-dynamic"}); !xerr.IsCode(err, xerr.SessionRevoked) { t.Fatalf("expected revoked refresh session, got %v", err) } blocks, err := authSvc.ListLoginRiskBlockedCountries(ctx) if err != nil { t.Fatalf("ListLoginRiskBlockedCountries failed: %v", err) } if len(blocks) != 1 || blocks[0].CountryCode != "MY" || blocks[0].Keyword != "Malaysia" { t.Fatalf("dynamic block list mismatch: %+v", blocks) } } func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) { // CompleteOnboarding 后只需要替换 access token;refresh token 原文不可从数据库反查,也不应被静默轮换。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"}) userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } now = time.UnixMilli(2000) if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } now = time.UnixMilli(3000) reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"}) if err != nil { t.Fatalf("IssueAccessTokenForSession failed: %v", err) } if !reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || reissued.AccessToken == "" { t.Fatalf("reissued token must carry completed onboarding: %+v", reissued) } if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" { t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued) } } func TestDisabledUserCannotLogin(t *testing.T) { // 用户状态被禁用后,即使密码正确也不能继续签发 token。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if err := svc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil { t.Fatalf("SetPassword failed: %v", err) } user, err := repository.GetUser(ctx, token.UserID) if err != nil { t.Fatalf("GetUser failed: %v", err) } user.Status = userdomain.StatusDisabled // 直接改 MySQL 测试库中的用户状态,模拟运营禁用用户。 repository.PutUser(user) _, err = svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "ios", authservice.Meta{}) if !xerr.IsCode(err, xerr.UserDisabled) { t.Fatalf("expected USER_DISABLED, got %v", err) } } func TestThirdPartyLoginOrRegister(t *testing.T) { // 首次三方登录创建用户,再次同 provider subject 登录应复用原用户。 ctx := context.Background() repository := mysqltest.NewRepository(t) now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"}) firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-first"}) if err != nil { t.Fatalf("first LoginThirdParty failed: %v", err) } if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" { t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) } secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-second"}) if err != nil { t.Fatalf("second LoginThirdParty failed: %v", err) } if isNewUser || secondToken.UserID != firstToken.UserID { t.Fatalf("expected existing third-party user, token=%+v isNew=%v", secondToken, isNewUser) } if _, _, err := svc.LoginThirdParty(ctx, "unknown", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 未允许的 provider 统一认证失败。 t.Fatalf("expected AUTH_FAILED for unknown provider, got %v", err) } } func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) { // 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"}) if err := repository.CreateUserWithIdentity(ctx, userdomain.User{ AppCode: "lalu", UserID: 800001, DefaultDisplayUserID: "188001", CurrentDisplayUserID: "188001", CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, Status: userdomain.StatusActive, OnboardingStatus: userdomain.OnboardingStatusProfileRequired, CreatedAtMs: now.UnixMilli(), UpdatedAtMs: now.UnixMilli(), }, userdomain.Identity{ AppCode: "lalu", UserID: 800001, DisplayUserID: "188001", DefaultDisplayUserID: "188001", DisplayUserIDKind: userdomain.DisplayUserIDKindDefault, Status: userdomain.DisplayUserIDStatusActive, }); err != nil { t.Fatalf("seed inviter failed: %v", err) } inviter, err := repository.GetUser(ctx, 800001) if err != nil { t.Fatalf("get inviter failed: %v", err) } registration := authdomain.ThirdPartyRegistration{ Username: " hy ", Gender: "male", Country: "CN", InviteCode: inviter.InviteOverview.MyInviteCode, IP: "198.51.100.9", DeviceID: " dev-1 ", Device: "iPhone 15", OSVersion: " iOS 18.1 ", Avatar: "https://cdn.example/avatar.png", BirthDate: "2000-01-02", AppVersion: "1.2.3", BuildNumber: " 123 ", Source: "campaign-a", InstallChannel: " app_store ", Campaign: " spring_launch ", Platform: "IOS", Language: "zh-CN", Timezone: " Asia/Shanghai ", } token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-profile", registration, authservice.Meta{ClientIP: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIP: "cn"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if !isNewUser { t.Fatalf("first third-party login must create user") } user, err := repository.GetUser(ctx, token.UserID) if err != nil { t.Fatalf("GetUser failed: %v", err) } if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != inviter.InviteOverview.MyInviteCode { t.Fatalf("profile fields mismatch: %+v", user) } if user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "CN" { t.Fatalf("gateway observed source fields mismatch: %+v", user) } if user.RegisterDeviceID != "dev-1" || user.RegisterDevice != "iPhone 15" || user.RegisterOSVersion != "iOS 18.1" || user.RegisterAppVersion != "1.2.3" || user.RegisterBuildNumber != "123" { t.Fatalf("registration source fields mismatch: %+v", user) } if user.Avatar == "" || user.BirthDate != "2000-01-02" || user.RegisterSource != "campaign-a" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Language != "zh-CN" || user.Timezone != "Asia/Shanghai" { t.Fatalf("registration profile fields mismatch: %+v", user) } } func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) { // 首次注册按国家映射写 users.region_id;已有三方登录不因请求体国家变化改用户资料。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") region := mustResolveRegionByCountry(t, repository, "SG") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"}) firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{ DeviceID: "ios", Platform: "ios", Country: " sg ", }, authservice.Meta{RequestID: "req-region-first"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if !isNew { t.Fatalf("first third-party login must create user") } user, err := repository.GetUser(ctx, firstToken.UserID) if err != nil { t.Fatalf("GetUser failed: %v", err) } if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode { t.Fatalf("region assignment mismatch: %+v", user) } _, isNew, err = svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{ DeviceID: "ios", Platform: "ios", Country: "ZZ", }, authservice.Meta{RequestID: "req-region-second"}) if err != nil { t.Fatalf("existing LoginThirdParty failed: %v", err) } if isNew { t.Fatalf("existing third-party login must not create user") } user, err = repository.GetUser(ctx, firstToken.UserID) if err != nil { t.Fatalf("GetUser failed: %v", err) } if user.Country != "SG" || user.RegionID != region.RegionID { t.Fatalf("existing login must not rewrite country or region: %+v", user) } } func TestThirdPartyRegisterRejectsUnknownOrDisabledCountry(t *testing.T) { // 国家格式合法但未命中 active countries 时,也必须返回稳定 INVALID_ARGUMENT。 ctx := context.Background() repository := mysqltest.NewRepository(t) country := seedCountry(t, repository, "MY") repository.SetCountryEnabled(country.CountryID, false) now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900007, 900008}, []string{"100007", "100008"}) for _, tc := range []struct { name string country string }{ {name: "unknown", country: "ZZ"}, {name: "disabled", country: "MY"}, } { _, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: tc.country}, authservice.Meta{}) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for %s country, got %v", tc.name, err) } } } func TestThirdPartyRegisterValidatesRegistrationProfile(t *testing.T) { // 格式边界必须在 service 层返回稳定 INVALID_ARGUMENT,不能让 MySQL 报错泄漏为 upstream error。 ctx := context.Background() repository := mysqltest.NewRepository(t) now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900004}, []string{"100004"}) cases := []struct { name string registration authdomain.ThirdPartyRegistration }{ {name: "birth_format", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "2000/01/02"}}, {name: "birth_mysql_range", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "0001-01-01"}}, {name: "platform_missing", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios"}}, {name: "platform_invalid", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "web"}}, {name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: "U1"}}, {name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: "ftp://cdn.example/avatar.png"}}, {name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: "en_US"}}, {name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: "Mars/Phobos"}}, } for _, tc := range cases { _, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, authservice.Meta{}) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for %s, got %v", tc.name, err) } } } func TestThirdPartyRegisterValidatesSQLBackedLengths(t *testing.T) { // 每个写入 users VARCHAR 的注册字段都要在 service 层按 schema 长度拒绝,避免数据库错误变成 UPSTREAM_ERROR。 ctx := context.Background() repository := mysqltest.NewRepository(t) now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900005}, []string{"100005"}) longURL := "https://cdn.example/" + strings.Repeat("a", 513) cases := []struct { name string registration authdomain.ThirdPartyRegistration meta authservice.Meta }{ {name: "username", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Username: strings.Repeat("a", 65)}}, {name: "gender", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Gender: strings.Repeat("a", 33)}}, {name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: strings.Repeat("a", 65)}}, {name: "invite_code", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InviteCode: strings.Repeat("a", 65)}}, {name: "register_ip", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{ClientIP: strings.Repeat("1", 65)}}, {name: "register_user_agent", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{UserAgent: strings.Repeat("a", 513)}}, {name: "device_id", registration: authdomain.ThirdPartyRegistration{DeviceID: strings.Repeat("a", 129), Platform: "ios"}}, {name: "device", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Device: strings.Repeat("a", 129)}}, {name: "os_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", OSVersion: strings.Repeat("a", 65)}}, {name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: longURL}}, {name: "app_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", AppVersion: strings.Repeat("a", 65)}}, {name: "build_number", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BuildNumber: strings.Repeat("a", 65)}}, {name: "source", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Source: strings.Repeat("a", 65)}}, {name: "install_channel", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InstallChannel: strings.Repeat("a", 65)}}, {name: "campaign", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Campaign: strings.Repeat("a", 129)}}, {name: "platform", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: strings.Repeat("a", 17)}}, {name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: strings.Repeat("a", 33)}}, {name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: strings.Repeat("a", 65)}}, } for _, tc := range cases { _, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, tc.meta) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT for overlong %s, got %v", tc.name, err) } } } func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) { // 默认短号候选冲突时,三方注册应在有限次数内重试下一个候选。 ctx := context.Background() repository := mysqltest.NewRepository(t) repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "163000", Status: userdomain.StatusActive}) now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001, 900002}, []string{"100001", "100002"}) token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{}) if err != nil { t.Fatalf("LoginThirdParty should retry display_user_id conflict: %v", err) } userSvc := newUserService(repository) identity, err := userSvc.GetUserIdentity(ctx, token.UserID) if err != nil { t.Fatalf("GetUserIdentity failed: %v", err) } if identity.DisplayUserID != "163001" { t.Fatalf("display_user_id retry mismatch: %+v", identity) } } func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) { // 密码登录必须使用当前 active display_user_id;靓号 active 时默认号不能登录。 ctx := context.Background() repository := mysqltest.NewRepository(t) now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty", thirdPartyRegistration("ios"), authservice.Meta{}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil { t.Fatalf("SetPassword failed: %v", err) } if _, _, err := userSvc.ApplyPrettyDisplayUserID(ctx, token.UserID, "888888", 1000, "receipt-1", "req-pretty"); err != nil { t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err) } if _, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 默认短号处于 held 状态时不能作为登录入口。 t.Fatalf("default display_user_id must not login while pretty is active, got %v", err) } prettyToken, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{}) if err != nil { t.Fatalf("pretty display_user_id login failed: %v", err) } if prettyToken.DisplayUserID != "888888" || prettyToken.DefaultDisplayUserID != token.DefaultDisplayUserID || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) { t.Fatalf("unexpected pretty login token: %+v", prettyToken) } now = time.UnixMilli(2500) // 靓号过期后,密码登录默认号会触发懒恢复。 defaultToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{}) if err != nil { t.Fatalf("default display_user_id login should recover after pretty expires: %v", err) } if defaultToken.DisplayUserID != token.DefaultDisplayUserID || defaultToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindDefault) { t.Fatalf("unexpected recovered login token: %+v", defaultToken) } if _, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { t.Fatalf("expired pretty display_user_id must not login, got %v", err) } }