// Package auth_test 验证 user-service 认证用例的登录、注册、密码、refresh 和靓号登录语义。 package auth_test import ( "context" "testing" "time" "hyapp/pkg/xerr" 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/storage/memory" ) type sequenceIDGenerator struct { // values 是测试预设 user_id 序列。 values []int64 // index 指向当前要返回的 user_id。 index int } func (g *sequenceIDGenerator) NewInt64() int64 { // 到达末尾后保持返回最后一个值,便于测试重试耗尽路径。 value := g.values[g.index] if g.index < len(g.values)-1 { g.index++ } return value } type sequenceDisplayUserIDAllocator struct { // values 是测试预设 display_user_id 候选序列。 values []string } func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int) string { if attempt >= len(a.values) { // 超出候选数量后复用最后一个值,便于构造持续冲突。 return a.values[len(a.values)-1] } return a.values[attempt] } func newAuthService(repository *memory.Repository, now *time.Time, ids []int64, displayIDs []string) *authservice.Service { // 测试服务固定 JWT、发号器、短号候选和时钟,保证 token/session 语义可断言。 return authservice.New(authservice.Config{ Issuer: "hyapp-test", AccessTokenTTLSec: 1800, RefreshTokenTTLSec: 2592000, SigningAlg: "HS256", SigningSecret: "test-secret", }, authservice.WithRepository(repository), authservice.WithIDGenerator(&sequenceIDGenerator{values: ids}), authservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: displayIDs}), authservice.WithClock(func() time.Time { return *now }), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})), ) } func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { // 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。 ctx := context.Background() repository := memory.NewRepository() now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) thirdToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", "ios", authservice.Meta{RequestID: "req-third"}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "100001" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" { t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser) } if _, err := svc.LoginPassword(ctx, "100001", "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, "100001", "secret-pass", "ios", authservice.Meta{RequestID: "req-login"}) if err != nil { t.Fatalf("LoginPassword failed: %v", err) } if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != "100001" || loginToken.RefreshToken == thirdToken.RefreshToken { t.Fatalf("unexpected login token: %+v", loginToken) } if _, err := svc.LoginPassword(ctx, "100001", "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 != "100001" { t.Fatalf("refresh token must rotate") } if _, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) { t.Fatalf("expected old refresh token revoked, got %v", err) } revoked, err := svc.Logout(ctx, refreshToken.SessionID, refreshToken.RefreshToken, authservice.Meta{RequestID: "req-logout"}) if err != nil || !revoked { t.Fatalf("Logout failed: revoked=%v err=%v", revoked, err) } if _, err := svc.RefreshToken(ctx, refreshToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) { t.Fatalf("expected logout refresh token revoked, got %v", err) } } func TestDisabledUserCannotLogin(t *testing.T) { // 用户状态被禁用后,即使密码正确也不能继续签发 token。 ctx := context.Background() repository := memory.NewRepository() now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", "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 // 直接改 memory repository 中的用户状态,模拟运营禁用用户。 repository.PutUser(user) _, err = svc.LoginPassword(ctx, "100001", "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 := memory.NewRepository() now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"}) firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", "ios", authservice.Meta{RequestID: "req-third-first"}) if err != nil { t.Fatalf("first LoginThirdParty failed: %v", err) } if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "100002" { t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) } secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", "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", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 未允许的 provider 统一认证失败。 t.Fatalf("expected AUTH_FAILED for unknown provider, got %v", err) } } func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) { // 默认短号候选冲突时,三方注册应在有限次数内重试下一个候选。 ctx := context.Background() repository := memory.NewRepository() repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "100001", 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", "ios", authservice.Meta{}) if err != nil { t.Fatalf("LoginThirdParty should retry display_user_id conflict: %v", err) } userSvc := userservice.New(repository) identity, err := userSvc.GetUserIdentity(ctx, token.UserID) if err != nil { t.Fatalf("GetUserIdentity failed: %v", err) } if identity.DisplayUserID != "100002" { t.Fatalf("display_user_id retry mismatch: %+v", identity) } } func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) { // 密码登录必须使用当前 active display_user_id;靓号 active 时默认号不能登录。 ctx := context.Background() repository := memory.NewRepository() now := time.UnixMilli(1000) authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) userSvc := userservice.New(repository, userservice.WithClock(func() time.Time { return now })) token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty", "ios", authservice.Meta{}) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil { t.Fatalf("SetPassword failed: %v", err) } if _, _, err := userSvc.ApplyPrettyDisplayUserID(ctx, token.UserID, "888888", 1000, "receipt-1", "req-pretty"); err != nil { t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err) } if _, err := authSvc.LoginPassword(ctx, "100001", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 默认短号处于 held 状态时不能作为登录入口。 t.Fatalf("default display_user_id must not login while pretty is active, got %v", err) } prettyToken, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{}) if err != nil { t.Fatalf("pretty display_user_id login failed: %v", err) } if prettyToken.DisplayUserID != "888888" || prettyToken.DefaultDisplayUserID != "100001" || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) { t.Fatalf("unexpected pretty login token: %+v", prettyToken) } now = time.UnixMilli(2500) // 靓号过期后,密码登录默认号会触发懒恢复。 defaultToken, err := authSvc.LoginPassword(ctx, "100001", "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 != "100001" || 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) } }