package service import ( "context" "errors" "testing" "time" "github.com/google/uuid" "github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock" "github.com/ocdp/cluster-service/internal/domain/entity" "github.com/ocdp/cluster-service/internal/domain/repository" "github.com/ocdp/cluster-service/internal/pkg/authz" jwtpkg "github.com/ocdp/cluster-service/internal/pkg/jwt" ) func TestAuthServiceUpdateUserDowngradeReusesUsernameWorkspace(t *testing.T) { ctx := adminContext() userRepo := mock.NewUserRepositoryMock() workspaceRepo := mock.NewWorkspaceRepositoryMock() svc := NewAuthService(userRepo, workspaceRepo, testPasswordHasher{}, testTokenGenerator{}) target := testUser("user-1", "alice", authz.RoleAdmin, entity.DefaultWorkspaceID) if err := userRepo.Create(ctx, target); err != nil { t.Fatalf("seed user: %v", err) } workspace := entity.NewWorkspace(userWorkspaceName("alice"), "admin") workspace.ID = "workspace-alice" workspace.K8sNamespace = entity.NamespaceForUser("alice") workspace.K8sSAName = entity.ServiceAccountForNamespace(workspace.K8sNamespace) if err := workspaceRepo.Create(ctx, workspace); err != nil { t.Fatalf("seed workspace: %v", err) } updated, err := svc.UpdateUser(ctx, target.ID, authz.RoleUser, "", UserWorkspaceOptions{DefaultClusterID: "cluster-1"}, nil, nil) if err != nil { t.Fatalf("UpdateUser returned error: %v", err) } if updated.Role != authz.RoleUser { t.Fatalf("expected user role, got %q", updated.Role) } if updated.WorkspaceID != workspace.ID { t.Fatalf("expected reused workspace %q, got %q", workspace.ID, updated.WorkspaceID) } reused, err := workspaceRepo.GetByID(ctx, workspace.ID) if err != nil { t.Fatalf("get reused workspace: %v", err) } if reused.DefaultClusterID != "cluster-1" { t.Fatalf("expected updated default cluster, got %q", reused.DefaultClusterID) } } func TestAuthServiceRegisterUserAlwaysCreatesPrivateWorkspaceWithZeroDefaultQuotas(t *testing.T) { ctx := adminContext() userRepo := mock.NewUserRepositoryMock() workspaceRepo := mock.NewWorkspaceRepositoryMock() svc := NewAuthService(userRepo, workspaceRepo, testPasswordHasher{}, testTokenGenerator{}) user, err := svc.Register(ctx, "alice", "password", authz.RoleUser, "shared-workspace", UserWorkspaceOptions{}, nil, nil) if err != nil { t.Fatalf("Register returned error: %v", err) } if user.WorkspaceID == "shared-workspace" || user.WorkspaceID == entity.DefaultWorkspaceID { t.Fatalf("expected private user workspace, got %q", user.WorkspaceID) } workspace, err := workspaceRepo.GetByID(ctx, user.WorkspaceID) if err != nil { t.Fatalf("get user workspace: %v", err) } if workspace.K8sNamespace != entity.NamespaceForUser("alice") { t.Fatalf("expected user namespace %q, got %q", entity.NamespaceForUser("alice"), workspace.K8sNamespace) } if workspace.QuotaCPU != "" || workspace.QuotaMemory != "" || workspace.QuotaGPU != "0" || workspace.QuotaGPUMem != "0" { t.Fatalf("expected omitted CPU/memory to stay unlimited and GPU/gpumem to default zero, got cpu=%q memory=%q gpu=%q gpumem=%q", workspace.QuotaCPU, workspace.QuotaMemory, workspace.QuotaGPU, workspace.QuotaGPUMem) } } func TestAuthServiceUpdateUserDowngradeRejectsNamespaceConflict(t *testing.T) { ctx := adminContext() userRepo := mock.NewUserRepositoryMock() workspaceRepo := mock.NewWorkspaceRepositoryMock() svc := NewAuthService(userRepo, workspaceRepo, testPasswordHasher{}, testTokenGenerator{}) target := testUser("user-1", "alice", authz.RoleAdmin, entity.DefaultWorkspaceID) if err := userRepo.Create(ctx, target); err != nil { t.Fatalf("seed user: %v", err) } conflicting := entity.NewWorkspace("someone-else", "admin") conflicting.ID = "workspace-other" conflicting.K8sNamespace = entity.NamespaceForUser("alice") conflicting.K8sSAName = entity.ServiceAccountForNamespace(conflicting.K8sNamespace) if err := workspaceRepo.Create(ctx, conflicting); err != nil { t.Fatalf("seed conflicting workspace: %v", err) } _, err := svc.UpdateUser(ctx, target.ID, authz.RoleUser, "", UserWorkspaceOptions{}, nil, nil) if !errors.Is(err, entity.ErrWorkspaceNamespaceConflict) { t.Fatalf("expected namespace conflict, got %v", err) } } func TestAuthServiceDeleteUserRejectsUserWithInstances(t *testing.T) { ctx := adminContext() userRepo := mock.NewUserRepositoryMock() workspaceRepo := mock.NewWorkspaceRepositoryMock() instanceRepo := mock.NewInstanceRepositoryMock() svc := NewAuthService(userRepo, workspaceRepo, testPasswordHasher{}, testTokenGenerator{}) svc.SetUserLifecycleCleanup(instanceRepo, nil, nil, nil) user := testUser("user-1", "alice", authz.RoleUser, "workspace-alice") if err := userRepo.Create(ctx, user); err != nil { t.Fatalf("seed user: %v", err) } instance := entity.NewInstance("cluster-1", "app", "ocdp-u-alice", "registry-1", "repo", "chart", "1.0.0") instance.ID = "instance-1" instance.OwnerID = user.ID instance.WorkspaceID = user.WorkspaceID if err := instanceRepo.Create(ctx, instance); err != nil { t.Fatalf("seed instance: %v", err) } err := svc.DeleteUser(ctx, user.ID) if !errors.Is(err, entity.ErrUserHasInstances) { t.Fatalf("expected user instance conflict, got %v", err) } if _, err := userRepo.GetByID(ctx, user.ID); err != nil { t.Fatalf("user should not be deleted: %v", err) } } func TestAuthServiceDeleteUserRejectsWorkspaceInstanceEvenWithDifferentOwner(t *testing.T) { ctx := adminContext() userRepo := mock.NewUserRepositoryMock() workspaceRepo := mock.NewWorkspaceRepositoryMock() instanceRepo := mock.NewInstanceRepositoryMock() svc := NewAuthService(userRepo, workspaceRepo, testPasswordHasher{}, testTokenGenerator{}) svc.SetUserLifecycleCleanup(instanceRepo, nil, nil, nil) user := testUser("user-1", "alice", authz.RoleUser, "workspace-alice") if err := userRepo.Create(ctx, user); err != nil { t.Fatalf("seed user: %v", err) } instance := entity.NewInstance("cluster-1", "shared-workspace-app", "ocdp-u-alice", "registry-1", "repo", "chart", "1.0.0") instance.ID = "instance-1" instance.OwnerID = "other-user" instance.WorkspaceID = user.WorkspaceID if err := instanceRepo.Create(ctx, instance); err != nil { t.Fatalf("seed workspace instance: %v", err) } err := svc.DeleteUser(ctx, user.ID) if !errors.Is(err, entity.ErrUserHasInstances) { t.Fatalf("expected workspace instance conflict, got %v", err) } if _, err := userRepo.GetByID(ctx, user.ID); err != nil { t.Fatalf("user should not be deleted: %v", err) } } func TestAuthServiceDeleteUserCleansExclusiveWorkspaceBindings(t *testing.T) { ctx := adminContext() userRepo := mock.NewUserRepositoryMock() workspaceRepo := mock.NewWorkspaceRepositoryMock() instanceRepo := mock.NewInstanceRepositoryMock() bindingRepo := mock.NewWorkspaceClusterBindingRepositoryMock() clusterRepo := &testClusterRepo{clusters: map[string]*entity.Cluster{ "cluster-1": {ID: "cluster-1", Name: "cluster-1", Host: "https://cluster.invalid", Token: "token"}, }} tenantClient := &recordingTenantClient{} svc := NewAuthService(userRepo, workspaceRepo, testPasswordHasher{}, testTokenGenerator{}) svc.SetUserLifecycleCleanup(instanceRepo, clusterRepo, bindingRepo, tenantClient) workspace := entity.NewWorkspace(userWorkspaceName("alice"), "admin") workspace.ID = "workspace-alice" workspace.K8sNamespace = entity.NamespaceForUser("alice") workspace.K8sSAName = entity.ServiceAccountForNamespace(workspace.K8sNamespace) if err := workspaceRepo.Create(ctx, workspace); err != nil { t.Fatalf("seed workspace: %v", err) } user := testUser("user-1", "alice", authz.RoleUser, workspace.ID) if err := userRepo.Create(ctx, user); err != nil { t.Fatalf("seed user: %v", err) } if err := bindingRepo.Upsert(ctx, &entity.WorkspaceClusterBinding{ ID: "binding-1", WorkspaceID: workspace.ID, ClusterID: "cluster-1", Namespace: workspace.K8sNamespace, ServiceAccount: workspace.K8sSAName, Status: "active", }); err != nil { t.Fatalf("seed binding: %v", err) } if err := svc.DeleteUser(ctx, user.ID); err != nil { t.Fatalf("DeleteUser returned error: %v", err) } if _, err := userRepo.GetByID(ctx, user.ID); !errors.Is(err, entity.ErrUserNotFound) { t.Fatalf("expected user deleted, got %v", err) } if bindings, err := bindingRepo.ListByWorkspace(ctx, workspace.ID); err != nil || len(bindings) != 0 { t.Fatalf("expected bindings cleaned, got len=%d err=%v", len(bindings), err) } if len(tenantClient.deleted) != 1 || tenantClient.deleted[0] != workspace.K8sNamespace { t.Fatalf("expected tenant namespace cleanup, got %#v", tenantClient.deleted) } if _, err := workspaceRepo.GetByID(ctx, workspace.ID); !errors.Is(err, entity.ErrWorkspaceNotFound) { t.Fatalf("expected exclusive workspace deleted, got %v", err) } } func adminContext() context.Context { return authz.WithPrincipal(context.Background(), &authz.Principal{ UserID: "admin-1", Username: "admin", Role: authz.RoleAdmin, WorkspaceID: entity.DefaultWorkspaceID, }) } func testUser(id, username, role, workspaceID string) *entity.User { user := entity.NewUser(username, "hash", username+"@local.ocdp") user.ID = id user.Role = role user.WorkspaceID = workspaceID return user } type testPasswordHasher struct{} func (testPasswordHasher) Hash(password string) (string, error) { return "hash:" + password, nil } func (testPasswordHasher) Verify(password, hash string) error { return nil } type testTokenGenerator struct{} func (testTokenGenerator) Generate(userID, username, role, workspaceID string) (string, string, error) { return "access", "refresh", nil } func (testTokenGenerator) Verify(token string) (string, string, error) { return "", "", nil } func (testTokenGenerator) VerifyWithIssuedAt(token string) (string, string, int64, error) { return "", "", 0, nil } func (testTokenGenerator) VerifyAccess(token string) (*jwtpkg.Claims, error) { return nil, nil } func (testTokenGenerator) VerifyRefresh(token string) (*jwtpkg.Claims, error) { return nil, nil } func (testTokenGenerator) Refresh(refreshToken string) (string, error) { return "access", nil } type testClusterRepo struct { clusters map[string]*entity.Cluster } func (r *testClusterRepo) Create(ctx context.Context, cluster *entity.Cluster) error { if cluster.ID == "" { cluster.ID = uuid.New().String() } copy := *cluster r.clusters[cluster.ID] = © return nil } func (r *testClusterRepo) GetByID(ctx context.Context, id string) (*entity.Cluster, error) { cluster, ok := r.clusters[id] if !ok { return nil, entity.ErrClusterNotFound } copy := *cluster return ©, nil } func (r *testClusterRepo) GetByName(ctx context.Context, name string) (*entity.Cluster, error) { for _, cluster := range r.clusters { if cluster.Name == name { copy := *cluster return ©, nil } } return nil, entity.ErrClusterNotFound } func (r *testClusterRepo) Update(ctx context.Context, cluster *entity.Cluster) error { copy := *cluster r.clusters[cluster.ID] = © return nil } func (r *testClusterRepo) Delete(ctx context.Context, id string) error { delete(r.clusters, id) return nil } func (r *testClusterRepo) List(ctx context.Context) ([]*entity.Cluster, error) { result := make([]*entity.Cluster, 0, len(r.clusters)) for _, cluster := range r.clusters { copy := *cluster result = append(result, ©) } return result, nil } type recordingTenantClient struct { deleted []string usage *repository.ResourceQuotaUsage } func (c *recordingTenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error { return nil } func (c *recordingTenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) { return nil, nil } func (c *recordingTenantClient) GetResourceQuotaUsage(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) (*repository.ResourceQuotaUsage, error) { if c.usage != nil { return c.usage, nil } return &repository.ResourceQuotaUsage{}, nil } func (c *recordingTenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error { return nil } func (c *recordingTenantClient) DeleteTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error { if err := binding.Validate(); err != nil { return err } c.deleted = append(c.deleted, binding.Namespace) return nil }