2026-04-27 02:29:42 +08:00

251 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package auth_test 验证 user-service 认证用例的登录、注册、密码、refresh 和靓号登录语义。
package auth_test
import (
"context"
"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)
}
}