package auth_test import ( "context" "errors" "testing" "time" "github.com/google/uuid" "backend/internal/modules/auth" "backend/internal/modules/users" "backend/internal/platform/security" ) // fakeUserFinder is an in-memory auth.UserFinder used to unit-test // auth.Service without a real users repository/database. type fakeUserFinder struct { byID map[uuid.UUID]*users.User byEmail map[string]*users.User } func newFakeUserFinder() *fakeUserFinder { return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byEmail: map[string]*users.User{}} } func (f *fakeUserFinder) add(email, password, role string, active bool) *users.User { hash, err := security.HashPassword(password) if err != nil { panic(err) } u := &users.User{ID: uuid.New(), Email: email, PasswordHash: hash, Role: role, IsActive: active} f.byID[u.ID] = u f.byEmail[u.Email] = u return u } func (f *fakeUserFinder) FindByEmail(_ context.Context, email string) (*users.User, error) { u, ok := f.byEmail[email] if !ok { return nil, users.ErrNotFound } return u, nil } func (f *fakeUserFinder) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) { u, ok := f.byID[id] if !ok { return nil, users.ErrNotFound } return u, nil } // fakeRefreshStore is an in-memory auth.RefreshStore used to unit-test // rotation/revocation without a real Redis instance. type fakeRefreshStore struct { byToken map[string]uuid.UUID byUser map[uuid.UUID]map[string]bool nextID int } func newFakeRefreshStore() *fakeRefreshStore { return &fakeRefreshStore{byToken: map[string]uuid.UUID{}, byUser: map[uuid.UUID]map[string]bool{}} } func (f *fakeRefreshStore) newToken() string { f.nextID++ return "token-" + uuid.NewString() } func (f *fakeRefreshStore) Issue(_ context.Context, _ security.Audience, userID uuid.UUID, _ time.Duration) (string, error) { token := f.newToken() f.byToken[token] = userID if f.byUser[userID] == nil { f.byUser[userID] = map[string]bool{} } f.byUser[userID][token] = true return token, nil } func (f *fakeRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) { userID, ok := f.byToken[oldToken] if !ok { return "", uuid.UUID{}, auth.ErrInvalidRefreshToken } delete(f.byToken, oldToken) delete(f.byUser[userID], oldToken) newToken, err := f.Issue(ctx, aud, userID, ttl) return newToken, userID, err } func (f *fakeRefreshStore) Revoke(_ context.Context, _ security.Audience, token string) error { userID, ok := f.byToken[token] if !ok { return nil } delete(f.byToken, token) delete(f.byUser[userID], token) return nil } func (f *fakeRefreshStore) RevokeAllForUser(_ context.Context, _ security.Audience, userID uuid.UUID) error { for token := range f.byUser[userID] { delete(f.byToken, token) } delete(f.byUser, userID) return nil } func TestService_Login_Success(t *testing.T) { finder := newFakeUserFinder() finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) pair, user, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } if pair.AccessToken == "" || pair.RefreshToken == "" { t.Fatal("Login() returned an empty token pair") } if user.Email != "admin@example.com" { t.Fatalf("Login() user = %+v, want email admin@example.com", user) } } func TestService_Login_WrongPasswordRejected(t *testing.T) { finder := newFakeUserFinder() finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "wrong-password") if !errors.Is(err, auth.ErrInvalidCredentials) { t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err) } } func TestService_Login_WrongRoleSpaceRejected(t *testing.T) { // A customer account must not be able to log into the admin space, even // with the correct password -- the two spaces are strictly separated. finder := newFakeUserFinder() finder.add("shopper@example.com", "correct-password", users.RoleCustomer, true) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "shopper@example.com", "correct-password") if !errors.Is(err, auth.ErrInvalidCredentials) { t.Fatalf("Login() error = %v, want ErrInvalidCredentials for a customer logging into the admin space", err) } } func TestService_Login_DisabledAccountRejected(t *testing.T) { finder := newFakeUserFinder() finder.add("admin@example.com", "correct-password", users.RoleAdmin, false) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") if !errors.Is(err, auth.ErrAccountDisabled) { t.Fatalf("Login() error = %v, want ErrAccountDisabled", err) } } func TestService_Refresh_RotatesToken(t *testing.T) { finder := newFakeUserFinder() finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } newPair, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken) if err != nil { t.Fatalf("Refresh() error = %v", err) } if newPair.RefreshToken == pair.RefreshToken { t.Fatal("Refresh() returned the same refresh token instead of rotating it") } } func TestService_Refresh_RejectsReusedToken(t *testing.T) { finder := newFakeUserFinder() finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil { t.Fatalf("first Refresh() error = %v", err) } // The old (already-rotated) refresh token must never work again. if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil { t.Fatal("second Refresh() with the same (rotated-out) token succeeded, want error") } } func TestService_Logout_RevokesToken(t *testing.T) { finder := newFakeUserFinder() finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } if err := svc.Logout(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil { t.Fatalf("Logout() error = %v", err) } if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil { t.Fatal("Refresh() succeeded after logout, want error") } }