package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "encoding/csv" "fmt" "io" "net/http" "net/url" "os" "sort" "strings" "testing" "time" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/gorm" ) func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_SAMAN_PROVISIONING") != "1" { t.Skip("live Worksmobile Saman provisioning is disabled") } ctx := context.Background() db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) require.NoError(t, err) tenantRepo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) outboxRepo := repository.NewWorksmobileOutboxRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) client := newWorksmobileLiveClient() syncService := NewWorksmobileSyncService(tenantService, userRepo, outboxRepo, client) worker := NewWorksmobileRelayWorker(outboxRepo, client) root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) require.NoError(t, err) samanTenant, err := tenantService.GetTenantBySlug(ctx, "saman") require.NoError(t, err) createWorksmobileLiveOrgUnitIfMissing(t, ctx, client, *samanTenant) targetEmails := []string{"tester@samaneng.com", "orgadmin@samaneng.com"} for _, email := range targetEmails { user, err := userRepo.FindByEmail(ctx, email) require.NoError(t, err) dedupeKey := "user:" + strings.ToLower(WorksmobileUserStatusAction(user.Status)) + ":" + user.ID job := findWorksmobileLiveOutboxByDedupe(t, db, dedupeKey) if job.Status != domain.WorksmobileOutboxStatusProcessed { remote, err := client.FindUser(ctx, user.Email) require.NoError(t, err) if remote != nil { require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID)) continue } item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "") require.NoError(t, err) require.NotEmpty(t, item) require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID)) job = findWorksmobileLiveOutboxByDedupe(t, db, dedupeKey) err = worker.processJob(ctx, job) require.NoError(t, err) } processed, err := outboxRepo.FindByID(ctx, job.ID) require.NoError(t, err) require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status) } credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID, "") require.NoError(t, err) seen := map[string]bool{} for _, credential := range credentials { if credential.Email == "tester@samaneng.com" || credential.Email == "orgadmin@samaneng.com" { require.Equal(t, domain.WorksmobileOutboxStatusProcessed, credential.Status) require.Len(t, credential.InitialPassword, 16) seen[credential.Email] = true } } require.True(t, seen["tester@samaneng.com"]) require.True(t, seen["orgadmin@samaneng.com"]) remoteUsers, err := client.ListUsers(ctx) require.NoError(t, err) remoteByEmail := map[string]WorksmobileRemoteUser{} for _, user := range remoteUsers { remoteByEmail[user.Email] = user } require.NotEmpty(t, remoteByEmail["tester@samaneng.com"].ID) require.NotEmpty(t, remoteByEmail["orgadmin@samaneng.com"].ID) remoteGroups, err := client.ListGroups(ctx) require.NoError(t, err) foundSamanOrgUnit := false for _, group := range remoteGroups { if group.ExternalID == samanTenant.ID { foundSamanOrgUnit = true require.Equal(t, "삼안", group.DisplayName) } } require.True(t, foundSamanOrgUnit) } func TestWorksmobileLiveSamanOrgUnitProvisioning(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_SAMAN_ORGUNIT_PROVISIONING") != "1" { t.Skip("live Worksmobile Saman orgunit provisioning is disabled") } runWorksmobileLiveCompanyOrgUnitProvisioning(t, "saman", "SAMAN_DOMAIN_ID", nil) } func TestWorksmobileLiveGPDTDCOrgUnitProvisioning(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_GPDTDC_ORGUNIT_PROVISIONING") != "1" { t.Skip("live Worksmobile GPDTDC orgunit provisioning is disabled") } runWorksmobileLiveCompanyOrgUnitProvisioning(t, "gpdtdc", "GPDTDC_DOMAIN_ID", map[string]bool{ "56cd0fd7-b62a-43c0-8db9-74a30468d7cb": true, }) } func TestWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_BARONGROUP_ORGUNIT_PROVISIONING") != "1" { t.Skip("live Worksmobile Baron Group orgunit provisioning is disabled") } runWorksmobileLiveBaronGroupOrgUnitProvisioning(t) } func TestWorksmobileLiveSyncHanmacFamilyOrgUnits(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_SYNC_HANMAC_FAMILY_ORGUNITS") != "1" { t.Skip("live Worksmobile Hanmac family orgunit sync is disabled") } ctx := context.Background() db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) require.NoError(t, err) tenantRepo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) client := newWorksmobileLiveClient() root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) require.NoError(t, err) tenants, err := listWorksmobileLiveTenantScope(db, root.ID) require.NoError(t, err) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...)) targets := worksmobileLiveOrgUnitTargets(t, tenants, tenantByID, *root) targetByID := map[string]worksmobileLiveOrgUnitTarget{} for _, target := range targets { targetByID[target.Tenant.ID] = target } remoteGroups, err := client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys := worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") for _, target := range sortWorksmobileLiveTargetsTopologically(targets, tenantByID) { remote, found := remoteByExternalID[target.Tenant.ID] if found && remote.DomainID != target.Payload.DomainID { require.Failf(t, "external key is attached to a different Worksmobile domain", "slug=%s external=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.DomainID, target.Payload.DomainID) } if !found { remote, found = findWorksmobileLiveRemoteByPath(remoteGroups, worksmobileLiveRemoteByID(remoteGroups), target.Payload.DomainID, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID)) } if found { t.Logf("PATCH orgunit slug=%s id=%s worksID=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, remote.ID, target.Payload.Email, target.Payload.ParentOrgUnitID) require.NoError(t, patchWorksmobileLiveOrgUnit(ctx, client, remote.ID, target.Payload)) } else { t.Logf("CREATE orgunit slug=%s id=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, target.Payload.Email, target.Payload.ParentOrgUnitID) require.NoError(t, client.UpsertOrgUnit(ctx, target.Payload, target.Tenant.Slug)) } time.Sleep(1100 * time.Millisecond) remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") } remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") remoteByID := worksmobileLiveRemoteByID(remoteGroups) for _, target := range targets { remote, ok := remoteByExternalID[target.Tenant.ID] require.True(t, ok, "missing Worksmobile orgunit after sync: %s", target.Tenant.Slug) require.Equal(t, target.Payload.DomainID, remote.DomainID, "domain mismatch: %s", target.Tenant.Slug) require.Equal(t, target.Tenant.Name, remote.DisplayName, "name mismatch: %s", target.Tenant.Slug) require.Equal(t, worksmobileMailLocalPart(target.Payload.Email), remote.MailLocalPart, "email local-part mismatch: %s", target.Tenant.Slug) require.Equal(t, strings.ToLower(strings.TrimSpace(target.Payload.Email)), strings.ToLower(strings.TrimSpace(remote.Email)), "email mismatch: %s", target.Tenant.Slug) expectedParentID := "" if parentExternalKey := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentExternalKey != "" && parentExternalKey != target.Payload.ParentOrgUnitID { parentRemote, ok := remoteByExternalID[parentExternalKey] require.True(t, ok, "missing Worksmobile parent for %s", target.Tenant.Slug) expectedParentID = parentRemote.ID parentTarget, ok := targetByID[parentExternalKey] require.True(t, ok, "missing Baron parent target for %s", target.Tenant.Slug) require.Equal(t, worksmobileMailLocalPart(parentTarget.Payload.Email), parentRemote.MailLocalPart, "parent email local-part mismatch: %s", target.Tenant.Slug) } require.Equal(t, expectedParentID, remote.ParentID, "parent mismatch: %s", target.Tenant.Slug) require.Equal(t, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID), worksmobileLiveRemotePath(remoteByID, remote), "path mismatch: %s", target.Tenant.Slug) } extraWithExternalKey, extraWithoutExternalKey := worksmobileLiveExtraRemoteGroups(remoteGroups, targetByID) t.Logf("SUMMARY synced=%d extraWithExternalKey=%d extraWithoutExternalKey=%d", len(targets), len(extraWithExternalKey), len(extraWithoutExternalKey)) for _, extra := range extraWithExternalKey { t.Logf("EXTRA external=%s name=%s email=%s domain=%d", extra.ExternalID, extra.DisplayName, extra.Email, extra.DomainID) } for _, extra := range extraWithoutExternalKey { t.Logf("EXTRA_NO_EXTERNAL id=%s name=%s email=%s domain=%d", extra.ID, extra.DisplayName, extra.Email, extra.DomainID) } } func TestWorksmobileLiveInspectGPDTDCOrgUnits(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_INSPECT_GPDTDC_ORGUNITS") != "1" { t.Skip("live Worksmobile GPDTDC orgunit inspection is disabled") } ctx := context.Background() db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) require.NoError(t, err) tenantRepo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) client := newWorksmobileLiveClient() gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc") require.NoError(t, err) tenants, err := listWorksmobileLiveTenantSubtree(db, gpdtdcTenant.ID) require.NoError(t, err) gpdtdcDomainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID") require.True(t, ok, "missing GPDTDC_DOMAIN_ID") remoteGroups, err := client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID := map[string]WorksmobileRemoteGroup{} remoteByID := map[string]WorksmobileRemoteGroup{} gpdtdcRemoteCount := 0 for _, group := range remoteGroups { remoteByID[group.ID] = group if group.DomainID == gpdtdcDomainID { gpdtdcRemoteCount++ t.Logf("REMOTE GPDTDC id=%s external=%s name=%s email=%s parent=%s parentName=%s", group.ID, group.ExternalID, group.DisplayName, group.Email, group.ParentID, group.ParentName) } if group.ExternalID != "" { remoteByExternalID[group.ExternalID] = group } } missing := make([]domain.Tenant, 0) wrongDomain := make([]WorksmobileRemoteGroup, 0) for _, tenant := range tenants { if tenant.ID == "56cd0fd7-b62a-43c0-8db9-74a30468d7cb" { continue } remote, ok := remoteByExternalID[tenant.ID] if !ok { missing = append(missing, tenant) continue } if remote.DomainID != gpdtdcDomainID { wrongDomain = append(wrongDomain, remote) } } for _, tenant := range missing { t.Logf("MISSING LOCAL id=%s slug=%s name=%s parent=%v", tenant.ID, tenant.Slug, tenant.Name, tenant.ParentID) } for _, remote := range wrongDomain { t.Logf("WRONG DOMAIN external=%s name=%s domainID=%d domainName=%s", remote.ExternalID, remote.DisplayName, remote.DomainID, remote.DomainName) } remoteUsers, err := client.ListUsers(ctx) require.NoError(t, err) usersByPrimaryOrg := map[string]int{} for _, user := range remoteUsers { if user.DomainID != gpdtdcDomainID || user.PrimaryOrgUnitID == "" { continue } usersByPrimaryOrg[user.PrimaryOrgUnitID]++ } for orgID, count := range usersByPrimaryOrg { group := remoteByID[orgID] t.Logf("USER PRIMARY ORG orgID=%s count=%d external=%s name=%s email=%s", orgID, count, group.ExternalID, group.DisplayName, group.Email) } t.Logf("SUMMARY localOrganizations=%d remoteGPDTDCDomain=%d matchedExternal=%d missing=%d wrongDomain=%d", len(tenants), gpdtdcRemoteCount, len(remoteByExternalID), len(missing), len(wrongDomain)) require.Empty(t, missing) require.Empty(t, wrongDomain) } func TestWorksmobileLiveRecoverGPDTDCOrgUnits(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_RECOVER_GPDTDC_ORGUNITS") != "1" { t.Skip("live Worksmobile GPDTDC orgunit recovery is disabled") } ctx := context.Background() db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) require.NoError(t, err) tenantRepo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) client := newWorksmobileLiveClient() gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc") require.NoError(t, err) tenants, err := listWorksmobileLiveTenantSubtree(db, gpdtdcTenant.ID) require.NoError(t, err) csvNodes, err := readWorksmobileLiveOrgCSV("../../../adminfront/gpdtdc_org_slugged.csv") require.NoError(t, err) requireWorksmobileLiveBaronCSVMatch(t, tenants, csvNodes) gpdtdcDomainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID") require.True(t, ok, "missing GPDTDC_DOMAIN_ID") remoteGroups, err := client.ListGroups(ctx) require.NoError(t, err) remoteByID := map[string]WorksmobileRemoteGroup{} remoteByExternalID := map[string]WorksmobileRemoteGroup{} for _, group := range remoteGroups { remoteByID[group.ID] = group if group.ExternalID != "" { remoteByExternalID[group.ExternalID] = group } } for _, tenant := range tenants { current, ok := remoteByExternalID[tenant.ID] if !ok || current.DomainID == gpdtdcDomainID { continue } t.Logf("CLEAR conflicting external key id=%s external=%s name=%s email=%s domain=%d", current.ID, current.ExternalID, current.DisplayName, current.Email, current.DomainID) if err := client.ClearOrgUnitExternalKey(ctx, current.ID, current.DomainID); err != nil { legacyPatch := WorksmobileOrgUnitPatchPayload{ DomainID: current.DomainID, OrgUnitExternalKey: "legacy-" + current.ID, } t.Logf("REKEY conflicting external key id=%s replacement=%s error=%v", current.ID, legacyPatch.OrgUnitExternalKey, err) require.NoError(t, client.PatchOrgUnit(ctx, current.ID, legacyPatch)) } time.Sleep(1100 * time.Millisecond) } remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByID = map[string]WorksmobileRemoteGroup{} remoteByExternalID = map[string]WorksmobileRemoteGroup{} for _, group := range remoteGroups { remoteByID[group.ID] = group if group.ExternalID != "" { remoteByExternalID[group.ExternalID] = group } } for _, tenant := range tenants { current, ok := remoteByExternalID[tenant.ID] if !ok || current.DomainID == gpdtdcDomainID { continue } legacyPatch := WorksmobileOrgUnitPatchPayload{ DomainID: current.DomainID, OrgUnitExternalKey: "legacy-" + current.ID, } t.Logf("REKEY still-conflicting external key id=%s replacement=%s", current.ID, legacyPatch.OrgUnitExternalKey) require.NoError(t, client.PatchOrgUnit(ctx, current.ID, legacyPatch)) time.Sleep(1100 * time.Millisecond) } remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByID = map[string]WorksmobileRemoteGroup{} remoteByExternalID = map[string]WorksmobileRemoteGroup{} for _, group := range remoteGroups { remoteByID[group.ID] = group if group.ExternalID != "" { remoteByExternalID[group.ExternalID] = group } } remoteUsers, err := client.ListUsers(ctx) require.NoError(t, err) usersByPrimaryOrg := map[string]int{} for _, user := range remoteUsers { if user.DomainID == gpdtdcDomainID && user.PrimaryOrgUnitID != "" { usersByPrimaryOrg[user.PrimaryOrgUnitID]++ } } type recoveryTarget struct { Tenant domain.Tenant CSV worksmobileLiveCSVOrg Target WorksmobileRemoteGroup Bad *WorksmobileRemoteGroup } targets := make([]recoveryTarget, 0) badByID := map[string]WorksmobileRemoteGroup{} for _, tenant := range tenants { node, ok := csvNodes[tenant.Slug] if !ok { t.Logf("SKIP no CSV node slug=%s name=%s id=%s", tenant.Slug, tenant.Name, tenant.ID) continue } desiredPath := worksmobileLiveCSVPath(csvNodes, tenant.Slug) target, found := findWorksmobileLiveRemoteByPath(remoteGroups, remoteByID, gpdtdcDomainID, desiredPath) if !found { if current, ok := remoteByExternalID[tenant.ID]; ok && current.DomainID == gpdtdcDomainID { target = current found = true } } require.True(t, found, "missing recovery target slug=%s path=%s", tenant.Slug, desiredPath) var bad *WorksmobileRemoteGroup if current, ok := remoteByExternalID[tenant.ID]; ok && current.DomainID == gpdtdcDomainID && current.ID != target.ID { currentCopy := current bad = ¤tCopy badByID[current.ID] = current } targets = append(targets, recoveryTarget{Tenant: tenant, CSV: node, Target: target, Bad: bad}) } badIDs := map[string]bool{} for id := range badByID { collectWorksmobileLiveSubtreeIDs(id, remoteGroups, badIDs) } badGroups := make([]WorksmobileRemoteGroup, 0, len(badIDs)) for id := range badIDs { group := remoteByID[id] if group.ID == "" { continue } require.Zero(t, usersByPrimaryOrg[id], "refusing to delete orgunit with primary users: %s %s", group.DisplayName, id) badGroups = append(badGroups, group) } sort.SliceStable(badGroups, func(i, j int) bool { return worksmobileLiveRemoteDepth(remoteByID, badGroups[i]) > worksmobileLiveRemoteDepth(remoteByID, badGroups[j]) }) for _, group := range badGroups { t.Logf("DELETE duplicate id=%s external=%s name=%s email=%s", group.ID, group.ExternalID, group.DisplayName, group.Email) require.NoError(t, client.DeleteOrgUnit(ctx, group.ID)) } for _, target := range targets { if badIDs[target.Target.ID] { continue } patch := WorksmobileOrgUnitPatchPayload{ DomainID: gpdtdcDomainID, Email: target.CSV.Email, OrgUnitName: target.CSV.Name, OrgUnitExternalKey: target.Tenant.ID, } t.Logf("PATCH existing id=%s external=%s name=%s email=%s", target.Target.ID, target.Tenant.ID, target.CSV.Name, target.CSV.Email) require.NoError(t, client.PatchOrgUnit(ctx, target.Target.ID, patch)) time.Sleep(1100 * time.Millisecond) } remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID = map[string]WorksmobileRemoteGroup{} for _, group := range remoteGroups { if group.ExternalID != "" { remoteByExternalID[group.ExternalID] = group } } for _, target := range targets { remote, ok := remoteByExternalID[target.Tenant.ID] require.True(t, ok, "missing recovered external key for %s", target.Tenant.Slug) require.Equal(t, gpdtdcDomainID, remote.DomainID) require.Equal(t, target.CSV.Name, remote.DisplayName) } } func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug string, domainIDEnvKey string, skipTenantIDs map[string]bool) { t.Helper() ctx := context.Background() db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) require.NoError(t, err) tenantRepo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) client := newWorksmobileLiveClient() companyTenant, err := tenantService.GetTenantBySlug(ctx, companySlug) require.NoError(t, err) root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) require.NoError(t, err) tenants, err := listWorksmobileLiveTenantSubtree(db, companyTenant.ID) require.NoError(t, err) domainID, ok := worksmobileDomainIDFromEnv(domainIDEnvKey) require.True(t, ok, "missing %s", domainIDEnvKey) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, append([]domain.Tenant{*companyTenant}, tenants...)...)) for index, tenant := range sortWorksmobileLiveOrgUnitsTopologically(tenants) { if skipTenantIDs[tenant.ID] { continue } payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, *companyTenant, root.Config, index+1) require.NoError(t, err) payload.DomainID = domainID if tenant.ParentID != nil && (*tenant.ParentID == companyTenant.ID || skipTenantIDs[*tenant.ParentID]) { payload.ParentOrgUnitID = "" } else { payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) } require.NoError(t, client.UpsertOrgUnit(ctx, payload, tenant.Slug), "tenant=%s slug=%s", tenant.Name, tenant.Slug) time.Sleep(1100 * time.Millisecond) } remoteGroups, err := client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID := map[string]WorksmobileRemoteGroup{} for _, group := range remoteGroups { if group.ExternalID != "" { remoteByExternalID[group.ExternalID] = group } } for _, tenant := range tenants { if skipTenantIDs[tenant.ID] { continue } remote, ok := remoteByExternalID[tenant.ID] require.True(t, ok, "missing remote orgunit external key for %s %s", tenant.Name, tenant.ID) require.Equal(t, tenant.Name, remote.DisplayName) } } func runWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) { t.Helper() ctx := context.Background() db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) require.NoError(t, err) tenantRepo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) client := newWorksmobileLiveClient() baronGroup, err := tenantService.GetTenantBySlug(ctx, "baron-group") require.NoError(t, err) tenants, err := listWorksmobileLiveTenantScope(db, baronGroup.ID) require.NoError(t, err) domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID") require.True(t, ok, "missing BARONGROUP_DOMAIN_ID") mailDomain := getenvDefault("BARONGROUP_MAIL_DOMAIN", getenvDefault("WORKS_DEFAULT_DOMAIN_BARONGROUP", "brsw.kr")) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*baronGroup}, tenants...)) targets := worksmobileLiveBaronGroupOrgUnitTargets(t, tenants, tenantByID, *baronGroup, domainID, mailDomain) targetByID := map[string]worksmobileLiveOrgUnitTarget{} for _, target := range targets { targetByID[target.Tenant.ID] = target } remoteGroups, err := client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys := worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") for _, target := range sortWorksmobileLiveTargetsTopologically(targets, tenantByID) { remote, found := remoteByExternalID[target.Tenant.ID] if found && remote.DomainID != target.Payload.DomainID { t.Logf("REKEY conflicting external key slug=%s external=%s worksID=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.ID, remote.DomainID, target.Payload.DomainID) if err := client.ClearOrgUnitExternalKey(ctx, remote.ID, remote.DomainID); err != nil { legacyPatch := WorksmobileOrgUnitPatchPayload{ DomainID: remote.DomainID, OrgUnitExternalKey: "legacy-" + remote.ID, } require.NoError(t, client.PatchOrgUnit(ctx, remote.ID, legacyPatch)) } time.Sleep(1100 * time.Millisecond) remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") remote, found = remoteByExternalID[target.Tenant.ID] if found && remote.DomainID != target.Payload.DomainID { require.Failf(t, "external key is attached to a different Worksmobile domain after rekey", "slug=%s external=%s currentDomain=%d expectedDomain=%d", target.Tenant.Slug, target.Tenant.ID, remote.DomainID, target.Payload.DomainID) } } if !found { remote, found = findWorksmobileLiveRemoteByPath(remoteGroups, worksmobileLiveRemoteByID(remoteGroups), target.Payload.DomainID, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID)) } if found { t.Logf("PATCH orgunit slug=%s id=%s worksID=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, remote.ID, target.Payload.Email, target.Payload.ParentOrgUnitID) require.NoError(t, patchWorksmobileLiveOrgUnit(ctx, client, remote.ID, target.Payload)) } else { t.Logf("CREATE orgunit slug=%s id=%s email=%s parent=%s", target.Tenant.Slug, target.Tenant.ID, target.Payload.Email, target.Payload.ParentOrgUnitID) require.NoError(t, client.UpsertOrgUnit(ctx, target.Payload, target.Tenant.Slug)) } time.Sleep(1100 * time.Millisecond) remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") } remoteGroups, err = client.ListGroups(ctx) require.NoError(t, err) remoteByExternalID, duplicateExternalKeys = worksmobileLiveRemoteByExternalID(remoteGroups) require.Empty(t, duplicateExternalKeys, "duplicate Worksmobile external keys") remoteByID := worksmobileLiveRemoteByID(remoteGroups) for _, target := range targets { remote, ok := remoteByExternalID[target.Tenant.ID] require.True(t, ok, "missing Worksmobile orgunit after sync: %s", target.Tenant.Slug) require.Equal(t, target.Payload.DomainID, remote.DomainID, "domain mismatch: %s", target.Tenant.Slug) require.Equal(t, target.Tenant.Name, remote.DisplayName, "name mismatch: %s", target.Tenant.Slug) require.Equal(t, worksmobileMailLocalPart(target.Payload.Email), remote.MailLocalPart, "email local-part mismatch: %s", target.Tenant.Slug) require.Equal(t, strings.ToLower(strings.TrimSpace(target.Payload.Email)), strings.ToLower(strings.TrimSpace(remote.Email)), "email mismatch: %s", target.Tenant.Slug) expectedParentID := "" if parentExternalKey := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentExternalKey != "" && parentExternalKey != target.Payload.ParentOrgUnitID { parentRemote, ok := remoteByExternalID[parentExternalKey] require.True(t, ok, "missing Worksmobile parent for %s", target.Tenant.Slug) expectedParentID = parentRemote.ID parentTarget, ok := targetByID[parentExternalKey] require.True(t, ok, "missing Baron parent target for %s", target.Tenant.Slug) require.Equal(t, worksmobileMailLocalPart(parentTarget.Payload.Email), parentRemote.MailLocalPart, "parent email local-part mismatch: %s", target.Tenant.Slug) } require.Equal(t, expectedParentID, remote.ParentID, "parent mismatch: %s", target.Tenant.Slug) require.Equal(t, worksmobileLiveTenantOrgPath(target.Tenant, tenantByID), worksmobileLiveRemotePath(remoteByID, remote), "path mismatch: %s", target.Tenant.Slug) } t.Logf("SUMMARY synced=%d domainID=%d", len(targets), domainID) } func worksmobileLiveBaronGroupOrgUnitTargets(t *testing.T, tenants []domain.Tenant, tenantByID map[string]domain.Tenant, root domain.Tenant, domainID int64, mailDomain string) []worksmobileLiveOrgUnitTarget { t.Helper() mailDomain = strings.ToLower(strings.TrimSpace(mailDomain)) require.NotEmpty(t, mailDomain, "baron group mail domain is required") targets := make([]worksmobileLiveOrgUnitTarget, 0) seenExternalKeys := map[string]string{} seenEmails := map[string]string{} for index, tenant := range tenants { if !isWorksmobileOrgUnitTenant(tenant, tenantByID) || worksmobileLiveSkipOrgUnitTenant(tenant) { continue } payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, root, root.Config, index+1) require.NoError(t, err, "payload build failed: %s", tenant.Slug) payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) payload.DomainID = domainID payload.Email = strings.ToLower(strings.TrimSpace(tenant.Slug)) + "@" + mailDomain require.NotEmpty(t, payload.Email, "orgunit email is required: %s", tenant.Slug) if owner, exists := seenExternalKeys[payload.OrgUnitExternalKey]; exists { require.Failf(t, "duplicate Baron external key", "external=%s owner=%s duplicate=%s", payload.OrgUnitExternalKey, owner, tenant.Slug) } seenExternalKeys[payload.OrgUnitExternalKey] = tenant.Slug normalizedEmail := strings.ToLower(strings.TrimSpace(payload.Email)) if owner, exists := seenEmails[normalizedEmail]; exists { require.Failf(t, "duplicate Baron orgunit email", "email=%s owner=%s duplicate=%s", normalizedEmail, owner, tenant.Slug) } seenEmails[normalizedEmail] = tenant.Slug targets = append(targets, worksmobileLiveOrgUnitTarget{Tenant: tenant, Payload: payload}) } return targets } func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) { t.Helper() payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1) require.NoError(t, err) if tenant.ParentID != nil { payload.ParentOrgUnitID = "" } err = client.CreateOrgUnit(ctx, payload) if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 { return } require.NoError(t, err) } func listWorksmobileLiveTenantSubtree(db *gorm.DB, rootID string) ([]domain.Tenant, error) { var tenants []domain.Tenant err := db.Raw(` with recursive scope as ( select id, type, parent_id, name, slug, description, status, config, created_at, updated_at, deleted_at from tenants where id = ? and deleted_at is null union all select t.id, t.type, t.parent_id, t.name, t.slug, t.description, t.status, t.config, t.created_at, t.updated_at, t.deleted_at from tenants t join scope on t.parent_id = scope.id where t.deleted_at is null ) select * from scope where type = ? order by name, slug `, rootID, domain.TenantTypeOrganization).Scan(&tenants).Error return tenants, err } func listWorksmobileLiveTenantScope(db *gorm.DB, rootID string) ([]domain.Tenant, error) { type tenantIDRow struct { ID string } rows := []tenantIDRow{} if err := db.Raw(` with recursive scope as ( select id, parent_id, created_at from tenants where id = ? and deleted_at is null union all select t.id, t.parent_id, t.created_at from tenants t join scope on t.parent_id = scope.id where t.deleted_at is null ) select id from scope where id <> ? order by created_at, id `, rootID, rootID).Scan(&rows).Error; err != nil { return nil, err } ids := make([]string, 0, len(rows)) order := map[string]int{} for index, row := range rows { ids = append(ids, row.ID) order[row.ID] = index } if len(ids) == 0 { return nil, nil } tenants := []domain.Tenant{} if err := db.Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil { return nil, err } sort.SliceStable(tenants, func(i, j int) bool { return order[tenants[i].ID] < order[tenants[j].ID] }) return tenants, nil } type worksmobileLiveOrgUnitTarget struct { Tenant domain.Tenant Payload WorksmobileOrgUnitPayload } func worksmobileLiveOrgUnitTargets(t *testing.T, tenants []domain.Tenant, tenantByID map[string]domain.Tenant, root domain.Tenant) []worksmobileLiveOrgUnitTarget { t.Helper() targets := make([]worksmobileLiveOrgUnitTarget, 0) seenExternalKeys := map[string]string{} seenEmails := map[string]string{} for index, tenant := range tenants { if !isWorksmobileOrgUnitTenant(tenant, tenantByID) || worksmobileLiveSkipOrgUnitTenant(tenant) { continue } domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, domainTenant, root.Config, index+1) require.NoError(t, err, "payload build failed: %s", tenant.Slug) payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) require.NotEmpty(t, payload.Email, "orgunit email is required: %s", tenant.Slug) if owner, exists := seenExternalKeys[payload.OrgUnitExternalKey]; exists { require.Failf(t, "duplicate Baron external key", "external=%s owner=%s duplicate=%s", payload.OrgUnitExternalKey, owner, tenant.Slug) } seenExternalKeys[payload.OrgUnitExternalKey] = tenant.Slug normalizedEmail := strings.ToLower(strings.TrimSpace(payload.Email)) if owner, exists := seenEmails[normalizedEmail]; exists { require.Failf(t, "duplicate Baron orgunit email", "email=%s owner=%s duplicate=%s", normalizedEmail, owner, tenant.Slug) } seenEmails[normalizedEmail] = tenant.Slug targets = append(targets, worksmobileLiveOrgUnitTarget{Tenant: tenant, Payload: payload}) } return targets } func worksmobileLiveSkipOrgUnitTenant(tenant domain.Tenant) bool { slug := strings.ToLower(strings.TrimSpace(tenant.Slug)) name := strings.TrimSpace(tenant.Name) return slug == "nw-admin-gpd" || slug == "su2" || name == "네이버웍스관리용" } func sortWorksmobileLiveTargetsTopologically(targets []worksmobileLiveOrgUnitTarget, tenantByID map[string]domain.Tenant) []worksmobileLiveOrgUnitTarget { byID := map[string]worksmobileLiveOrgUnitTarget{} for _, target := range targets { byID[target.Tenant.ID] = target } remaining := append([]worksmobileLiveOrgUnitTarget(nil), targets...) sort.SliceStable(remaining, func(i, j int) bool { left := worksmobileLiveTenantOrgPath(remaining[i].Tenant, tenantByID) right := worksmobileLiveTenantOrgPath(remaining[j].Tenant, tenantByID) if left != right { return left < right } return remaining[i].Tenant.Slug < remaining[j].Tenant.Slug }) done := map[string]bool{} result := make([]worksmobileLiveOrgUnitTarget, 0, len(remaining)) for len(remaining) > 0 { progress := false next := remaining[:0] for _, target := range remaining { parentReady := true if parentID := strings.TrimPrefix(target.Payload.ParentOrgUnitID, "externalKey:"); parentID != "" && parentID != target.Payload.ParentOrgUnitID { _, parentInTargets := byID[parentID] parentReady = !parentInTargets || done[parentID] } if parentReady { result = append(result, target) done[target.Tenant.ID] = true progress = true continue } next = append(next, target) } if !progress { result = append(result, next...) break } remaining = next } return result } func worksmobileLiveTenantOrgPath(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string { names := []string{tenant.Name} current := tenant seen := map[string]bool{tenant.ID: true} for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" { parent, ok := tenantByID[*current.ParentID] if !ok || seen[parent.ID] || !isWorksmobileOrgUnitTenant(parent, tenantByID) || worksmobileLiveSkipOrgUnitTenant(parent) { break } seen[parent.ID] = true names = append([]string{parent.Name}, names...) current = parent } return strings.Join(names, "/") } func worksmobileLiveRemoteByExternalID(groups []WorksmobileRemoteGroup) (map[string]WorksmobileRemoteGroup, []string) { result := map[string]WorksmobileRemoteGroup{} duplicates := []string{} seenDuplicate := map[string]bool{} for _, group := range groups { if group.ExternalID == "" { continue } if _, exists := result[group.ExternalID]; exists { if !seenDuplicate[group.ExternalID] { duplicates = append(duplicates, group.ExternalID) seenDuplicate[group.ExternalID] = true } continue } result[group.ExternalID] = group } sort.Strings(duplicates) return result, duplicates } func worksmobileLiveRemoteByID(groups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup { result := map[string]WorksmobileRemoteGroup{} for _, group := range groups { result[group.ID] = group } return result } func patchWorksmobileLiveOrgUnit(ctx context.Context, client *WorksmobileHTTPClient, orgUnitID string, payload WorksmobileOrgUnitPayload) error { body := map[string]any{ "domainId": payload.DomainID, "email": strings.TrimSpace(payload.Email), "orgUnitName": strings.TrimSpace(payload.OrgUnitName), "orgUnitExternalKey": strings.TrimSpace(payload.OrgUnitExternalKey), "parentOrgUnitId": strings.TrimSpace(payload.ParentOrgUnitID), "displayOrder": payload.DisplayOrder, } return client.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(strings.TrimSpace(orgUnitID)), body) } func worksmobileLiveExtraRemoteGroups(groups []WorksmobileRemoteGroup, targetByID map[string]worksmobileLiveOrgUnitTarget) ([]WorksmobileRemoteGroup, []WorksmobileRemoteGroup) { extraWithExternalKey := []WorksmobileRemoteGroup{} extraWithoutExternalKey := []WorksmobileRemoteGroup{} for _, group := range groups { if group.ExternalID == "" { extraWithoutExternalKey = append(extraWithoutExternalKey, group) continue } if _, ok := targetByID[group.ExternalID]; !ok { extraWithExternalKey = append(extraWithExternalKey, group) } } sort.SliceStable(extraWithExternalKey, func(i, j int) bool { return extraWithExternalKey[i].DisplayName < extraWithExternalKey[j].DisplayName }) sort.SliceStable(extraWithoutExternalKey, func(i, j int) bool { return extraWithoutExternalKey[i].DisplayName < extraWithoutExternalKey[j].DisplayName }) return extraWithExternalKey, extraWithoutExternalKey } func sortWorksmobileLiveOrgUnitsTopologically(tenants []domain.Tenant) []domain.Tenant { remaining := append([]domain.Tenant(nil), tenants...) sort.SliceStable(remaining, func(i, j int) bool { if remaining[i].Name != remaining[j].Name { return remaining[i].Name < remaining[j].Name } return remaining[i].Slug < remaining[j].Slug }) done := map[string]bool{} result := make([]domain.Tenant, 0, len(remaining)) for len(remaining) > 0 { progress := false next := remaining[:0] for _, tenant := range remaining { parentReady := tenant.ParentID == nil || *tenant.ParentID == "" || done[*tenant.ParentID] if !parentReady { parentInScope := false for _, candidate := range remaining { if candidate.ID == *tenant.ParentID { parentInScope = true break } } parentReady = !parentInScope } if parentReady { result = append(result, tenant) done[tenant.ID] = true progress = true continue } next = append(next, tenant) } if !progress { result = append(result, next...) break } remaining = next } return result } type worksmobileLiveCSVOrg struct { Slug string Name string Email string ParentSlug string } func readWorksmobileLiveOrgCSV(path string) (map[string]worksmobileLiveCSVOrg, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() reader := csv.NewReader(file) reader.FieldsPerRecord = -1 header, err := reader.Read() if err != nil { return nil, err } index := map[string]int{} for i, value := range header { index[strings.TrimSpace(value)] = i } result := map[string]worksmobileLiveCSVOrg{} for { row, err := reader.Read() if err == io.EOF { return result, nil } if err != nil { return nil, err } email := csvValue(row, index, "메일링 리스트") slug := worksmobileMailLocalPart(email) if slug == "" { continue } parentSlug := "" parent := csvValue(row, index, "상위 조직") if start := strings.LastIndex(parent, "("); start >= 0 && strings.HasSuffix(parent, ")") { parentSlug = worksmobileMailLocalPart(parent[start+1 : len(parent)-1]) } result[slug] = worksmobileLiveCSVOrg{ Slug: slug, Name: csvValue(row, index, "조직명"), Email: email, ParentSlug: parentSlug, } } } func requireWorksmobileLiveBaronCSVMatch(t *testing.T, tenants []domain.Tenant, csvNodes map[string]worksmobileLiveCSVOrg) { t.Helper() tenantSlugs := map[string]domain.Tenant{} for _, tenant := range tenants { tenantSlugs[tenant.Slug] = tenant } missingInBaron := make([]string, 0) for slug := range csvNodes { if _, ok := tenantSlugs[slug]; !ok { missingInBaron = append(missingInBaron, slug) } } missingInCSV := make([]string, 0) for _, tenant := range tenants { if _, ok := csvNodes[tenant.Slug]; !ok { missingInCSV = append(missingInCSV, tenant.Slug) } } sort.Strings(missingInBaron) sort.Strings(missingInCSV) require.Empty(t, missingInBaron, "CSV slugs missing in Baron") require.Empty(t, missingInCSV, "Baron slugs missing in CSV") } func csvValue(row []string, index map[string]int, key string) string { i, ok := index[key] if !ok || i < 0 || i >= len(row) { return "" } return strings.TrimSpace(row[i]) } func worksmobileLiveCSVPath(nodes map[string]worksmobileLiveCSVOrg, slug string) string { node, ok := nodes[slug] if !ok { return slug } if node.ParentSlug == "" { return node.Name } parentPath := worksmobileLiveCSVPath(nodes, node.ParentSlug) if parentPath == "" { return node.Name } return parentPath + "/" + node.Name } func findWorksmobileLiveRemoteByPath(groups []WorksmobileRemoteGroup, byID map[string]WorksmobileRemoteGroup, domainID int64, path string) (WorksmobileRemoteGroup, bool) { var fallback WorksmobileRemoteGroup found := false for _, group := range groups { if group.DomainID != domainID { continue } if worksmobileLiveRemotePath(byID, group) != path { continue } if group.ExternalID == "" { return group, true } if !found { fallback = group found = true } } return fallback, found } func worksmobileLiveRemotePath(byID map[string]WorksmobileRemoteGroup, group WorksmobileRemoteGroup) string { names := []string{group.DisplayName} parentID := strings.TrimSpace(group.ParentID) seen := map[string]bool{group.ID: true} for parentID != "" && !seen[parentID] { parent, ok := byID[parentID] if !ok { break } seen[parentID] = true if parent.DisplayName != "" { names = append([]string{parent.DisplayName}, names...) } parentID = strings.TrimSpace(parent.ParentID) } return strings.Join(names, "/") } func collectWorksmobileLiveSubtreeIDs(rootID string, groups []WorksmobileRemoteGroup, target map[string]bool) { if target[rootID] { return } target[rootID] = true for _, group := range groups { if group.ParentID == rootID { collectWorksmobileLiveSubtreeIDs(group.ID, groups, target) } } } func worksmobileLiveRemoteDepth(byID map[string]WorksmobileRemoteGroup, group WorksmobileRemoteGroup) int { depth := 0 parentID := strings.TrimSpace(group.ParentID) seen := map[string]bool{group.ID: true} for parentID != "" && !seen[parentID] { parent, ok := byID[parentID] if !ok { break } depth++ seen[parentID] = true parentID = strings.TrimSpace(parent.ParentID) } return depth } func worksmobileLiveDSN() string { host := getenvDefault("DB_HOST", "localhost") port := getenvDefault("DB_PORT", "5432") user := getenvDefault("DB_USER", "baron") password := os.Getenv("DB_PASSWORD") name := getenvDefault("DB_NAME", "baron_sso") return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", host, user, password, name, port) } func findWorksmobileLiveOutboxByDedupe(t *testing.T, db *gorm.DB, dedupeKey string) domain.WorksmobileOutbox { t.Helper() var job domain.WorksmobileOutbox deadline := time.Now().Add(3 * time.Second) for { err := db.Where("dedupe_key = ?", dedupeKey).First(&job).Error if err == nil { return job } if time.Now().After(deadline) { require.NoError(t, err) } time.Sleep(100 * time.Millisecond) } }