diff --git a/backend/test/users/service_test.go b/backend/test/users/service_test.go index 62f2697..45f706b 100644 --- a/backend/test/users/service_test.go +++ b/backend/test/users/service_test.go @@ -8,9 +8,14 @@ import ( "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 { @@ -79,6 +84,16 @@ func (r *fakeRepository) Update(_ context.Context, u *users.User) error { 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 { @@ -100,7 +115,7 @@ func (g fakeAccountsGate) CustomerAccountsAvailable(context.Context) (bool, erro } func TestService_Create_HashesPasswordAndPersists(t *testing.T) { - svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() user, err := svc.Create(ctx, "admin", "super-strong-password", users.RoleAdmin) @@ -118,14 +133,14 @@ func TestService_Create_HashesPasswordAndPersists(t *testing.T) { } func TestService_Create_RejectsInvalidRole(t *testing.T) { - svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + 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}) + 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 { @@ -138,7 +153,7 @@ func TestService_Create_DuplicateUsernameRejected(t *testing.T) { } func TestService_SetPassword_ChangesHash(t *testing.T) { - svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() user, err := svc.Create(ctx, "user1", "first-strong-password", users.RoleAdmin) @@ -169,7 +184,7 @@ func TestService_SetPassword_ChangesHash(t *testing.T) { } func TestService_Delete_RemovesUser(t *testing.T) { - svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}, testSeedConfig) ctx := context.Background() user, err := svc.Create(ctx, "todelete", "super-strong-password", users.RoleAdmin) @@ -186,14 +201,14 @@ func TestService_Delete_RemovesUser(t *testing.T) { } func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) { - svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + 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}) + 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) @@ -201,7 +216,7 @@ func TestService_Create_CustomerRoleRejectedWhenAccountsDisabled(t *testing.T) { } func TestService_Create_CustomerRoleAllowedWhenAccountsEnabled(t *testing.T) { - svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + 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) @@ -210,3 +225,33 @@ func TestService_Create_CustomerRoleAllowedWhenAccountsEnabled(t *testing.T) { 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) + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9e97579..925906c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,13 +1,3 @@ -// Central fetch wrapper: the access token is kept in memory only (never -// localStorage/sessionStorage, per the auth design), and a 401 triggers a -// single silent refresh-and-retry via the httpOnly refresh cookie. -// -// Admin and customer sessions are two entirely separate spaces (separate -// cookies, separate JWT audience -- see backend/internal/modules/auth) so -// each gets its own token store and its own refresh endpoint here too: a -// customer's expired access token must never be "refreshed" against the -// admin endpoint (it would just fail, since that reads the admin cookie). - export class ApiError extends Error { status: number; constructor(message: string, status: number) {