1898 lines
84 KiB
Go
1898 lines
84 KiB
Go
// 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 denylist;Redis 失败时不能消耗 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_token;denylist 分区必须来自持久化 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 只能复用已有 session;refresh token 原文不可从数据库反查,也不应被静默轮换。
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
seedCountry(t, repository, "SG")
|
||
now := time.UnixMilli(1000)
|
||
authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"})
|
||
|
||
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) {
|
||
// 检查接口只服务前端路由判断,不创建用户、不创建 session;pending 用户仍然要进资料页。
|
||
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)
|
||
}
|
||
}
|