163 lines
4.4 KiB
Go

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)
}
}