package auth import ( "errors" "testing" "time" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/model" authcore "hyapp-admin-server/internal/service" ) type fakeAuthStore struct { user model.User tokens map[string]model.RefreshToken nextTokenID uint } func newFakeAuthStore(t *testing.T) *fakeAuthStore { t.Helper() passwordHash, err := authcore.HashPassword("secret") if err != nil { t.Fatalf("hash password: %v", err) } return &fakeAuthStore{ user: model.User{ ID: 7, Username: "admin", Name: "Admin", PasswordHash: passwordHash, Status: model.UserStatusActive, Roles: []model.Role{{ Permissions: []model.Permission{{Code: "user:view"}}, }}, }, tokens: make(map[string]model.RefreshToken), } } func (s *fakeAuthStore) FindUserByUsername(username string) (*model.User, error) { if username != s.user.Username { return nil, errors.New("not found") } user := s.user return &user, nil } func (s *fakeAuthStore) FindUserByID(id uint) (*model.User, error) { if id != s.user.ID { return nil, errors.New("not found") } user := s.user return &user, nil } func (s *fakeAuthStore) CreateRefreshToken(token model.RefreshToken) error { if token.ID == 0 { s.nextTokenID++ token.ID = s.nextTokenID } s.tokens[token.TokenHash] = token return nil } func (s *fakeAuthStore) RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error { if err := s.CreateRefreshToken(token); err != nil { return err } nowMS := time.Now().UTC().UnixMilli() for hash, existing := range s.tokens { if existing.ID == oldTokenID && existing.RevokedAtMS == nil { existing.RevokedAtMS = &nowMS s.tokens[hash] = existing return nil } } return errors.New("old token not found") } func (s *fakeAuthStore) FindRefreshToken(hash string) (*model.RefreshToken, error) { token, ok := s.tokens[hash] if !ok || token.RevokedAtMS != nil || token.ExpiresAtMS <= time.Now().UTC().UnixMilli() { return nil, errors.New("not found") } return &token, nil } func (s *fakeAuthStore) RevokeRefreshToken(hash string) error { token, ok := s.tokens[hash] if !ok { return nil } nowMS := time.Now().UTC().UnixMilli() token.RevokedAtMS = &nowMS s.tokens[hash] = token return nil } func (s *fakeAuthStore) TouchUserLogin(uint) error { return nil } func (s *fakeAuthStore) CreateLoginLog(model.LoginLog) error { return nil } func (s *fakeAuthStore) ResetUserPassword(id uint, password string) error { if id != s.user.ID { return errors.New("not found") } passwordHash, err := authcore.HashPassword(password) if err != nil { return err } s.user.PasswordHash = passwordHash return nil } func (s *fakeAuthStore) activeRefreshTokenCount() int { count := 0 nowMS := time.Now().UTC().UnixMilli() for _, token := range s.tokens { if token.RevokedAtMS == nil && token.ExpiresAtMS > nowMS { count++ } } return count } func TestRefreshRotatesOnlyCurrentSessionAndKeepsMultiLogin(t *testing.T) { store := newFakeAuthStore(t) service := NewService(store, authcore.NewAuthService("test-secret", time.Hour), config.Config{RefreshTokenTTL: 7 * 24 * time.Hour}) first, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.1", UserAgent: "browser-a"}) if err != nil { t.Fatalf("first login: %v", err) } second, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.2", UserAgent: "browser-b"}) if err != nil { t.Fatalf("second login: %v", err) } if store.activeRefreshTokenCount() != 2 { t.Fatalf("expected two active sessions after multi-login, got %d", store.activeRefreshTokenCount()) } refreshedFirst, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"}) if err != nil { t.Fatalf("refresh first session: %v", err) } if refreshedFirst.RefreshToken == "" || refreshedFirst.RefreshToken == first.RefreshToken { t.Fatalf("refresh must rotate current refresh token") } if _, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"}); err == nil { t.Fatalf("old refresh token must be revoked after rotation") } if store.activeRefreshTokenCount() != 2 { t.Fatalf("rotation should keep one session per login point, got %d", store.activeRefreshTokenCount()) } if _, err := service.Refresh(second.RefreshToken, LoginInput{IP: "127.0.0.2", UserAgent: "browser-b"}); err != nil { t.Fatalf("second session should remain refreshable: %v", err) } }