251 lines
11 KiB
Go
251 lines
11 KiB
Go
// 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)
|
||
}
|
||
}
|