258 lines
8.2 KiB
Go
258 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|