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 []int64 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 []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 { 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) { 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) { 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) { 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) 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) { 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 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) { 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) { 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) { 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) { 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) } }