first
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user