Files
template-vitrine/backend/test/auth/service_test.go
T
2026-09-14 20:50:19 +02:00

218 lines
7.5 KiB
Go

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