package users_test import ( "context" "errors" "testing" "github.com/google/uuid" "backend/internal/modules/users" "backend/internal/platform/config" "backend/internal/platform/security" ) // testSeedConfig lets the service create several admins in one test. The // limit itself (ADMIN_NUMBER) is covered by TestService_Create_AdminLimit. var testSeedConfig = config.SeedConfig{AdminNumber: 10} // fakeRepository is an in-memory users.Repository used to unit-test // users.Service without a real database. type fakeRepository struct { byID map[uuid.UUID]*users.User byUsername map[string]uuid.UUID } func newFakeRepository() *fakeRepository { return &fakeRepository{ byID: make(map[uuid.UUID]*users.User), byUsername: make(map[string]uuid.UUID), } } func (r *fakeRepository) Create(_ context.Context, u *users.User) error { if _, exists := r.byUsername[u.Username]; exists { return users.ErrUsernameTaken } cp := *u r.byID[u.ID] = &cp r.byUsername[u.Username] = u.ID return nil } func (r *fakeRepository) FindByUsername(_ context.Context, username string) (*users.User, error) { id, ok := r.byUsername[username] if !ok { return nil, users.ErrNotFound } cp := *r.byID[id] return &cp, nil } func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) { u, ok := r.byID[id] if !ok { return nil, users.ErrNotFound } cp := *u return &cp, nil } func (r *fakeRepository) List(_ context.Context) ([]*users.User, error) { list := make([]*users.User, 0, len(r.byID)) for _, u := range r.byID { cp := *u list = append(list, &cp) } return list, nil } func (r *fakeRepository) Update(_ context.Context, u *users.User) error { existing, ok := r.byID[u.ID] if !ok { return users.ErrNotFound } if existing.Username != u.Username { if _, taken := r.byUsername[u.Username]; taken { return users.ErrUsernameTaken } delete(r.byUsername, existing.Username) r.byUsername[u.Username] = u.ID } cp := *u r.byID[u.ID] = &cp return nil } func (r *fakeRepository) CountByRole(_ context.Context, role string) (int64, error) { var n int64 for _, u := range r.byID { if u.Role == role { n++ } } return n, nil } func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error { u, ok := r.byID[id] if !ok { return users.ErrNotFound } delete(r.byUsername, u.Username) delete(r.byID, id) return nil } // fakeAccountsGate is an in-memory users.AccountsGate used to unit-test the // customer-role creation gate without a real site.Service. type fakeAccountsGate struct { available bool } func (g fakeAccountsGate) CustomerAccountsAvailable(context.Context) (bool, error) { return g.available, nil } func TestService_Create_HashesPasswordAndPersists(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() user, err := svc.Create(ctx, "admin", "super-strong-password", users.RoleAdmin) if err != nil { t.Fatalf("Create() error = %v", err) } if user.PasswordHash == "super-strong-password" { t.Fatal("Create() stored the plaintext password instead of a hash") } ok, err := security.VerifyPassword(user.PasswordHash, "super-strong-password") if err != nil || !ok { t.Fatalf("stored password hash does not verify against the original password (ok=%v, err=%v)", ok, err) } } func TestService_Create_RejectsInvalidRole(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) if _, err := svc.Create(context.Background(), "user-a", "super-strong-password", "superadmin"); err == nil { t.Fatal("Create() error = nil, want error for an invalid role") } } func TestService_Create_DuplicateUsernameRejected(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() if _, err := svc.Create(ctx, "dup-user", "super-strong-password", users.RoleAdmin); err != nil { t.Fatalf("first Create() error = %v", err) } _, err := svc.Create(ctx, "dup-user", "another-strong-password", users.RoleAdmin) if !errors.Is(err, users.ErrUsernameTaken) { t.Fatalf("second Create() error = %v, want ErrUsernameTaken", err) } } func TestService_SetPassword_ChangesHash(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() user, err := svc.Create(ctx, "user1", "first-strong-password", users.RoleAdmin) if err != nil { t.Fatalf("Create() error = %v", err) } oldHash := user.PasswordHash if err := svc.SetPassword(ctx, user.ID, "second-strong-password"); err != nil { t.Fatalf("SetPassword() error = %v", err) } updated, err := svc.FindByID(ctx, user.ID) if err != nil { t.Fatalf("FindByID() error = %v", err) } if updated.PasswordHash == oldHash { t.Fatal("SetPassword() did not change the stored hash") } ok, _ := security.VerifyPassword(updated.PasswordHash, "second-strong-password") if !ok { t.Fatal("new password does not verify against the updated hash") } ok, _ = security.VerifyPassword(updated.PasswordHash, "first-strong-password") if ok { t.Fatal("old password still verifies after SetPassword()") } } func TestService_Delete_RemovesUser(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() user, err := svc.Create(ctx, "todelete", "super-strong-password", users.RoleAdmin) if err != nil { t.Fatalf("Create() error = %v", err) } if err := svc.Delete(ctx, user.ID); err != nil { t.Fatalf("Delete() error = %v", err) } if _, err := svc.FindByID(ctx, user.ID); !errors.Is(err, users.ErrNotFound) { t.Fatalf("FindByID() after delete error = %v, want ErrNotFound", err) } } func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) if err := svc.Delete(context.Background(), uuid.New()); !errors.Is(err, users.ErrNotFound) { t.Fatalf("Delete() error = %v, want ErrNotFound", err) } } func TestService_Create_CustomerRoleRejectedWhenAccountsDisabled(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: false}, testSeedConfig) _, err := svc.Create(context.Background(), "some-customer", "super-strong-password", users.RoleCustomer) if !errors.Is(err, users.ErrCustomerAccountsDisabled) { t.Fatalf("Create() error = %v, want ErrCustomerAccountsDisabled", err) } } func TestService_Create_CustomerRoleAllowedWhenAccountsEnabled(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) user, err := svc.Create(context.Background(), "some-customer", "super-strong-password", users.RoleCustomer) if err != nil { t.Fatalf("Create() error = %v", err) } if user.Role != users.RoleCustomer { t.Fatalf("Create() role = %q, want %q", user.Role, users.RoleCustomer) } } func TestService_Create_AdminLimit(t *testing.T) { ctx := context.Background() cfg := config.SeedConfig{AdminNumber: 2} svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, cfg) for _, name := range []string{"admin-a", "admin-b"} { if _, err := svc.Create(ctx, name, "super-strong-password", users.RoleAdmin); err != nil { t.Fatalf("Create(%q) error = %v, want nil while under the limit", name, err) } } _, err := svc.Create(ctx, "admin-c", "super-strong-password", users.RoleAdmin) if !errors.Is(err, users.ErrAdminLimitReached) { t.Fatalf("Create() error = %v, want ErrAdminLimitReached once ADMIN_NUMBER admins exist", err) } // The limit only concerns admins: customers are not counted against it. if _, err := svc.Create(ctx, "customer-a", "super-strong-password", users.RoleCustomer); err != nil { t.Fatalf("Create(customer) error = %v, want nil (customer accounts are outside ADMIN_NUMBER)", err) } } func TestService_Create_AdminLimitZeroRejectsEveryAdmin(t *testing.T) { svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, config.SeedConfig{}) _, err := svc.Create(context.Background(), "admin", "super-strong-password", users.RoleAdmin) if !errors.Is(err, users.ErrAdminLimitReached) { t.Fatalf("Create() error = %v, want ErrAdminLimitReached when ADMIN_NUMBER is 0", err) } }