2026-07-06 11:10:19 +08:00

1898 lines
84 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"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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"
jwt "github.com/golang-jwt/jwt/v5"
)
const testAccessTokenTTLSec int64 = 604800
func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
t.Helper()
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
return []byte("test-secret"), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("parse access token failed: token=%q err=%v", accessToken, err)
}
exp, err := parsed.Claims.GetExpirationTime()
if err != nil || exp == nil {
t.Fatalf("access token missing exp: claims=%+v err=%v", parsed.Claims, err)
}
return exp.Unix()
}
type memoryDecisionCache struct {
mu sync.Mutex
ip map[string]authservice.IPDecision
revoked map[string]string
revTTL map[string]time.Duration
ips map[string][]string
users map[string][]int64
userTTL map[string]time.Duration
err error
}
func newMemoryDecisionCache() *memoryDecisionCache {
return &memoryDecisionCache{
ip: make(map[string]authservice.IPDecision),
revoked: make(map[string]string),
revTTL: make(map[string]time.Duration),
ips: make(map[string][]string),
users: make(map[string][]int64),
userTTL: make(map[string]time.Duration),
}
}
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, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.err != nil {
return c.err
}
key := appCode + ":" + sessionID
c.revoked[key] = reason
c.revTTL[key] = ttl
return nil
}
func (c *memoryDecisionCache) GetIPWhitelist(_ context.Context, appCode string) ([]string, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
ips, ok := c.ips[appCode]
return append([]string(nil), ips...), ok, nil
}
func (c *memoryDecisionCache) SetIPWhitelist(_ context.Context, appCode string, ips []string, _ time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.ips[appCode] = append([]string(nil), ips...)
return nil
}
func (c *memoryDecisionCache) GetUserWhitelist(_ context.Context, appCode string) ([]int64, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
userIDs, ok := c.users[appCode]
return append([]int64(nil), userIDs...), ok, nil
}
func (c *memoryDecisionCache) SetUserWhitelist(_ context.Context, appCode string, userIDs []int64, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.users[appCode] = append([]int64(nil), userIDs...)
c.userTTL[appCode] = ttl
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]
}
type fakeIMAccountImporter struct {
userID int64
nickname string
faceURL string
}
func (f *fakeIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
f.userID = userID
f.nickname = nickname
f.faceURL = faceURL
return nil
}
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"})),
authservice.WithIPDecisionCache(newMemoryDecisionCache()),
}
options = append(options, extra...)
return authservice.New(authservice.Config{
Issuer: "hyapp-test",
AccessTokenTTLSec: testAccessTokenTTLSec,
RefreshTokenTTLSec: 2592000,
SigningAlg: "HS256",
SigningSecret: "test-secret",
}, options...)
}
func assertSevenDigitDisplayUserID(t *testing.T, displayUserID string) {
t.Helper()
if len(displayUserID) != 7 || displayUserID[0] < '1' || displayUserID[0] > '9' {
t.Fatalf("display_user_id must be 7 digits and start with 1-9, got %q", displayUserID)
}
for _, digit := range displayUserID {
if digit < '0' || digit > '9' {
t.Fatalf("display_user_id must contain digits only, got %q", displayUserID)
}
}
}
func assertUserRegisteredOutboxCount(t *testing.T, repository *mysqltest.Repository, userID int64, want int) {
t.Helper()
var count int
var status string
if err := repository.RawDB().QueryRowContext(context.Background(), `
SELECT COUNT(*), COALESCE(MAX(status), '')
FROM user_outbox
WHERE app_code = 'lalu' AND event_type = 'UserRegistered' AND aggregate_id = ?
`, userID).Scan(&count, &status); err != nil {
t.Fatalf("query UserRegistered outbox failed: %v", err)
}
if count != want {
t.Fatalf("UserRegistered outbox count for user %d = %d, want %d", userID, count, want)
}
if want > 0 && status != "pending" {
t.Fatalf("UserRegistered outbox status for user %d = %s, want pending", userID, status)
}
}
func assertUserRegisteredOutboxFact(t *testing.T, repository *mysqltest.Repository, userID int64, countryID int64, regionID int64, occurredAtMS int64) {
t.Helper()
var raw string
var createdAtMS int64
if err := repository.RawDB().QueryRowContext(context.Background(), `
SELECT payload_json, created_at_ms
FROM user_outbox
WHERE app_code = 'lalu' AND event_type = 'UserRegistered' AND aggregate_id = ?
`, userID).Scan(&raw, &createdAtMS); err != nil {
t.Fatalf("query UserRegistered payload failed: %v", err)
}
var payload struct {
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
ProfileCompletedAtMS int64 `json:"profile_completed_at_ms"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
t.Fatalf("decode UserRegistered payload failed: %v raw=%s", err, raw)
}
if payload.CountryID != countryID || payload.RegionID != regionID {
t.Fatalf("UserRegistered dimensions = country:%d region:%d, want country:%d region:%d", payload.CountryID, payload.RegionID, countryID, regionID)
}
if payload.ProfileCompletedAtMS != occurredAtMS || createdAtMS != occurredAtMS {
t.Fatalf("UserRegistered time = payload:%d outbox:%d, want %d", payload.ProfileCompletedAtMS, createdAtMS, occurredAtMS)
}
}
func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
// 部分测试需要覆盖注册快照字段,因此默认提供完整资料;完成态仍必须由 onboarding 流程推进。
return authdomain.ThirdPartyRegistration{
Username: "hy",
Avatar: "https://cdn.example/avatar.png",
Gender: "male",
Country: "SG",
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, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}, authservice.WithIPDecisionCache(cache))
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)
}
displayUserID := thirdToken.DisplayUserID
assertSevenDigitDisplayUserID(t, displayUserID)
if !isNewUser || thirdToken.UserID != 900001 || displayUserID == "" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
}
if thirdToken.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("third-party access token TTL mismatch: %+v", thirdToken)
}
if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
}
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
}
assertUserRegisteredOutboxCount(t, repository, thirdToken.UserID, 0)
if _, err := svc.LoginPassword(ctx, displayUserID, "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, displayUserID, "secret-pass", "ios", authservice.Meta{RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginPassword failed: %v", err)
}
if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != displayUserID || loginToken.RefreshToken == thirdToken.RefreshToken {
t.Fatalf("unexpected login token: %+v", loginToken)
}
if loginToken.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("password login access token TTL mismatch: %+v", loginToken)
}
if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
}
if _, err := svc.LoginPassword(ctx, displayUserID, "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 != displayUserID {
t.Fatalf("refresh token must rotate")
}
if refreshToken.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("refresh access token TTL mismatch: %+v", refreshToken)
}
if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
}
if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
t.Fatalf("refresh must denylist rotated access session, got %q", got)
}
if got, want := cache.revTTL["lalu:"+loginToken.SessionID], time.Duration(testAccessTokenTTLSec+60)*time.Second; got != want {
t.Fatalf("refresh denylist TTL mismatch: got=%s want=%s", got, want)
}
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)
}
if got := cache.revoked["lalu:"+refreshToken.SessionID]; got != "USER_LOGOUT" {
t.Fatalf("logout must denylist access session, got %q", got)
}
if got, want := cache.revTTL["lalu:"+refreshToken.SessionID], time.Duration(testAccessTokenTTLSec+60)*time.Second; got != want {
t.Fatalf("logout denylist TTL mismatch: got=%s want=%s", got, want)
}
}
func TestRefreshTokenDenylistFailureDoesNotConsumeSession(t *testing.T) {
// refresh 轮换前必须先让旧 sid 进入 gateway denylistRedis 失败时不能消耗 refresh token。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}, authservice.WithIPDecisionCache(cache))
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-denylist-failure", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
cache.err = errors.New("redis unavailable")
if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-fail"}); err == nil {
t.Fatal("RefreshToken should fail when access denylist write fails")
}
cache.err = nil
refreshed, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-retry"})
if err != nil {
t.Fatalf("RefreshToken retry should still use original refresh token: %v", err)
}
if refreshed.SessionID == token.SessionID || refreshed.RefreshToken == token.RefreshToken {
t.Fatalf("retry must rotate session and refresh token: old=%+v new=%+v", token, refreshed)
}
if got := cache.revoked["lalu:"+token.SessionID]; got != "REFRESH_ROTATED" {
t.Fatalf("retry must denylist original session, got %q", got)
}
}
func TestRefreshAndLogoutRequireSessionDenylistCache(t *testing.T) {
// 168 小时 access token 下,缺少 gateway denylist 写入能力时不能假装 refresh/logout 成功。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900014}, []string{"100014"}, authservice.WithIPDecisionCache(nil))
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-missing-denylist", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh"}); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("RefreshToken must fail without session denylist cache, got %v", err)
}
if revoked, err := svc.Logout(ctx, token.SessionID, token.RefreshToken, authservice.Meta{AppCode: "lalu", RequestID: "req-logout"}); err == nil || !xerr.IsCode(err, xerr.Unavailable) || revoked {
t.Fatalf("Logout must fail without session denylist cache, revoked=%v err=%v", revoked, err)
}
}
func TestLogoutByRefreshTokenUsesSessionAppCodeForDenylist(t *testing.T) {
// logout 是公开接口,旧客户端可能只带 refresh_tokendenylist 分区必须来自持久化 session而不是依赖请求头。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
svc := newAuthService(repository, &now, []int64{900015}, []string{"100015"}, authservice.WithIPDecisionCache(cache))
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-only-logout", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
revoked, err := svc.Logout(ctx, "", token.RefreshToken, authservice.Meta{RequestID: "req-logout-refresh-only"})
if err != nil || !revoked {
t.Fatalf("Logout by refresh token failed: revoked=%v err=%v", revoked, err)
}
if got := cache.revoked["lalu:"+token.SessionID]; got != "USER_LOGOUT" {
t.Fatalf("logout must use session app_code for denylist, got %q", got)
}
if _, ok := cache.revoked[":"+token.SessionID]; ok {
t.Fatal("logout must not write denylist under empty app_code")
}
}
func TestCompleteOnboardingEmitsUserRegisteredOutboxWithSelectedCountry(t *testing.T) {
// UserRegistered 是“注册页资料完成”事实,不是三方登录占位账号创建事实;统计国家必须来自最终提交的国家。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "AE")
region := mustResolveRegionByCountry(t, repository, "AE")
loginAt := time.UnixMilli(1000)
authSvc := newAuthService(repository, &loginAt, []int64{900031}, []string{"100031"})
token, isNewUser, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-register", authdomain.ThirdPartyRegistration{
DeviceID: "dev-ios",
Platform: "ios",
}, authservice.Meta{RequestID: "req-third-create"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if !isNewUser || token.ProfileCompleted {
t.Fatalf("third-party login should create profile-required user: token=%+v isNew=%v", token, isNewUser)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)
completedAtMS := int64(2500)
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return time.UnixMilli(completedAtMS).UTC() }))
user, err := userSvc.CompleteOnboarding(ctx, token.UserID, "Amina", "https://cdn.example/amina.png", "female", "ae", "", "req-complete-onboarding")
if err != nil {
t.Fatalf("CompleteOnboarding failed: %v", err)
}
if !user.ProfileCompleted || user.Country != "AE" || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != completedAtMS {
t.Fatalf("completed user snapshot mismatch: %+v", user)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, token.UserID, country.CountryID, region.RegionID, completedAtMS)
}
func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
// 快捷建号必须一次性得到可登录短号、已完成资料快照和密码身份,服务游戏联调直接取 uid/token。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(2000)
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900011}, []string{"100011"}, authservice.WithIMAccountImporter(imImporter))
token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
Username: "Lingxian Test",
Avatar: "https://cdn.example/avatar.png",
Gender: "unknown",
Country: "SG",
DeviceID: "quick-dev",
Platform: "ios",
Language: "en-US",
Timezone: "Asia/Shanghai",
AppVersion: "1.0.0",
}, authservice.Meta{RequestID: "req-quick-create", AppCode: "lalu", ClientIP: "203.0.113.10", UserAgent: "quick-test/1.0"})
if err != nil {
t.Fatalf("QuickCreateAccount failed: %v", err)
}
assertSevenDigitDisplayUserID(t, token.DisplayUserID)
if token.UserID != 900011 || token.DisplayUserID == "" || token.AccessToken == "" || token.RefreshToken == "" {
t.Fatalf("unexpected quick token: %+v", token)
}
if token.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("quick account access token TTL mismatch: %+v", token)
}
if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("quick account must be completed: %+v", token)
}
user, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Username != "Lingxian Test" || user.Country != "SG" || !user.ProfileCompleted {
t.Fatalf("quick user profile mismatch: %+v", user)
}
if imImporter.userID != 900011 || imImporter.nickname != "Lingxian Test" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("quick account must import IM account: %+v", imImporter)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)
loginToken, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "quick-dev", authservice.Meta{RequestID: "req-quick-login", AppCode: "lalu"})
if err != nil {
t.Fatalf("LoginPassword for quick account failed: %v", err)
}
if loginToken.UserID != token.UserID || loginToken.DisplayUserID != token.DisplayUserID || !loginToken.ProfileCompleted {
t.Fatalf("quick account login token mismatch: %+v", loginToken)
}
}
func TestQuickCreateGameRobotCannotLoginOrCreateSession(t *testing.T) {
// 全站机器人复用用户资料和短号展示,但不是 App 登录主体;创建路径不能返回可用 token也不能落 refresh session。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SA")
now := time.UnixMilli(3000)
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}, authservice.WithIMAccountImporter(imImporter))
token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
Username: "Robot Account",
Avatar: "https://cdn.example/robot.png",
Gender: "unknown",
Country: "SA",
DeviceID: "robot-dev",
Source: userdomain.RegisterSourceGameRobot,
Platform: "android",
Language: "ar",
Timezone: "UTC",
AppVersion: "1.0.0",
}, authservice.Meta{RequestID: "req-robot-create", AppCode: "lalu", ClientIP: "203.0.113.20", UserAgent: "robot-test/1.0"})
if err != nil {
t.Fatalf("QuickCreateAccount robot failed: %v", err)
}
assertSevenDigitDisplayUserID(t, token.DisplayUserID)
if token.UserID != 900012 || token.DisplayUserID == "" || token.AccessToken != "" || token.RefreshToken != "" || token.SessionID != "" {
t.Fatalf("robot token must expose identity without login credentials: %+v", token)
}
user, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser robot failed: %v", err)
}
if user.RegisterSource != userdomain.RegisterSourceGameRobot || user.CanLogin() {
t.Fatalf("robot user source/login boundary mismatch: %+v", user)
}
if imImporter.userID != 0 {
t.Fatalf("robot account must not import IM login account: %+v", imImporter)
}
var sessionCount int
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT COUNT(*)
FROM auth_sessions
WHERE app_code = 'lalu' AND user_id = ?
`, token.UserID).Scan(&sessionCount); err != nil {
t.Fatalf("query robot sessions failed: %v", err)
}
if sessionCount != 0 {
t.Fatalf("robot account must not create auth session, got %d", sessionCount)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)
if _, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "robot-dev", authservice.Meta{RequestID: "req-robot-login", AppCode: "lalu"}); !xerr.IsCode(err, xerr.UserDisabled) {
t.Fatalf("robot password login must be blocked, got %v", err)
}
}
func TestGameRobotSourceDoesNotEmitUserRegisteredOutbox(t *testing.T) {
// UserRegistered 是新增用户和留存 cohort 的事实来源source=game_robot 即使走通用用户创建事务也不能写入统计 outbox。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(4000)
robot := userdomain.User{
AppCode: "lalu",
UserID: 900101,
DefaultDisplayUserID: "190101",
CurrentDisplayUserID: "190101",
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
RegisterSource: userdomain.RegisterSourceGameRobot,
Status: userdomain.StatusActive,
CreatedAtMs: now.UnixMilli(),
UpdatedAtMs: now.UnixMilli(),
}
normal := userdomain.User{
AppCode: "lalu",
UserID: 900102,
DefaultDisplayUserID: "190102",
CurrentDisplayUserID: "190102",
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.StatusActive,
CreatedAtMs: now.Add(time.Millisecond).UnixMilli(),
UpdatedAtMs: now.Add(time.Millisecond).UnixMilli(),
}
if err := repository.CreateUserWithIdentity(ctx, robot, userdomain.Identity{
AppCode: "lalu",
UserID: robot.UserID,
DisplayUserID: robot.DefaultDisplayUserID,
DefaultDisplayUserID: robot.DefaultDisplayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}); err != nil {
t.Fatalf("create robot user failed: %v", err)
}
if err := repository.CreateUserWithIdentity(ctx, normal, userdomain.Identity{
AppCode: "lalu",
UserID: normal.UserID,
DisplayUserID: normal.DefaultDisplayUserID,
DefaultDisplayUserID: normal.DefaultDisplayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}); err != nil {
t.Fatalf("create normal user failed: %v", err)
}
var outboxCount int
var aggregateID int64
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(MAX(aggregate_id), 0)
FROM user_outbox
WHERE app_code = 'lalu' AND event_type = 'UserRegistered'
`).Scan(&outboxCount, &aggregateID); err != nil {
t.Fatalf("query user registered outbox failed: %v", err)
}
if outboxCount != 1 || aggregateID != normal.UserID {
t.Fatalf("robot source must be excluded from UserRegistered outbox, count=%d aggregate=%d", outboxCount, aggregateID)
}
}
func TestRefreshTokenCarriesProfileRequiredOnboardingStatus(t *testing.T) {
// gateway profile gate 依赖 access token 快照;未完成 onboarding 的新用户刷新后仍必须保持资料待补态。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"})
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(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.OnboardingStatusProfileRequired) {
t.Fatalf("refreshed token must carry profile-required onboarding: %+v", refreshed)
}
}
func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
seedCountry(t, repository, "SG")
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 TestLoginIPRiskIPWhitelistBypassesBlockedCountry(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
now := time.UnixMilli(1000)
if _, err := repository.RawDB().ExecContext(ctx, `
INSERT INTO login_risk_ip_whitelist (
app_code, ip_address, enabled, created_at_ms, updated_at_ms
)
VALUES ('lalu', '203.0.113.80', 1, 1000, 1000)
`); err != nil {
t.Fatalf("seed ip whitelist failed: %v", err)
}
cache := newMemoryDecisionCache()
authSvc := newAuthService(repository, &now, []int64{900024}, []string{"100024"},
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-whitelist", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-whitelist",
ClientIP: "203.0.113.80",
CountryByIP: "CN",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-whitelist-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-whitelist"}); err != nil {
t.Fatalf("whitelisted IP must keep refresh session active: %v", err)
}
if _, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.80")]; ok {
t.Fatalf("whitelisted IP must not write ordinary IP decision cache")
}
if got := cache.ips["lalu"]; len(got) != 1 || got[0] != "203.0.113.80" {
t.Fatalf("ip whitelist cache mismatch: %+v", got)
}
var decision string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT decision
FROM login_ip_risk_jobs
WHERE app_code = 'lalu' AND request_id = 'req-risk-whitelist'
`).Scan(&decision); err != nil {
t.Fatalf("query risk job failed: %v", err)
}
if decision != "allowed" {
t.Fatalf("whitelisted risk job decision mismatch: %s", decision)
}
}
func TestLoginIPRiskUserWhitelistBypassesBlockedCountryWithFiveMinuteCache(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
now := time.UnixMilli(1000)
if _, err := repository.RawDB().ExecContext(ctx, `
INSERT INTO login_risk_user_whitelist (
app_code, user_id, enabled, created_at_ms, updated_at_ms
)
VALUES ('lalu', 900025, 1, 1000, 1000)
`); err != nil {
t.Fatalf("seed user whitelist failed: %v", err)
}
cache := newMemoryDecisionCache()
authSvc := newAuthService(repository, &now, []int64{900025}, []string{"100025"},
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"})),
)
snapshot, err := authSvc.RefreshLoginRiskWhitelistCache(ctx)
if err != nil {
t.Fatalf("refresh whitelist cache failed: %v", err)
}
if !snapshot.Refreshed || snapshot.UserWhitelistCount != 1 || snapshot.UserCacheExpiresAtMs != now.Add(5*time.Minute).UnixMilli() {
t.Fatalf("user whitelist refresh snapshot mismatch: %+v", snapshot)
}
if got := cache.userTTL["lalu"]; got != 5*time.Minute {
t.Fatalf("user whitelist cache ttl = %v, want 5m", got)
}
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-user-whitelist", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-user-whitelist",
ClientIP: "203.0.113.81",
CountryByIP: "CN",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-user-whitelist-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-user-whitelist"}); err != nil {
t.Fatalf("whitelisted user must keep refresh session active: %v", err)
}
if _, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.81")]; ok {
t.Fatalf("user whitelist must not write ordinary IP decision cache")
}
var decision string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT decision
FROM login_ip_risk_jobs
WHERE app_code = 'lalu' AND request_id = 'req-risk-user-whitelist'
`).Scan(&decision); err != nil {
t.Fatalf("query risk job failed: %v", err)
}
if decision != "allowed" {
t.Fatalf("whitelisted user risk job decision mismatch: %s", decision)
}
}
func TestLoginIPRiskWorkerRevokesRotatedSameDeviceSessions(t *testing.T) {
// 复现线上竞态:登录后风控任务尚未完成,客户端连续 refresh 轮换 session。
// blocked 决策落地时必须沿源 session 的同设备登录链路清掉后续 session否则最新 access/refresh 仍能继续使用。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
authSvc := newAuthService(repository, &now, []int64{900023}, []string{"100023"},
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-rotated", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-rotated",
ClientIP: "203.0.113.79",
CountryByIP: "CN",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(1200)
refreshedOnce, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-before-risk-1"})
if err != nil {
t.Fatalf("first RefreshToken before risk failed: %v", err)
}
now = time.UnixMilli(1400)
refreshedTwice, err := authSvc.RefreshToken(ctx, refreshedOnce.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-before-risk-2"})
if err != nil {
t.Fatalf("second RefreshToken before risk failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-rotated-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, refreshedTwice.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-after-risk"}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("latest rotated refresh session must be revoked by risk, got %v", err)
}
if _, err := authSvc.IssueAccessTokenForSession(ctx, refreshedTwice.UserID, refreshedTwice.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-resign-after-risk"}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("latest rotated access session must be revoked by risk, got %v", err)
}
for _, sessionID := range []string{token.SessionID, refreshedOnce.SessionID, refreshedTwice.SessionID} {
if got := cache.revoked["lalu:"+sessionID]; got != "GEO_POLICY_BLOCKED" {
t.Fatalf("session %s denylist mismatch: %q", sessionID, got)
}
}
var latestReason string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT revoked_reason
FROM auth_sessions
WHERE app_code = 'lalu' AND session_id = ?
`, refreshedTwice.SessionID).Scan(&latestReason); err != nil {
t.Fatalf("query latest session failed: %v", err)
}
if latestReason != "GEO_POLICY_BLOCKED" {
t.Fatalf("latest session revoke reason mismatch: %s", latestReason)
}
}
func TestLoginIPRiskWorkerUsesHTTPGeoProviderFallback(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if got := request.URL.Query().Get("ip"); got != "203.0.113.78" {
t.Errorf("http geo provider received wrong ip query: %q", got)
}
if got := request.Header.Get("X-Source-IP"); got != "203.0.113.78" {
t.Errorf("http geo provider received wrong ip header: %q", got)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"data":{"country_code":"CN"}}`))
}))
defer server.Close()
authSvc := newAuthService(repository, &now, []int64{900022}, []string{"100022"},
authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
Enabled: true,
UnsupportedCountries: []string{"CN"},
BlockedTTL: time.Hour,
AllowedTTL: time.Hour,
UnknownTTL: time.Minute,
ProviderTimeout: time.Second,
ProviderNames: []string{"edge_country", "primary_geo"},
DenylistTTL: time.Hour,
}),
authservice.WithIPDecisionCache(cache),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProvidersWithHTTP([]string{"edge_country", "primary_geo"}, map[string]authservice.HTTPIPGeoProviderConfig{
"primary_geo": {
URL: server.URL + "/lookup?ip={ip}",
Method: http.MethodGet,
CountryJSONPath: "data.country_code",
Headers: map[string]string{"X-Source-IP": "{ip}"},
HTTPClient: server.Client(),
},
})),
)
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-http-provider", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-http-provider",
ClientIP: "203.0.113.78",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-http-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-http-provider"}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("expected revoked refresh session, got %v", err)
}
decision, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.78")]
if !ok || decision.Decision != "blocked" || decision.Country != "CN" {
t.Fatalf("blocked IP cache mismatch: ok=%v decision=%+v", ok, decision)
}
var countryCode string
var decisionValue string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT country_code, decision
FROM login_ip_risk_jobs
WHERE app_code = 'lalu' AND request_id = 'req-risk-http-provider'
`).Scan(&countryCode, &decisionValue); err != nil {
t.Fatalf("query risk job failed: %v", err)
}
if countryCode != "CN" || decisionValue != "blocked" {
t.Fatalf("risk job decision mismatch: country=%s decision=%s", countryCode, decisionValue)
}
}
func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "MY")
seedCountry(t, repository, "SG")
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 TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
// 重新签发 access token 只能复用已有 sessionrefresh token 原文不可从数据库反查,也不应被静默轮换。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"})
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(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.OnboardingStatusProfileRequired) || reissued.AccessToken == "" {
t.Fatalf("reissued token must carry profile-required onboarding: %+v", reissued)
}
if reissued.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("reissued access token TTL mismatch: %+v", reissued)
}
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
}
}
func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {
// access token 不能因为心跳或资料完成重签而越过 refresh session 的服务端硬过期时间。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900013}, []string{"100013"})
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-session-expiry-cap", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(3000)
sessionExpiresAt := now.Add(2 * time.Hour)
if _, err := repository.RawDB().ExecContext(ctx, `
UPDATE auth_sessions
SET expires_at_ms = ?
WHERE app_code = 'lalu' AND session_id = ?
`, sessionExpiresAt.UnixMilli(), token.SessionID); err != nil {
t.Fatalf("shorten session expiry failed: %v", err)
}
reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-resign"})
if err != nil {
t.Fatalf("IssueAccessTokenForSession failed: %v", err)
}
if reissued.ExpiresInSec != int64((2 * time.Hour).Seconds()) {
t.Fatalf("reissued token must be capped by session expiry: %+v", reissued)
}
if got, want := accessTokenExpUnix(t, reissued.AccessToken), sessionExpiresAt.Unix(); got != want {
t.Fatalf("reissued access token exp mismatch: got=%d want=%d", got, want)
}
}
func TestAppHeartbeatRefreshesCurrentSessionOnly(t *testing.T) {
// APP 心跳是登录会话在线状态,不应该创建房间 presence也不能续住其他用户或其他 session。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900011}, []string{"100011"})
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-app-heartbeat", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(4000)
session, err := authSvc.AppHeartbeat(ctx, token.UserID, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-app-heartbeat"})
if err != nil {
t.Fatalf("AppHeartbeat failed: %v", err)
}
if session.LastHeartbeatAtMs != 4000 || session.LastHeartbeatRequestID != "req-app-heartbeat" {
t.Fatalf("session heartbeat mismatch: %+v", session)
}
var heartbeatAtMS int64
var requestID string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT last_heartbeat_at_ms, last_heartbeat_request_id
FROM auth_sessions
WHERE app_code = 'lalu' AND session_id = ?
`, token.SessionID).Scan(&heartbeatAtMS, &requestID); err != nil {
t.Fatalf("query heartbeat failed: %v", err)
}
if heartbeatAtMS != 4000 || requestID != "req-app-heartbeat" {
t.Fatalf("stored heartbeat mismatch: at=%d request=%s", heartbeatAtMS, requestID)
}
now = time.UnixMilli(5000)
if _, err := authSvc.AppHeartbeat(ctx, token.UserID+1, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-wrong-user"}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("wrong user must not refresh session heartbeat, got %v", err)
}
}
func TestDisabledUserCannotLogin(t *testing.T) {
// 用户状态被禁用后,即使密码正确也不能继续签发 token。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
seedCountry(t, repository, "SG")
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)
seedCountry(t, repository, "SG")
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)
}
assertSevenDigitDisplayUserID(t, firstToken.DisplayUserID)
if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID == "" {
t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
}
if firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("first third-party registration must require onboarding: %+v", firstToken)
}
secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "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 TestRegisterThirdPartyCreatesCompletedUserAndSession(t *testing.T) {
// 新注册接口必须一次性完成资料、三方绑定、session 和注册事实,客户端不再拿 profile_required token。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "SG")
region := mustResolveRegionByCountry(t, repository, "SG")
now := time.UnixMilli(1000)
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900030}, []string{"100030"}, authservice.WithIMAccountImporter(imImporter))
token, isNewUser, err := svc.RegisterThirdParty(ctx, "wechat", "openid-register-complete", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-register", ClientIP: "203.0.113.10", UserAgent: "hyapp-test/1.0"})
if err != nil {
t.Fatalf("RegisterThirdParty failed: %v", err)
}
if !isNewUser || token.UserID != 900030 || token.ProfileCompleted != true || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || token.AccessToken == "" || token.RefreshToken == "" {
t.Fatalf("register must return completed token for new user: token=%+v isNew=%v", token, isNewUser)
}
user, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if !user.ProfileCompleted || user.ProfileCompletedAtMs != now.UnixMilli() || user.OnboardingStatus != userdomain.OnboardingStatusCompleted {
t.Fatalf("user must be completed immediately: %+v", user)
}
if user.Country != country.CountryCode || user.RegionID != region.RegionID || user.Username != "hy" || user.Avatar != "https://cdn.example/avatar.png" {
t.Fatalf("registration profile mismatch: %+v", user)
}
if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("completed registration must import final IM profile: %+v", imImporter)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, token.UserID, country.CountryID, region.RegionID, now.UnixMilli())
}
func TestRegisterThirdPartyCompletesExistingPendingUser(t *testing.T) {
// 兼容旧流程:同一 provider subject 已有 pending 用户时,新接口补齐资料并签新 session不创建第二个账号。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "SG")
region := mustResolveRegionByCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900031, 900032}, []string{"100031", "100032"})
pendingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-pending-register", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-pending"})
if err != nil {
t.Fatalf("LoginThirdParty pending seed failed: %v", err)
}
if !isNewUser || pendingToken.ProfileCompleted {
t.Fatalf("seed login must create pending user: token=%+v isNew=%v", pendingToken, isNewUser)
}
assertUserRegisteredOutboxCount(t, repository, pendingToken.UserID, 0)
now = time.UnixMilli(2000)
register := thirdPartyRegistration("ios")
register.Username = "finished"
register.DeviceID = "dev-ios-finished"
completedToken, isNewUser, err := svc.RegisterThirdParty(ctx, "wechat", "openid-pending-register", register, authservice.Meta{RequestID: "req-third-complete"})
if err != nil {
t.Fatalf("RegisterThirdParty completion failed: %v", err)
}
if isNewUser || completedToken.UserID != pendingToken.UserID || !completedToken.ProfileCompleted || completedToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("pending subject must complete existing user: token=%+v isNew=%v pending=%+v", completedToken, isNewUser, pendingToken)
}
user, err := repository.GetUser(ctx, pendingToken.UserID)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Username != "finished" || user.Country != country.CountryCode || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != now.UnixMilli() {
t.Fatalf("pending user completion mismatch: %+v", user)
}
assertUserRegisteredOutboxCount(t, repository, pendingToken.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, pendingToken.UserID, country.CountryID, region.RegionID, now.UnixMilli())
}
func TestCheckThirdPartyRegisteredReturnsOnlyCompletedRegistration(t *testing.T) {
// 检查接口只服务前端路由判断,不创建用户、不创建 sessionpending 用户仍然要进资料页。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900033}, []string{"100033"})
registered, err := svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-empty"})
if err != nil {
t.Fatalf("CheckThirdPartyRegistered empty failed: %v", err)
}
if registered {
t.Fatalf("unbound third-party subject must not be registered")
}
pendingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-check", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-check-pending"})
if err != nil {
t.Fatalf("seed pending LoginThirdParty failed: %v", err)
}
if !isNewUser || pendingToken.ProfileCompleted {
t.Fatalf("seed login must create pending user: token=%+v isNew=%v", pendingToken, isNewUser)
}
registered, err = svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-pending"})
if err != nil {
t.Fatalf("CheckThirdPartyRegistered pending failed: %v", err)
}
if registered {
t.Fatalf("pending third-party subject must not be treated as registered")
}
register := thirdPartyRegistration("ios")
register.DeviceID = "dev-ios-register"
if _, _, err := svc.RegisterThirdParty(ctx, "wechat", "openid-check", register, authservice.Meta{RequestID: "req-check-register"}); err != nil {
t.Fatalf("RegisterThirdParty completion failed: %v", err)
}
registered, err = svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-completed"})
if err != nil {
t.Fatalf("CheckThirdPartyRegistered completed failed: %v", err)
}
if !registered {
t.Fatalf("completed third-party subject must be registered")
}
}
func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) {
// 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900020, 900021}, []string{"100020", "100021"})
registration := thirdPartyRegistration("ios")
registration.DeviceID = "device-one-account"
firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-device-first", registration, authservice.Meta{RequestID: "req-device-first"})
if err != nil {
t.Fatalf("first LoginThirdParty failed: %v", err)
}
if !isNewUser || firstToken.UserID != 900020 {
t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
}
existingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-device-first", registration, authservice.Meta{RequestID: "req-device-existing"})
if err != nil {
t.Fatalf("existing LoginThirdParty should not be blocked by device limit: %v", err)
}
if isNewUser || existingToken.UserID != firstToken.UserID {
t.Fatalf("expected existing third-party login, token=%+v isNew=%v", existingToken, isNewUser)
}
_, _, err = svc.LoginThirdParty(ctx, "wechat", "openid-device-second", registration, authservice.Meta{RequestID: "req-device-second"})
if !xerr.IsCode(err, xerr.DeviceAlreadyRegistered) {
// 第二个 provider subject 会创建新账号,必须被注册设备唯一性拦截。
t.Fatalf("expected DEVICE_ALREADY_REGISTERED for reused device, got %v", err)
}
}
func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
// 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"})
token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-minimal", authdomain.ThirdPartyRegistration{
DeviceID: "dev-ios",
Platform: "ios",
}, authservice.Meta{})
if err != nil {
t.Fatalf("LoginThirdParty with minimal registration failed: %v", err)
}
if !isNewUser || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("minimal third-party registration must create profile-required user: token=%+v isNew=%v", token, isNewUser)
}
}
func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
// 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
now := time.UnixMilli(1000)
imImporter := &fakeIMAccountImporter{}
svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"}, authservice.WithIMAccountImporter(imImporter))
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)
}
if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("third-party registration must import IM account: %+v", imImporter)
}
}
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{
Username: "hy",
Avatar: "https://cdn.example/avatar.png",
Gender: "male",
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 TestThirdPartyRegisterAvoidsOccupiedDisplayUserID(t *testing.T) {
// 默认短号随机生成前先查历史占用;创建事务仍用唯一索引处理并发撞号。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
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)
}
assertSevenDigitDisplayUserID(t, identity.DisplayUserID)
if identity.DisplayUserID == "163000" {
t.Fatalf("display_user_id must not reuse occupied value: %+v", identity)
}
}
func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
// 密码登录允许 held 默认短号作为登录别名;签发 token 必须仍使用当前 active 靓号。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
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)
}
defaultAliasToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
if err != nil {
t.Fatalf("default display_user_id alias login failed while pretty is active: %v", err)
}
if defaultAliasToken.DisplayUserID != "888888" || defaultAliasToken.DefaultDisplayUserID != token.DefaultDisplayUserID || defaultAliasToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
// 默认短号只是登录入口别名,不能把用户当前展示号回退成默认短号。
t.Fatalf("unexpected default alias login token: %+v", defaultAliasToken)
}
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)
}
}
func TestPasswordLoginAllowsAlphanumericPrettyDisplayID(t *testing.T) {
// 后台发放靓号可以包含字母;密码登录既允许 active 靓号,也允许 held 默认短号作为别名。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
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-alpha", 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.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
TargetUserID: token.UserID,
DisplayUserID: "1111cc1111",
OperatorAdminID: 1,
RequestID: "req-admin-pretty-alpha",
}); err != nil {
t.Fatalf("AdminGrantPrettyDisplayID failed: %v", err)
}
if _, err := repository.RawDB().ExecContext(ctx, `
UPDATE users
SET current_display_user_id = default_display_user_id,
current_display_user_id_kind = ?,
current_display_user_id_expires_at_ms = NULL
WHERE app_code = ? AND user_id = ?
`, string(userdomain.DisplayUserIDKindDefault), "lalu", token.UserID); err != nil {
t.Fatalf("make users display snapshot stale failed: %v", err)
}
defaultAliasToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
if err != nil {
t.Fatalf("default display_user_id alias login failed while admin pretty is active: %v", err)
}
if defaultAliasToken.UserID != token.UserID || defaultAliasToken.DisplayUserID != "1111cc1111" || defaultAliasToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
// users 快照即使因为历史脚本滞后停在默认号,登录成功后也必须按事实表修回 active 靓号。
t.Fatalf("unexpected default alias login token: %+v", defaultAliasToken)
}
userAfterAliasLogin, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser after default alias login failed: %v", err)
}
if userAfterAliasLogin.CurrentDisplayUserID != "1111cc1111" || userAfterAliasLogin.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
t.Fatalf("default alias login must repair users current display snapshot: %+v", userAfterAliasLogin)
}
prettyToken, err := authSvc.LoginPassword(ctx, "1111cc1111", "secret-pass", "ios", authservice.Meta{})
if err != nil {
t.Fatalf("alphanumeric pretty display_user_id login failed: %v", err)
}
if prettyToken.UserID != token.UserID || prettyToken.DisplayUserID != "1111cc1111" || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
t.Fatalf("unexpected alphanumeric pretty login token: %+v", prettyToken)
}
}
func TestRecycleAdminGrantedPrettyDisplayIDRestoresDefaultDisplayID(t *testing.T) {
// 后台回收必须能处理 duration_ms=0 的长期靓号;这类靓号不能复用到期恢复接口,否则会被未到期校验拒绝。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
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-recycle", 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)
}
identity, _, err := userSvc.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
TargetUserID: token.UserID,
DisplayUserID: "RECYCLE2026",
OperatorAdminID: 1,
RequestID: "req-admin-pretty-recycle-grant",
})
if err != nil {
t.Fatalf("AdminGrantPrettyDisplayID failed: %v", err)
}
if identity.PrettyID == "" || identity.DisplayUserID != "RECYCLE2026" {
t.Fatalf("unexpected grant identity: %+v", identity)
}
now = time.UnixMilli(2000)
recycled, err := userSvc.RecyclePrettyDisplayID(ctx, userdomain.PrettyDisplayIDRecycleCommand{
PrettyID: identity.PrettyID,
Reason: "manual test recycle",
OperatorAdminID: 2,
RequestID: "req-admin-pretty-recycle",
})
if err != nil {
t.Fatalf("RecyclePrettyDisplayID failed: %v", err)
}
if recycled.Status != userdomain.PrettyDisplayIDStatusReleased || recycled.AssignedUserID != 0 || recycled.AssignedLeaseID != "" {
// 后台自由发放号回收后保留 released 历史态,不回到池内可申请。
t.Fatalf("unexpected recycled pretty item: %+v", recycled)
}
userAfterRecycle, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser after recycle failed: %v", err)
}
if userAfterRecycle.CurrentDisplayUserID != token.DefaultDisplayUserID || userAfterRecycle.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindDefault {
t.Fatalf("user must recover default display id after recycle: %+v", userAfterRecycle)
}
defaultToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
if err != nil {
t.Fatalf("default display_user_id login after recycle failed: %v", err)
}
if defaultToken.DisplayUserID != token.DefaultDisplayUserID || defaultToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindDefault) {
t.Fatalf("unexpected default token after recycle: %+v", defaultToken)
}
if _, err := authSvc.LoginPassword(ctx, "RECYCLE2026", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
t.Fatalf("recycled pretty display_user_id must not login, got %v", err)
}
}