forked from baron/baron-sso
1161 lines
46 KiB
Go
1161 lines
46 KiB
Go
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 := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
|
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
|
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
|
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
|
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
})
|
|
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 := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
|
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
|
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
|
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
|
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
})
|
|
|
|
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 := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
|
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
|
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
|
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
|
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
})
|
|
|
|
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 := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
|
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
|
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
|
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
|
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
})
|
|
|
|
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 := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
|
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
|
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
|
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
|
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
})
|
|
|
|
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 := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
|
|
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
|
|
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
|
|
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
|
|
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|