diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index e2cf8b35..ac82f70a 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -83,7 +83,7 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error { if profile.TenantID != nil { filterTenantID = *profile.TenantID } - + // If they requested a specific tenant, verify they can manage it if requestedTenantID != "" && requestedTenantID != filterTenantID { canManage := false diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 00212e60..d206f48c 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -171,6 +171,7 @@ func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } + func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { return false, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index b12db5c9..8f558c66 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -148,24 +148,24 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { tenantIDs = append(tenantIDs, t.ID) slugs = append(slugs, t.Slug) } - + idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs) slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs) items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { summary := mapTenantSummary(t) - + // Combine counts from both ID and Slug (Max to avoid double counting if some have one or the other) idCount := idCounts[t.ID] slugCount := slugCounts[strings.ToLower(t.Slug)] - + if idCount > slugCount { summary.MemberCount = idCount } else { summary.MemberCount = slugCount } - + items = append(items, summary) } @@ -195,7 +195,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { idCount := idCounts[tenant.ID] slugCount := slugCounts[strings.ToLower(tenant.Slug)] - + count := idCount if slugCount > idCount { count = slugCount diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 54b23e72..3c481a9a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -226,7 +226,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && requester.Role == domain.RoleTenantAdmin { compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) - + // Check if the target user's companyCode is in requester's manageable tenants allowed := false for _, t := range requester.ManageableTenants { @@ -539,7 +539,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { PhoneNumber: normalizePhoneNumber(item.Phone), Attributes: attributes, }, password) - if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) continue @@ -561,7 +560,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { CreatedAt: time.Now(), UpdatedAt: time.Now(), } - + if tItem.ID != "" { localUser.TenantID = &tItem.ID } @@ -575,11 +574,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) } - + if h.KetoOutboxRepo != nil { // 1. Sync Role based relationship h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID) - + // 2. Sync direct membership to the Tenant (for count) if localUser.TenantID != nil && *localUser.TenantID != "" { _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ @@ -803,7 +802,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { } if req.CompanyCode != nil { traits["companyCode"] = *req.CompanyCode - + // Resolve and update tenant_id in traits if changed if tItem, exists := tenantCache[*req.CompanyCode]; exists { traits["tenant_id"] = tItem.ID @@ -841,11 +840,19 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { oldRole := extractTraitString(identity.Traits, "grade") oldTenantID := extractTraitString(identity.Traits, "tenant_id") - if req.Role != nil { localUser.Role = *req.Role } - if req.Status != nil { localUser.Status = *req.Status } - if req.CompanyCode != nil { localUser.CompanyCode = *req.CompanyCode } - if req.Department != nil { localUser.Department = *req.Department } - + if req.Role != nil { + localUser.Role = *req.Role + } + if req.Status != nil { + localUser.Status = *req.Status + } + if req.CompanyCode != nil { + localUser.CompanyCode = *req.CompanyCode + } + if req.Department != nil { + localUser.Department = *req.Department + } + // Resolve TenantID if changing companyCode if req.CompanyCode != nil && h.TenantService != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil { @@ -857,7 +864,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { // [Keto Sync] if h.KetoOutboxRepo != nil { - h.syncKetoRole(c.Context(), localUser.ID, + h.syncKetoRole(c.Context(), localUser.ID, localUser.Role, oldRole, oldTenantID, localUser.TenantID) } } @@ -992,12 +999,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // [Validation] Based on Tenant Schema (Multi-tenant aware) isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin) - + // If metadata is namespaced (key is tenant ID), validate each namespace // If it's flat, validate using schemaCompCode for key, val := range req.Metadata { // Basic check if key looks like a UUID (tenant ID) - if len(key) >= 32 { + if len(key) >= 32 { // Namespaced metadata if h.TenantService != nil { tenant, err := h.TenantService.GetTenant(c.Context(), key) diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 994c1225..d1f0cf0d 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -26,10 +26,12 @@ func (m *MockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosI args := m.Called(ctx) return args.Get(0).([]service.KratosIdentity), args.Error(1) } + func (m *MockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { args := m.Called(ctx, identifier) return args.String(0), args.Error(1) } + func (m *MockKratosAdmin) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) { args := m.Called(ctx, id) if args.Get(0) == nil { @@ -37,6 +39,7 @@ func (m *MockKratosAdmin) GetIdentity(ctx context.Context, id string) (*service. } return args.Get(0).(*service.KratosIdentity), args.Error(1) } + func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { args := m.Called(ctx, id, traits, state) if args.Get(0) == nil { @@ -44,9 +47,11 @@ func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits } return args.Get(0).(*service.KratosIdentity), args.Error(1) } + func (m *MockKratosAdmin) UpdateIdentityPassword(ctx context.Context, id, pw string) error { return m.Called(ctx, id, pw).Error(0) } + func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } @@ -59,9 +64,11 @@ func (m *MockOryProvider) CreateUser(user *domain.BrokerUser, password string) ( args := m.Called(user, password) return args.String(0), args.Error(1) } + func (m *MockOryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { return m.Called(loginID, newPassword, r).Error(0) } + func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { args := m.Called() return args.Get(0).(*domain.PasswordPolicy), args.Error(1) @@ -162,7 +169,7 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) { var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) results := result["results"].([]interface{}) - + assert.False(t, results[0].(map[string]interface{})["success"].(bool)) assert.Contains(t, results[0].(map[string]interface{})["message"].(string), "tenant not found") }) @@ -250,7 +257,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) { mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com"}, State: "active", }, nil).Once() - + mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{}, nil).Once() status := "inactive" @@ -264,7 +271,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) { resp, _ := app.Test(req) assert.Equal(t, 200, resp.StatusCode) - + var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) results := result["results"].([]interface{}) @@ -285,7 +292,7 @@ func TestUserHandler_BulkDeleteUsers(t *testing.T) { t.Run("Success - Delete multiple", func(t *testing.T) { mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ID: "u-1"}, nil).Once() mockKratos.On("GetIdentity", mock.Anything, "u-2").Return(&service.KratosIdentity{ID: "u-2"}, nil).Once() - + mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() mockKratos.On("DeleteIdentity", mock.Anything, "u-2").Return(nil).Once() diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 3cce7a18..98d22726 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -91,7 +91,7 @@ func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []strin } var results []result counts := make(map[string]int64) - + if len(tenantIDs) == 0 { return counts, nil } @@ -175,7 +175,7 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str if search != "" { searchTerm := "%" + search + "%" // Search in basic fields and metadata (PostgreSQL JSONB) - db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ? OR metadata::text LIKE ?)", + db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ? OR metadata::text LIKE ?)", searchTerm, searchTerm, searchTerm, searchTerm) } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 0e16890d..c8da1666 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -67,13 +67,19 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string // Fallback: Check direct membership if list objects didn't catch everything directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID) directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID) - + idMap := make(map[string]bool) - for _, id := range directAdminIDs { idMap[id] = true } - for _, id := range directOwnerIDs { idMap[id] = true } - + for _, id := range directAdminIDs { + idMap[id] = true + } + for _, id := range directOwnerIDs { + idMap[id] = true + } + allIDs = make([]string, 0, len(idMap)) - for id := range idMap { allIDs = append(allIDs, id) } + for id := range idMap { + allIDs = append(allIDs, id) + } } if len(allIDs) == 0 { diff --git a/docs/TEST_GUIDE.md b/docs/TEST_GUIDE.md new file mode 100644 index 00000000..849d65f7 --- /dev/null +++ b/docs/TEST_GUIDE.md @@ -0,0 +1,92 @@ +# 린트 체크 및 테스트 실행 가이드 + +이 문서는 Baron SSO 프로젝트의 각 모듈별 정적 분석(Lint) 및 테스트 수행 방법을 설명합니다. + +## 1. 준비 사항 +테스트를 실행하기 위해 다음 도구들이 설치되어 있어야 합니다. +- **Docker & Docker Compose** (백엔드 인프라 의존성용) +- **Go 1.22+** +- **Flutter SDK** +- **Node.js 20+** + +--- + +## 2. 모듈별 실행 명령어 + +### 2.1 Backend (Go) +백엔드 테스트는 Redis와 ClickHouse 컨테이너가 실행 중이어야 합니다. + +```bash +# 인프라 서비스 실행 +docker compose -f compose.infra.yaml up -d redis clickhouse + +cd backend + +# 린트 및 포맷 확인 +go fmt ./... +go vet ./... + +# 유닛 테스트 실행 +export REDIS_ADDR=localhost:6379 CLICKHOUSE_HOST=localhost CLICKHOUSE_PORT_NATIVE=9000 +go test ./... +``` + +### 2.2 Userfront (Flutter) +```bash +cd userfront + +# 코드 포맷 확인 +dart format --output=none --set-exit-if-changed lib test + +# 정적 분석 (경고/정보 메시지는 무시) +flutter analyze --no-fatal-warnings --no-fatal-infos + +# 유닛 및 위젯 테스트 +flutter test +``` + +### 2.3 Adminfront & Devfront (React) +Biome을 사용하여 린트 및 포맷팅을 관리하며, Playwright로 E2E 테스트를 수행합니다. + +```bash +cd adminfront # 또는 cd devfront + +# 린트 및 포맷 자동 수정 +npx biome check --write . + +# 안전하지 않은 규칙까지 포함하여 자동 수정 (필요 시) +npx biome check --write --unsafe . + +# E2E 테스트 실행 +npm test +``` + +### 2.4 i18n 검증 +코드 내에서 사용되는 다국어 키와 `locales/*.toml` 파일 간의 정합성을 검증합니다. + +```bash +# 프로젝트 루트에서 실행 +node tools/i18n-scanner/index.js +node tools/i18n-scanner/report.js + +# 결과 확인 +cat reports/i18n-report.txt +``` + +--- + +## 3. 통합 실행 (Batch) +프로젝트 루트의 스크립트를 통해 전체 과정을 한 번에 시도할 수 있습니다. + +```bash +bash run_local_checks.sh +``` + +--- + +## 4. 정리 (Cleanup) +테스트 완료 후 실행 중인 테스트용 인프라 서비스를 종료합니다. + +```bash +docker compose -f compose.infra.yaml down +```