- Add GetMetrics method to MetricsClient interface and implement cluster metrics API - Add QuotaPrecheck service for validating resource quotas before deployment - Add auth DTO with role/permission models and auth handler tests - Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics - Update workspace handler with GetWorkspace endpoint and shared-user list - Fix monitoring handler to use correct service method name - Add tail_lines fallback in instance handler for snake_case query params - Update nginx config for SSE log streaming support (no buffering) - Add comprehensive test coverage: auth_service_test, auth_handler_test, auth_dto_test, metrics_client_test, quota_precheck_test - Update error messages for quota validation and instance operations - ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit - InstanceCard: correctly disable scale-minus when replicas <= 0 - SidebarLayout: add hover transition for sidebar items - Update todo.md and lessons.md with latest fixes
323 lines
12 KiB
Go
323 lines
12 KiB
Go
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
|
|
}
|