fix: scale replicas in response, K8s metrics client, quota precheck, auth tests
- 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
This commit is contained in:
322
backend/internal/domain/service/auth_service_test.go
Normal file
322
backend/internal/domain/service/auth_service_test.go
Normal file
@ -0,0 +1,322 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user