2026-05-16 08:12:42 +08:00

756 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 tokenrefresh 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)
}
}