Files
ocdp-go/backend/internal/domain/service/auth_service_test.go
Ivan087 33ddaf97db 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
2026-05-20 16:56:29 +08:00

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] = &copy
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 &copy, 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 &copy, nil
}
}
return nil, entity.ErrClusterNotFound
}
func (r *testClusterRepo) Update(ctx context.Context, cluster *entity.Cluster) error {
copy := *cluster
r.clusters[cluster.ID] = &copy
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, &copy)
}
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
}