From fd82dd9bdd592d7a39f00ec1528e0e414a331d2d Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 20 May 2026 11:17:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A1=B0=EC=A7=81=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../page-2026-05-20T02-00-01-354Z.yml | 23 ++ README.md | 1 + .../routes/TenantWorksmobilePage.test.ts | 51 +++- .../tenants/routes/TenantWorksmobilePage.tsx | 23 +- .../tenants/routes/worksmobileComparison.ts | 49 ++++ .../internal/bootstrap/admin_account_test.go | 2 +- backend/internal/bootstrap/bootstrap.go | 22 ++ .../bootstrap/user_metadata_sanitize_test.go | 46 +++ backend/internal/domain/user.go | 6 +- backend/internal/domain/user_test.go | 4 +- backend/internal/handler/user_handler.go | 4 +- .../service/worksmobile_mapper_test.go | 2 +- .../service/worksmobile_sync_service.go | 121 +++++++- .../service/worksmobile_sync_service_test.go | 267 +++++++++++++++++- ...smobile-directory-sync-technical-review.md | 1 + 15 files changed, 592 insertions(+), 30 deletions(-) create mode 100644 .playwright-mcp/page-2026-05-20T02-00-01-354Z.yml diff --git a/.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml b/.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml new file mode 100644 index 00000000..44680000 --- /dev/null +++ b/.playwright-mcp/page-2026-05-20T02-00-01-354Z.yml @@ -0,0 +1,23 @@ +- generic [ref=e4]: + - generic [ref=e5]: + - img [ref=e7] + - generic [ref=e9]: + - heading "Baron SSO" [level=1] [ref=e10] + - paragraph [ref=e11]: Admin Control Plane + - generic [ref=e12]: + - generic [ref=e13]: + - heading "관리자 로그인" [level=3] [ref=e14]: + - img [ref=e15] + - text: 관리자 로그인 + - paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다. + - generic [ref=e19]: + - button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]: + - img [ref=e21] + - text: SSO 계정으로 로그인 + - img [ref=e23] + - paragraph [ref=e27]: + - text: 관리자 전역 세션은 보안을 위해 15분간 유지됩니다. + - text: 민감한 작업 시 재인증을 요구할 수 있습니다. + - paragraph [ref=e32]: + - text: 인증 정보가 없거나 로그인이 되지 않는 경우 + - text: 시스템 관리자에게 문의하세요. \ No newline at end of file diff --git a/README.md b/README.md index 944d0dc4..7922dc66 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 - 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다. - 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다. +- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다. - `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다. diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts index 6df0b398..68257648 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -4,11 +4,14 @@ import { canCreateWorksmobileRow, canOpenWorksmobilePasswordManage, canSelectWorksmobileRow, + comparisonFilterOptions, filterVisibleWorksmobileComparisonRows, filterWorksmobileComparisonRows, filterWorksmobileComparisonRowsBySearch, formatWorksmobileOrgDetails, formatWorksmobilePersonName, + formatWorksmobileUpdateDetails, + getDefaultGroupComparisonFilters, getDefaultWorksmobileComparisonColumns, getWorksmobileComparisonStatusLabel, getWorksmobileRowSelectionKey, @@ -24,6 +27,7 @@ describe("TenantWorksmobilePage comparison helpers", () => { it("summarizes comparison rows by status", () => { const summary = summarizeWorksmobileComparison([ { resourceType: "USER", status: "matched" }, + { resourceType: "GROUP", status: "needs_update" }, { resourceType: "USER", status: "missing_in_worksmobile" }, { resourceType: "USER", status: "missing_in_baron" }, { resourceType: "USER", status: "missing_external_key" }, @@ -31,8 +35,9 @@ describe("TenantWorksmobilePage comparison helpers", () => { ]); expect(summary).toEqual({ - total: 5, + total: 6, matched: 1, + needsUpdate: 1, missingInWorksmobile: 1, missingInBaron: 2, missingExternalKey: 1, @@ -50,6 +55,9 @@ describe("TenantWorksmobilePage comparison helpers", () => { expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe( "ex_key 없음", ); + expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe( + "업데이트 필요", + ); expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe( "unknown_status", ); @@ -426,11 +434,52 @@ describe("TenantWorksmobilePage comparison helpers", () => { it("orders user comparison filter options from Baron-only first", () => { expect(userFilterOptions.map((option) => option.value)).toEqual([ "baron_only", + "needs_update", "works_only", "matched", ]); }); + it("keeps all organization/group comparison filter labels available", () => { + expect(comparisonFilterOptions).toEqual([ + { value: "baron_only", label: "바론에만 있음" }, + { value: "needs_update", label: "업데이트 필요" }, + { value: "works_only", label: "웍스에만 있음" }, + { value: "matched", label: "양쪽 다 있음" }, + ]); + }); + + it("shows update-needed group rows by default", () => { + const rows = [ + { resourceType: "GROUP", status: "needs_update", baronId: "org-1" }, + { resourceType: "GROUP", status: "matched", baronId: "org-2" }, + ]; + + expect( + filterWorksmobileComparisonRows(rows, getDefaultGroupComparisonFilters()), + ).toEqual([rows[0]]); + }); + + it("formats update details for changed organization rows", () => { + expect( + formatWorksmobileUpdateDetails({ + resourceType: "GROUP", + status: "needs_update", + baronId: "818c856b-9545-442f-b827-d1c569f200b0", + baronName: "삼안기술개발센터(조직도용)", + worksmobileName: "기술개발센터(조직도용)", + baronParentId: "9caf62e1-297d-4e8f-870b-61780998bbeb", + baronParentWorksmobileId: "works-saman", + baronParentWorksmobileName: "삼안", + worksmobileParentId: "works-other", + worksmobileParentName: "다른 상위", + }), + ).toEqual([ + "이름: 기술개발센터(조직도용) -> 삼안기술개발센터(조직도용)", + "상위: 다른 상위 -> 삼안", + ]); + }); + it("formats WORKS account name with level on one line", () => { expect( formatWorksmobilePersonName({ diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index 08595d58..a8af2063 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -63,6 +63,8 @@ import { filterWorksmobileComparisonRowsBySearch, formatWorksmobileOrgDetails, formatWorksmobilePersonName, + formatWorksmobileUpdateDetails, + getDefaultGroupComparisonFilters, getDefaultWorksmobileComparisonColumns, getWorksmobileComparisonStatusLabel, getWorksmobileRowSelectionKey, @@ -81,7 +83,7 @@ export function TenantWorksmobilePage() { >(["baron_only", "works_only"]); const [groupFilters, setGroupFilters] = React.useState< WorksmobileComparisonFilter[] - >(["baron_only", "works_only"]); + >(getDefaultGroupComparisonFilters); const [includeUserMissingExternalKey, setIncludeUserMissingExternalKey] = React.useState(false); const [includeGroupMissingExternalKey, setIncludeGroupMissingExternalKey] = @@ -594,6 +596,9 @@ function getWorksmobileComparisonStatusVariant(status: string) { if (status === "matched") { return "success"; } + if (status === "needs_update") { + return "warning"; + } if (status === "missing_external_key") { return "warning"; } @@ -622,6 +627,10 @@ function ComparisonSummary({ Baron 없음 {summary.missingInBaron} +
+ 업데이트 필요 + {summary.needsUpdate} +
ex_key 없음 {summary.missingExternalKey} @@ -860,7 +869,7 @@ function ComparisonTable({ return (
-
+

{title}

-
+
{getWorksmobileComparisonStatusLabel(row.status)} + {formatWorksmobileUpdateDetails(row).map((detail) => ( +
+ {detail} +
+ ))} )} {showBaronIdColumn && isColumnVisible("baronId") && ( diff --git a/adminfront/src/features/tenants/routes/worksmobileComparison.ts b/adminfront/src/features/tenants/routes/worksmobileComparison.ts index 61979d53..cc74886a 100644 --- a/adminfront/src/features/tenants/routes/worksmobileComparison.ts +++ b/adminfront/src/features/tenants/routes/worksmobileComparison.ts @@ -3,11 +3,13 @@ import type { WorksmobileComparisonItem } from "../../../lib/adminApi"; export type WorksmobileComparisonFilter = | "works_only" | "baron_only" + | "needs_update" | "matched"; export type WorksmobileComparisonSummary = { total: number; matched: number; + needsUpdate: number; missingInWorksmobile: number; missingInBaron: number; missingExternalKey: number; @@ -52,6 +54,8 @@ export function summarizeWorksmobileComparison( (summary, row) => { if (row.status === "matched") { summary.matched += 1; + } else if (row.status === "needs_update") { + summary.needsUpdate += 1; } else if (row.status === "missing_in_worksmobile") { summary.missingInWorksmobile += 1; } else if (row.status === "missing_in_baron") { @@ -64,6 +68,7 @@ export function summarizeWorksmobileComparison( { total: rows.length, matched: 0, + needsUpdate: 0, missingInWorksmobile: 0, missingInBaron: 0, missingExternalKey: 0, @@ -77,6 +82,8 @@ export function getWorksmobileComparisonStatusLabel(status: string) { return "일치"; case "missing_in_worksmobile": return "WORKS 없음"; + case "needs_update": + return "업데이트 필요"; case "missing_in_baron": return "Baron 없음"; case "missing_external_key": @@ -292,6 +299,42 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) { return details; } +export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) { + if (row.status !== "needs_update") { + return []; + } + + const details: string[] = []; + const baronName = row.baronName?.trim(); + const worksmobileName = row.worksmobileName?.trim(); + if (baronName && worksmobileName && baronName !== worksmobileName) { + details.push(`이름: ${worksmobileName} -> ${baronName}`); + } + + const expectedParent = + row.baronParentWorksmobileName ?? + row.baronParentName ?? + row.baronParentWorksmobileId ?? + row.baronParentId ?? + ""; + const actualParent = + row.worksmobileParentName ?? + row.worksmobileParentExternalKey ?? + row.worksmobileParentId ?? + ""; + const expectedParentKey = + row.baronParentWorksmobileId ?? row.baronParentId ?? ""; + const actualParentKey = + row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? ""; + if (expectedParentKey !== actualParentKey) { + details.push( + `상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`, + ); + } + + return details; +} + export function buildWorksmobilePasswordManageUrl({ tenantId, domainId, @@ -345,15 +388,21 @@ export const comparisonFilterOptions: Array<{ label: string; }> = [ { value: "baron_only", label: "바론에만 있음" }, + { value: "needs_update", label: "업데이트 필요" }, { value: "works_only", label: "웍스에만 있음" }, { value: "matched", label: "양쪽 다 있음" }, ]; export const userFilterOptions = comparisonFilterOptions; +export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] { + return ["baron_only", "needs_update", "works_only"]; +} + const worksmobileFilterStatuses: Record = { baron_only: ["missing_in_worksmobile"], + needs_update: ["needs_update"], works_only: ["missing_in_baron"], matched: ["matched"], }; diff --git a/backend/internal/bootstrap/admin_account_test.go b/backend/internal/bootstrap/admin_account_test.go index 38983d9d..4c151b3b 100644 --- a/backend/internal/bootstrap/admin_account_test.go +++ b/backend/internal/bootstrap/admin_account_test.go @@ -56,7 +56,7 @@ func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) { Email: "existing@example.com", Name: "Existing", Role: domain.RoleUser, - Status: domain.UserStatusInactive, + Status: domain.UserStatusPreboarding, }, } diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index d4422168..66ee2ea7 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -23,6 +23,9 @@ func Run(db *gorm.DB) error { } // 3. Normalize staging seed/read-model data + if err := CanonicalizeLegacyUserStatuses(db); err != nil { + return fmt.Errorf("legacy user status canonicalization failed: %w", err) + } if err := SanitizeLegacyUserMetadata(db); err != nil { return fmt.Errorf("legacy user metadata sanitize failed: %w", err) } @@ -64,6 +67,25 @@ func migrateSchemas(db *gorm.DB) error { ) } +func CanonicalizeLegacyUserStatuses(db *gorm.DB) error { + if db == nil || !db.Migrator().HasTable(&domain.User{}) { + return nil + } + updates := map[string]string{ + "inactive": domain.UserStatusPreboarding, + "leave_of_absence": domain.UserStatusTemporaryLeave, + "baron_only": domain.UserStatusBaronGuest, + } + for legacy, canonical := range updates { + if err := db.Model(&domain.User{}). + Where("status = ?", legacy). + Update("status", canonical).Error; err != nil { + return fmt.Errorf("failed to canonicalize users.status %s to %s: %w", legacy, canonical, err) + } + } + return nil +} + func dropLegacyUserCompanyColumns(db *gorm.DB) error { if !db.Migrator().HasTable(&domain.User{}) { return nil diff --git a/backend/internal/bootstrap/user_metadata_sanitize_test.go b/backend/internal/bootstrap/user_metadata_sanitize_test.go index a6f4c061..bf48fffa 100644 --- a/backend/internal/bootstrap/user_metadata_sanitize_test.go +++ b/backend/internal/bootstrap/user_metadata_sanitize_test.go @@ -68,6 +68,52 @@ func TestSanitizeLegacyUserMetadataRemovesClassificationFlags(t *testing.T) { } } +func TestCanonicalizeLegacyUserStatuses(t *testing.T) { + db := openBootstrapPostgresTestDB(t) + if err := db.AutoMigrate(&domain.User{}); err != nil { + t.Fatalf("failed to migrate users table: %v", err) + } + + users := []domain.User{ + {ID: "11000000-0000-0000-0000-000000000001", Email: "inactive@example.com", Name: "Inactive", Role: domain.RoleUser, Status: "inactive"}, + {ID: "11000000-0000-0000-0000-000000000002", Email: "leave@example.com", Name: "Leave", Role: domain.RoleUser, Status: "leave_of_absence"}, + {ID: "11000000-0000-0000-0000-000000000003", Email: "baron-only@example.com", Name: "Baron Only", Role: domain.RoleUser, Status: "baron_only"}, + {ID: "11000000-0000-0000-0000-000000000004", Email: "active@example.com", Name: "Active", Role: domain.RoleUser, Status: domain.UserStatusActive}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatalf("failed to create users: %v", err) + } + + if err := CanonicalizeLegacyUserStatuses(db); err != nil { + t.Fatalf("CanonicalizeLegacyUserStatuses returned error: %v", err) + } + if err := CanonicalizeLegacyUserStatuses(db); err != nil { + t.Fatalf("CanonicalizeLegacyUserStatuses must be idempotent: %v", err) + } + + got := map[string]string{} + var loaded []domain.User + if err := db.Find(&loaded).Error; err != nil { + t.Fatalf("failed to load users: %v", err) + } + for _, user := range loaded { + got[user.Email] = user.Status + } + + if got["inactive@example.com"] != domain.UserStatusPreboarding { + t.Fatalf("inactive status = %q, want %q", got["inactive@example.com"], domain.UserStatusPreboarding) + } + if got["leave@example.com"] != domain.UserStatusTemporaryLeave { + t.Fatalf("leave status = %q, want %q", got["leave@example.com"], domain.UserStatusTemporaryLeave) + } + if got["baron-only@example.com"] != domain.UserStatusBaronGuest { + t.Fatalf("baron_only status = %q, want %q", got["baron-only@example.com"], domain.UserStatusBaronGuest) + } + if got["active@example.com"] != domain.UserStatusActive { + t.Fatalf("active status = %q, want %q", got["active@example.com"], domain.UserStatusActive) + } +} + func TestRunSanitizesLegacyUserMetadata(t *testing.T) { db := openBootstrapPostgresTestDB(t) if err := db.AutoMigrate(&domain.User{}); err != nil { diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 01981475..9195a3a5 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -21,9 +21,7 @@ const ( // User statuses const ( UserStatusActive = "active" - UserStatusInactive = "inactive" UserStatusSuspended = "suspended" - UserStatusLeaveOfAbsence = "leave_of_absence" UserStatusTemporaryLeave = "temporary_leave" UserStatusPreboarding = "preboarding" UserStatusBaronGuest = "baron_guest" @@ -37,9 +35,9 @@ func NormalizeUserStatus(status string) string { return UserStatusActive case "blocked", UserStatusSuspended: return UserStatusSuspended - case UserStatusInactive, UserStatusPreboarding: + case "inactive", UserStatusPreboarding: return UserStatusPreboarding - case UserStatusLeaveOfAbsence, UserStatusTemporaryLeave: + case "leave_of_absence", UserStatusTemporaryLeave: return UserStatusTemporaryLeave case "baron_only", UserStatusBaronGuest: return UserStatusBaronGuest diff --git a/backend/internal/domain/user_test.go b/backend/internal/domain/user_test.go index 54fb6e9b..e1ddb896 100644 --- a/backend/internal/domain/user_test.go +++ b/backend/internal/domain/user_test.go @@ -46,8 +46,8 @@ func TestUserStatusPolicy(t *testing.T) { {status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true}, {status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true}, {status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true}, - {status: UserStatusInactive, normalized: UserStatusPreboarding}, - {status: UserStatusLeaveOfAbsence, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true}, + {status: "inactive", normalized: UserStatusPreboarding}, + {status: "leave_of_absence", normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true}, {status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true}, } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index cff3584a..b2cdf848 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -2618,7 +2618,7 @@ func normalizeKratosState(status *string) string { } value := strings.ToLower(strings.TrimSpace(*status)) if value == "blocked" { - return domain.UserStatusInactive + return "inactive" } if value == domain.UserStatusActive { return domain.UserStatusActive @@ -2630,7 +2630,7 @@ func normalizeKratosState(status *string) string { normalized == domain.UserStatusBaronGuest || normalized == domain.UserStatusExtendedLeave || normalized == domain.UserStatusArchived { - return domain.UserStatusInactive + return "inactive" } return "" } diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go index c7d4f3e2..99e20668 100644 --- a/backend/internal/service/worksmobile_mapper_test.go +++ b/backend/internal/service/worksmobile_mapper_test.go @@ -376,7 +376,7 @@ func TestWorksmobileUserStatusAction(t *testing.T) { require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusExtendedLeave)) require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusBaronGuest)) require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusArchived)) - require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence)) + require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction("leave_of_absence")) require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction("baron_only")) } diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 0b25ef91..6d318f6b 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -736,6 +736,9 @@ func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]doma if tenant.Type == domain.TenantTypeOrganization { return true } + if tenant.Type == domain.TenantTypeUserGroup { + return true + } if tenant.Type == domain.TenantTypeCompany { return isWorksmobileBarongroupChildCompany(tenant, tenantByID) } @@ -749,7 +752,7 @@ func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool { func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant { current := tenant for { - if current.Type == domain.TenantTypeCompany || len(current.Domains) > 0 { + if isWorksmobileDomainRootTenant(current) { return current } parentID := worksmobileTenantParentID(current) @@ -764,6 +767,25 @@ func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[ } } +func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool { + slug := strings.ToLower(strings.TrimSpace(tenant.Slug)) + switch slug { + case "saman", "hanmac", "gpdtdc", "baron-group": + return true + } + if tenantHasDomain(tenant, "samaneng.com") || + tenantHasDomain(tenant, "hanmaceng.co.kr") || + tenantHasDomain(tenant, "baroncs.co.kr") || + tenantHasDomain(tenant, "brsw.kr") { + return true + } + name := strings.TrimSpace(tenant.Name) + return name == "삼안" || + name == "한맥기술" || + name == "총괄기획&기술개발센터" || + name == "바론그룹" +} + func isWorksmobileBarongroupChildCompany(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool { if tenant.Type != domain.TenantTypeCompany || tenant.Slug == "baron-group" { return false @@ -972,14 +994,14 @@ func worksmobileUserPrimaryOrgSlug(user domain.User, localTenants map[string]dom } func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem { - remoteByExternalID := map[string]WorksmobileRemoteGroup{} + remoteByExternalID := map[string][]WorksmobileRemoteGroup{} remoteByID := map[string]WorksmobileRemoteGroup{} for _, remote := range remoteGroups { if remote.ID != "" { remoteByID[remote.ID] = remote } if remote.ExternalID != "" { - remoteByExternalID[remote.ExternalID] = remote + remoteByExternalID[remote.ExternalID] = append(remoteByExternalID[remote.ExternalID], remote) } } tenantByID := worksmobileTenantByID(localTenants) @@ -993,11 +1015,7 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works continue } localByID[tenant.ID] = tenant - remote, matched := remoteByExternalID[tenant.ID] - if matched && !includeMatched { - matchedRemoteIDs[remote.ID] = true - continue - } + remote, matched := matchingWorksmobileRemoteGroupForTenant(tenant, remoteByExternalID[tenant.ID], tenantByID) item := WorksmobileComparisonItem{ ResourceType: "GROUP", BaronID: tenant.ID, @@ -1018,7 +1036,13 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works item.WorksmobileDomainName = remote.DomainName item.WorksmobileParentID = remote.ParentID item.WorksmobileParentName = remote.ParentName - if parentRemote, ok := remoteByExternalID[item.BaronParentID]; ok { + if parent, ok := tenantByID[item.BaronParentID]; ok { + if parentRemote, ok := matchingWorksmobileRemoteGroupForTenant(parent, remoteByExternalID[item.BaronParentID], tenantByID); ok { + item.BaronParentWorksmobileID = parentRemote.ID + item.BaronParentWorksmobileName = parentRemote.DisplayName + item.BaronParentWorksmobileEmail = parentRemote.Email + } + } else if parentRemote, ok := firstWorksmobileRemoteGroup(remoteByExternalID[item.BaronParentID]); ok { item.BaronParentWorksmobileID = parentRemote.ID item.BaronParentWorksmobileName = parentRemote.DisplayName item.BaronParentWorksmobileEmail = parentRemote.Email @@ -1031,8 +1055,14 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works item.WorksmobileParentExternalKey = parentRemote.ExternalID } item = fillWorksmobileParentFromBaronParentMatch(item) + if worksmobileGroupNeedsUpdate(tenant, remote, remoteByID, remoteByExternalID, tenantByID) { + item.Status = "needs_update" + } matchedRemoteIDs[remote.ID] = true } + if matched && item.Status == "matched" && !includeMatched { + continue + } result = append(result, item) } for _, remote := range remoteGroups { @@ -1091,6 +1121,79 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works return result } +func matchingWorksmobileRemoteGroupForTenant(tenant domain.Tenant, remotes []WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) (WorksmobileRemoteGroup, bool) { + if len(remotes) == 0 { + return WorksmobileRemoteGroup{}, false + } + expectedDomainID, hasExpectedDomainID := expectedWorksmobileDomainIDForTenant(tenant, tenantByID) + if !hasExpectedDomainID { + return remotes[0], true + } + var unknownDomain WorksmobileRemoteGroup + hasUnknownDomain := false + for i := range remotes { + remote := remotes[i] + if remote.DomainID == expectedDomainID { + return remote, true + } + if remote.DomainID == 0 && !hasUnknownDomain { + unknownDomain = remote + hasUnknownDomain = true + } + } + if hasUnknownDomain { + return unknownDomain, true + } + return WorksmobileRemoteGroup{}, false +} + +func firstWorksmobileRemoteGroup(remotes []WorksmobileRemoteGroup) (WorksmobileRemoteGroup, bool) { + if len(remotes) == 0 { + return WorksmobileRemoteGroup{}, false + } + return remotes[0], true +} + +func expectedWorksmobileDomainIDForTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) (int64, bool) { + domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) + domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, nil) + if err != nil || domainID <= 0 { + return 0, false + } + return domainID, true +} + +func worksmobileGroupNeedsUpdate(tenant domain.Tenant, remote WorksmobileRemoteGroup, remoteByID map[string]WorksmobileRemoteGroup, remoteByExternalID map[string][]WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) bool { + if strings.TrimSpace(tenant.Name) != strings.TrimSpace(remote.DisplayName) { + return true + } + + expectedParentExternalKey := expectedWorksmobileParentExternalKey(tenant, remoteByExternalID, tenantByID) + actualParentExternalKey := "" + if remote.ParentID != "" { + actualParentExternalKey = strings.TrimSpace(remoteByID[remote.ParentID].ExternalID) + } + return expectedParentExternalKey != actualParentExternalKey +} + +func expectedWorksmobileParentExternalKey(tenant domain.Tenant, remoteByExternalID map[string][]WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) string { + parentID := worksmobileTenantParentID(tenant) + if parentID == "" { + return "" + } + if parent, ok := tenantByID[parentID]; ok && parent.Slug == "baron-group" { + return "" + } + parent, ok := tenantByID[parentID] + if !ok { + return "" + } + if _, ok := matchingWorksmobileRemoteGroupForTenant(parent, remoteByExternalID[parentID], tenantByID); !ok { + return "" + } + return parentID +} + func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem { if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID { return item diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index b2c9594f..16e85fc2 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -200,28 +200,28 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t Type: domain.TenantTypeOrganization, ParentID: &hanmac.ID, } - legacyUserGroup := domain.Tenant{ + userGroup := domain.Tenant{ ID: "legacy-user-group-tenant", - Name: "레거시 사용자 그룹", + Name: "사용자 그룹 조직", Type: domain.TenantTypeUserGroup, ParentID: &hanmac.ID, } items := compareWorksmobileGroups( - []domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup}, + []domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, userGroup}, []WorksmobileRemoteGroup{ {ID: "works-root", ExternalID: root.ID, DisplayName: root.Name}, {ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name, Email: "hanmac@hanmaceng.co.kr"}, {ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name}, {ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name}, {ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name, ParentID: "works-hanmac"}, - {ID: "works-legacy-user-group", ExternalID: legacyUserGroup.ID, DisplayName: legacyUserGroup.Name}, + {ID: "works-user-group", ExternalID: userGroup.ID, DisplayName: userGroup.Name, ParentID: "works-hanmac"}, {ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"}, }, true, ) - require.Len(t, items, 3) + require.Len(t, items, 4) require.Equal(t, barongroupChildCompany.ID, items[0].BaronID) require.Equal(t, "matched", items[0].Status) require.Equal(t, organization.ID, items[1].BaronID) @@ -233,8 +233,159 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t require.Equal(t, "works-hanmac", items[1].BaronParentWorksmobileID) require.Equal(t, hanmac.Name, items[1].BaronParentWorksmobileName) require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].BaronParentWorksmobileEmail) - require.Equal(t, "works-orphan", items[2].ExternalKey) - require.Equal(t, "missing_in_baron", items[2].Status) + require.Equal(t, userGroup.ID, items[2].BaronID) + require.Equal(t, "matched", items[2].Status) + require.Equal(t, "works-orphan", items[3].ExternalKey) + require.Equal(t, "missing_in_baron", items[3].Status) +} + +func TestCompareWorksmobileGroupsShowsUserGroupMissingInWorksmobile(t *testing.T) { + parentID := "company-tenant" + userGroup := domain.Tenant{ + ID: "team-tenant", + Name: "신규 팀", + Slug: "new-team", + Type: domain.TenantTypeUserGroup, + ParentID: &parentID, + } + + items := compareWorksmobileGroups( + []domain.Tenant{ + {ID: parentID, Slug: "company", Name: "계열사", Type: domain.TenantTypeCompany}, + userGroup, + }, + nil, + false, + ) + + require.Len(t, items, 1) + require.Equal(t, userGroup.ID, items[0].BaronID) + require.Equal(t, "missing_in_worksmobile", items[0].Status) +} + +func TestCompareWorksmobileGroupsMarksMatchedOrgUnitNeedsUpdate(t *testing.T) { + parentID := "parent-tenant" + tenant := domain.Tenant{ + ID: "team-tenant", + Name: "변경된 팀명", + Slug: "team", + Type: domain.TenantTypeUserGroup, + ParentID: &parentID, + } + + items := compareWorksmobileGroups( + []domain.Tenant{ + {ID: parentID, Slug: "parent", Name: "상위 조직", Type: domain.TenantTypeUserGroup}, + tenant, + }, + []WorksmobileRemoteGroup{ + {ID: "works-parent", ExternalID: parentID, DisplayName: "상위 조직"}, + {ID: "works-team", ExternalID: tenant.ID, DisplayName: "이전 팀명", ParentID: "works-parent"}, + }, + false, + ) + + require.Len(t, items, 1) + require.Equal(t, tenant.ID, items[0].BaronID) + require.Equal(t, "needs_update", items[0].Status) +} + +func TestCompareWorksmobileGroupsCoversHanmacOrganizationRegressionIDs(t *testing.T) { + rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" + samanID := "9caf62e1-297d-4e8f-870b-61780998bbeb" + hanmacID := "369c1843-56af-4344-9c21-0e01197ab861" + baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" + changedID := "818c856b-9545-442f-b827-d1c569f200b0" + hanmacOnlyID := "2d217948-9c5a-42ea-805b-eef9c7421775" + baronOnlyID := "32464fd6-da51-473f-844a-ab88603ad1f0" + localTenants := []domain.Tenant{ + {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, + {ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID}, + {ID: hanmacID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID}, + {ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, + {ID: changedID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &samanID}, + {ID: hanmacOnlyID, Slug: "rnd-hanmac", Name: "한맥기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &hanmacID}, + {ID: baronOnlyID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID}, + } + remoteGroups := []WorksmobileRemoteGroup{ + {ID: "works-saman", ExternalID: samanID, DisplayName: "삼안"}, + {ID: "works-hanmac", ExternalID: hanmacID, DisplayName: "한맥기술"}, + { + ID: "works-rnd-saman", + ExternalID: changedID, + DisplayName: "삼안기술개발센터(조직도용)", + }, + } + + items := compareWorksmobileGroups(localTenants, remoteGroups, false) + itemsByBaronID := map[string]WorksmobileComparisonItem{} + for _, item := range items { + itemsByBaronID[item.BaronID] = item + } + + require.Equal(t, "needs_update", itemsByBaronID[changedID].Status) + require.Equal(t, "missing_in_worksmobile", itemsByBaronID[hanmacOnlyID].Status) + require.Equal(t, "missing_in_worksmobile", itemsByBaronID[baronOnlyID].Status) +} + +func TestCompareWorksmobileGroupsDoesNotMatchBaronGroupOrganizationInGPDTDCDomain(t *testing.T) { + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + t.Setenv("BARONGROUP_DOMAIN_ID", "1004") + rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" + baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" + orgID := "32464fd6-da51-473f-844a-ab88603ad1f0" + localTenants := []domain.Tenant{ + {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, + {ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, + {ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID}, + } + remoteGroups := []WorksmobileRemoteGroup{ + { + ID: "works-rnd-baron-gpdtdc", + ExternalID: orgID, + DisplayName: "바론기술개발센터(조직도용)", + DomainID: 1003, + DomainName: "총괄기획&기술개발센터", + }, + } + + items := compareWorksmobileGroups(localTenants, remoteGroups, false) + + require.Len(t, items, 1) + require.Equal(t, orgID, items[0].BaronID) + require.Equal(t, "missing_in_worksmobile", items[0].Status) + require.Empty(t, items[0].WorksmobileID) +} + +func TestCompareWorksmobileGroupsMatchesBaronGroupOrganizationInBaronGroupDomain(t *testing.T) { + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + t.Setenv("BARONGROUP_DOMAIN_ID", "1004") + rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" + baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" + orgID := "32464fd6-da51-473f-844a-ab88603ad1f0" + localTenants := []domain.Tenant{ + {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, + {ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, + {ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID}, + } + remoteGroups := []WorksmobileRemoteGroup{ + { + ID: "works-rnd-baron", + ExternalID: orgID, + DisplayName: "바론기술개발센터(조직도용)", + DomainID: 1004, + DomainName: "바론그룹", + }, + } + + diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false) + all := compareWorksmobileGroups(localTenants, remoteGroups, true) + + require.Empty(t, diffOnly) + require.Len(t, all, 1) + require.Equal(t, orgID, all[0].BaronID) + require.Equal(t, "matched", all[0].Status) + require.Equal(t, int64(1004), all[0].WorksmobileDomainID) } func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) { @@ -733,6 +884,108 @@ func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t * } } +func TestWorksmobileSyncServiceUsesBaronGroupDomainForBaronGroupChildOrganization(t *testing.T) { + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + t.Setenv("BARONGROUP_DOMAIN_ID", "1004") + rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" + baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" + orgID := "32464fd6-da51-473f-844a-ab88603ad1f0" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + Type: domain.TenantTypeCompanyGroup, + } + baronGroup := domain.Tenant{ + ID: baronGroupID, + Slug: "baron-group", + Name: "바론그룹", + Type: domain.TenantTypeCompanyGroup, + ParentID: &rootID, + } + organization := domain.Tenant{ + ID: orgID, + Slug: "rnd-baron", + Name: "바론기술개발센터(조직도용)", + Type: domain.TenantTypeOrganization, + ParentID: &baronGroupID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{ + tenants: map[string]domain.Tenant{ + root.ID: root, + baronGroup.ID: baronGroup, + organization.ID: organization, + }, + list: []domain.Tenant{root, baronGroup, organization}, + }, + &fakeWorksmobileUserRepo{}, + outboxRepo, + nil, + ) + + item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID) + + require.NoError(t, err) + require.NotNil(t, item) + require.Len(t, outboxRepo.created, 1) + request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) + require.Equal(t, int64(1004), request.DomainID) + require.Equal(t, "rnd-baron@brsw.kr", request.Email) +} + +func TestWorksmobileSyncServiceUsesGPDTDCDomainForGPDTDCChildOrganization(t *testing.T) { + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + t.Setenv("BARONGROUP_DOMAIN_ID", "1004") + rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" + gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee" + orgID := "gpdtdc-child-organization" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + Type: domain.TenantTypeCompanyGroup, + } + gpdtdc := domain.Tenant{ + ID: gpdtdcID, + Slug: "gpdtdc", + Name: "총괄기획&기술개발센터", + Type: domain.TenantTypeOrganization, + ParentID: &rootID, + } + organization := domain.Tenant{ + ID: orgID, + Slug: "planning", + Name: "기획", + Type: domain.TenantTypeOrganization, + ParentID: &gpdtdcID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{ + tenants: map[string]domain.Tenant{ + root.ID: root, + gpdtdc.ID: gpdtdc, + organization.ID: organization, + }, + list: []domain.Tenant{root, gpdtdc, organization}, + }, + &fakeWorksmobileUserRepo{}, + outboxRepo, + nil, + ) + + item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID) + + require.NoError(t, err) + require.NotNil(t, item) + require.Len(t, outboxRepo.created, 1) + request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) + require.Equal(t, int64(1003), request.DomainID) + require.Equal(t, "planning@baroncs.co.kr", request.Email) +} + func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1003") rootID := "root-tenant" diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md index 37aa875a..a6947131 100644 --- a/docs/worksmobile-directory-sync-technical-review.md +++ b/docs/worksmobile-directory-sync-technical-review.md @@ -344,6 +344,7 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta - `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다. - Baron user delete는 Worksmobile delete로 동기화합니다. - 기존 `inactive` 입력은 `preboarding`, `leave_of_absence` 입력은 `temporary_leave`, `baron_only` 입력은 `baron_guest`로 호환 처리합니다. +- backend bootstrap은 위 legacy `users.status` 값이 남아 있으면 canonical 상태값으로 자동 정규화합니다. ## 테스트 전략