1
0
forked from baron/baron-sso

네이버 웍스 연동기능 개선

This commit is contained in:
2026-05-18 15:36:30 +09:00
parent c71ece84b8
commit e29d056b9e
61 changed files with 4137 additions and 710 deletions

View File

@@ -751,6 +751,7 @@ func main() {
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)

View File

@@ -61,6 +61,15 @@ func (h *WorksmobileHandler) SyncOrgUnit(c *fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(job)
}
func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
orgUnitID := strings.TrimSpace(c.Params("orgUnitId"))
job, err := h.Service.EnqueueOrgUnitDelete(c.Context(), strings.TrimSpace(c.Params("tenantId")), orgUnitID)
if err != nil {
return worksmobileGuardError(c, err, "delete_orgunit", "org_unit_id", orgUnitID)
}
return c.Status(fiber.StatusAccepted).JSON(job)
}
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId"))
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)

View File

@@ -112,6 +112,10 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitSync(ctx context.Context, te
return &domain.WorksmobileOutbox{ID: "job-orgunit", ResourceID: orgUnitID}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
if f.syncUserErr != nil {
return nil, f.syncUserErr

View File

@@ -29,6 +29,7 @@ const (
type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error
@@ -186,6 +187,9 @@ func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, o
}
func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error {
if payload.DisplayOrder < 1 {
payload.DisplayOrder = 1
}
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
}
@@ -198,11 +202,12 @@ func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload Works
}
func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
localPart := worksmobileMailLocalPart(matchLocalPart)
groups, err := c.ListGroups(ctx)
if err != nil {
return err
}
normalizedMatchLocalPart := worksmobileMailLocalPart(matchLocalPart)
var localPartMatch *WorksmobileRemoteGroup
for _, group := range groups {
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
continue
@@ -216,43 +221,24 @@ func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx contex
}
return c.PatchOrgUnit(ctx, group.ID, NewWorksmobileOrgUnitPatchPayload(payload))
}
}
if localPart == "" {
return fmt.Errorf("worksmobile orgunit local-part match key is required")
}
matches := make([]WorksmobileRemoteGroup, 0, 1)
for _, group := range groups {
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
continue
}
if group.MailLocalPart == localPart {
matches = append(matches, group)
if normalizedMatchLocalPart != "" && worksmobileMailLocalPart(group.MailLocalPart) == normalizedMatchLocalPart {
matched := group
if localPartMatch != nil && localPartMatch.ID != matched.ID {
return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", normalizedMatchLocalPart)
}
localPartMatch = &matched
}
}
if len(matches) == 0 {
return fmt.Errorf("worksmobile orgunit local-part match not found: %s", localPart)
}
if len(matches) > 1 {
return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", localPart)
}
remote := matches[0]
if strings.TrimSpace(remote.ID) == "" {
return fmt.Errorf("worksmobile orgunit id is missing for local-part: %s", localPart)
}
if strings.TrimSpace(remote.ExternalID) != "" {
if remote.ExternalID == payload.OrgUnitExternalKey {
if localPartMatch != nil {
if strings.TrimSpace(localPartMatch.ID) == "" {
return nil
}
return fmt.Errorf("worksmobile orgunit external key already exists for local-part %s: %s", localPart, remote.ExternalID)
if delay := c.orgUnitWriteDelay(); delay > 0 {
time.Sleep(delay)
}
return c.PatchOrgUnit(ctx, localPartMatch.ID, NewWorksmobileOrgUnitPatchPayload(payload))
}
if delay := c.orgUnitWriteDelay(); delay > 0 {
time.Sleep(delay)
}
patch := NewWorksmobileOrgUnitPatchPayload(payload)
if patch.Email == "" {
patch.Email = remote.Email
}
return c.PatchOrgUnit(ctx, remote.ID, patch)
return fmt.Errorf("worksmobile orgunit external key match not found after create conflict: %s", payload.OrgUnitExternalKey)
}
func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error {

View File

@@ -325,6 +325,7 @@ func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path)
require.Contains(t, string(transport.requestBodies[0]), `"displayOrder":1`)
require.Equal(t, http.MethodGet, transport.requests[1].Method)
require.Equal(t, "/v1.0/orgunits", transport.requests[1].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
@@ -332,6 +333,34 @@ func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
}
func TestWorksmobileHTTPClientUpsertOrgUnitDoesNotBackfillExternalKeyByName(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"기술개발센터","email":"legacy-tech@samaneng.com"}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300285955},
HTTPClient: &http.Client{Transport: transport},
OrgUnitWriteDelay: -1,
}
err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{
DomainID: 300285955,
OrgUnitName: "기술개발센터",
OrgUnitExternalKey: "tenant-tech-dev-center",
DisplayOrder: 1,
}, "tech-dev-center")
require.Error(t, err)
require.Contains(t, err.Error(), "external key match not found")
require.Len(t, transport.requests, 2)
require.Equal(t, http.MethodGet, transport.requests[1].Method)
}
func TestWorksmobileHTTPClientUpsertOrgUnitTreatsExistingExternalKeyConflictAsSuccess(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
@@ -463,6 +492,29 @@ func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testin
require.Equal(t, []string{"tester@samaneng.com"}, client.activeUsers)
}
func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "works-org-1",
Action: domain.WorksmobileActionDelete,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{"worksmobileId": "works-org-1"},
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
@@ -564,8 +616,8 @@ func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
parentID := "tenant-parent"
childID := "tenant-child"
localTenants := []domain.Tenant{
{ID: parentID, Name: "기술본부", Type: domain.TenantTypeOrganization},
{ID: childID, Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
{ID: parentID, Slug: "tech-hq", Name: "기술본부", Type: domain.TenantTypeOrganization},
{ID: childID, Slug: "tech-planning", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
}
remoteGroups := []WorksmobileRemoteGroup{
{
@@ -589,7 +641,9 @@ func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
items := compareWorksmobileGroups(localTenants, remoteGroups, true)
require.Len(t, items, 2)
require.Equal(t, "tech-planning", items[1].BaronSlug)
require.Equal(t, parentID, items[1].BaronParentID)
require.Equal(t, "tech-hq", items[1].BaronParentSlug)
require.Equal(t, "기술본부", items[1].BaronParentName)
require.Equal(t, int64(300286337), items[1].WorksmobileDomainID)
require.Equal(t, "총괄기획&기술개발센터", items[1].WorksmobileDomainName)
@@ -638,7 +692,7 @@ func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenInclu
require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName)
}
func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *testing.T) {
func TestCompareWorksmobileGroupsDoesNotMatchBySlugLocalPartWhenExternalIDMissing(t *testing.T) {
localTenants := []domain.Tenant{
{
ID: "tenant-tech-dev-center",
@@ -659,12 +713,75 @@ func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(t *
diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false)
all := compareWorksmobileGroups(localTenants, remoteGroups, true)
require.Empty(t, diffOnly)
require.Len(t, all, 1)
require.Equal(t, "matched", all[0].Status)
require.Len(t, diffOnly, 2)
require.Equal(t, "missing_in_worksmobile", diffOnly[0].Status)
require.Equal(t, "tenant-tech-dev-center", diffOnly[0].BaronID)
require.Equal(t, "missing_external_key", diffOnly[1].Status)
require.Equal(t, "works-org-1", diffOnly[1].WorksmobileID)
require.Len(t, all, 2)
require.Equal(t, "missing_in_worksmobile", all[0].Status)
require.Equal(t, "tenant-tech-dev-center", all[0].BaronID)
require.Equal(t, "works-org-1", all[0].WorksmobileID)
require.Empty(t, all[0].ExternalKey)
require.Equal(t, "missing_external_key", all[1].Status)
require.Equal(t, "works-org-1", all[1].WorksmobileID)
require.Empty(t, all[1].ExternalKey)
}
func TestCompareWorksmobileGroupsDoesNotMatchByNameWhenExternalIDAndSlugAreMissing(t *testing.T) {
localTenants := []domain.Tenant{
{
ID: "tenant-tech-dev-center",
Slug: "tech-dev-center",
Name: "기술개발센터",
Type: domain.TenantTypeOrganization,
},
}
remoteGroups := []WorksmobileRemoteGroup{
{
ID: "works-org-1",
DisplayName: "기술개발센터",
},
}
items := compareWorksmobileGroups(localTenants, remoteGroups, false)
require.Len(t, items, 2)
require.Equal(t, "missing_in_worksmobile", items[0].Status)
require.Equal(t, "tenant-tech-dev-center", items[0].BaronID)
require.Equal(t, "missing_external_key", items[1].Status)
require.Equal(t, "works-org-1", items[1].WorksmobileID)
}
func TestCompareWorksmobileGroupsListsExternalKeyMissingRowsAsDeleteCandidatesAcrossDomains(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
rootID := "root-tenant"
samanID := "company-saman"
hanmacID := "company-hanmac"
localTenants := []domain.Tenant{
{ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
{ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
{ID: hanmacID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
{ID: "tenant-saman-planning", Slug: "planning", Name: "기획팀", Type: domain.TenantTypeOrganization, ParentID: &samanID},
{ID: "tenant-hanmac-planning", Slug: "planning", Name: "기획팀", Type: domain.TenantTypeOrganization, ParentID: &hanmacID},
}
remoteGroups := []WorksmobileRemoteGroup{
{ID: "works-saman-planning", DomainID: 1001, DisplayName: "기획팀", MailLocalPart: "planning"},
{ID: "works-hanmac-planning", DomainID: 1002, DisplayName: "기획팀", MailLocalPart: "planning"},
}
items := compareWorksmobileGroups(localTenants, remoteGroups, false)
require.Len(t, items, 4)
require.Equal(t, "tenant-saman-planning", items[0].BaronID)
require.Equal(t, "missing_in_worksmobile", items[0].Status)
require.Equal(t, "tenant-hanmac-planning", items[1].BaronID)
require.Equal(t, "missing_in_worksmobile", items[1].Status)
require.Equal(t, "works-saman-planning", items[2].WorksmobileID)
require.Equal(t, "missing_external_key", items[2].Status)
require.Equal(t, "works-hanmac-planning", items[3].WorksmobileID)
require.Equal(t, "missing_external_key", items[3].Status)
}
func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) {
@@ -802,11 +919,13 @@ func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, m
type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
deletedOrgUnits []string
createdUsers []WorksmobileUserPayload
deletedUsers []string
activeUsers []string
suspendedUsers []string
orgUnitMatchKeys []string
groups []WorksmobileRemoteGroup
}
type captureRoundTripper struct {
@@ -880,6 +999,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertOrgUnit(ctx context.Context, payl
return nil
}
func (f *fakeWorksmobileDirectoryClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
f.deletedOrgUnits = append(f.deletedOrgUnits, orgUnitID)
return nil
}
func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
f.createdUsers = append(f.createdUsers, payload)
return nil
@@ -909,5 +1033,5 @@ func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]Works
}
func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
return nil, nil
return f.groups, nil
}

View File

@@ -126,6 +126,13 @@ func TestWorksmobileLiveGPDTDCOrgUnitProvisioning(t *testing.T) {
})
}
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")
@@ -548,6 +555,142 @@ func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug stri
}
}
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)

View File

@@ -72,6 +72,9 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
if err := ValidateWorksmobileExternalKey(tenant.ID); err != nil {
return WorksmobileOrgUnitPayload{}, err
}
if displayOrder < 1 {
displayOrder = 1
}
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return WorksmobileOrgUnitPayload{}, err

View File

@@ -56,6 +56,21 @@ func TestBuildWorksmobileOrgUnitPayloadUsesWorksmobileMailDomainForBarongroup(t
require.Equal(t, "jangheon@brsw.kr", payload.Email)
}
func TestBuildWorksmobileOrgUnitPayloadDefaultsDisplayOrderToOne(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenant := domain.Tenant{
ID: "11111111-1111-1111-1111-111111111111",
Slug: "tech-dev-center",
Name: "기술개발센터",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 0)
require.NoError(t, err)
require.Equal(t, 1, payload.DisplayOrder)
}
func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) {
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID}

View File

@@ -82,6 +82,9 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
switch job.ResourceType {
case domain.WorksmobileResourceOrgUnit:
if job.Action == domain.WorksmobileActionDelete {
return w.client.DeleteOrgUnit(ctx, stringValue(job.Payload["worksmobileId"]))
}
if job.Action != domain.WorksmobileActionUpsert {
return nil
}

View File

@@ -24,6 +24,7 @@ type WorksmobileAdminService interface {
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
@@ -62,11 +63,14 @@ type WorksmobileComparison struct {
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
@@ -82,8 +86,13 @@ type WorksmobileComparisonItem struct {
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
Status string `json:"status"`
}
@@ -243,16 +252,21 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
}
func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root *domain.Tenant, tenant domain.Tenant, scopeTenants []domain.Tenant) (*domain.WorksmobileOutbox, error) {
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
*tenant,
worksmobileDomainClassificationTenant(*tenant, tenantByID),
tenant,
worksmobileDomainClassificationTenant(tenant, tenantByID),
root.Config,
0,
)
if err != nil {
return nil, err
}
payload = normalizeWorksmobileOrgUnitParent(payload, *tenant, tenantByID, root.ID)
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
@@ -269,6 +283,62 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
return item, nil
}
func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
if s.client == nil {
return nil, errors.New("worksmobile client is not configured")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return nil, err
}
worksmobileOrgUnitID = strings.TrimSpace(worksmobileOrgUnitID)
if worksmobileOrgUnitID == "" {
return nil, errors.New("worksmobile orgunit id is required")
}
groups, err := s.client.ListGroups(ctx)
if err != nil {
return nil, err
}
var target *WorksmobileRemoteGroup
for i := range groups {
if strings.TrimSpace(groups[i].ID) == worksmobileOrgUnitID {
target = &groups[i]
break
}
}
if target == nil {
return nil, errors.New("worksmobile orgunit not found")
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok {
return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants)
}
if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted")
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: worksmobileOrgUnitID,
Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
Payload: domain.JSONMap{
"worksmobileId": worksmobileOrgUnitID,
"externalKey": target.ExternalID,
"domainId": target.DomainID,
"name": target.DisplayName,
"email": target.Email,
},
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -585,6 +655,53 @@ func addWorksmobileLocalPart(target map[string]string, email string, owner strin
}
}
func findWorksmobileOrgUnitTenantByRemoteLocalPart(remote WorksmobileRemoteGroup, localTenants []domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
for _, tenant := range localTenants {
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
continue
}
if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
return tenant, true
}
}
return domain.Tenant{}, false
}
func isProtectedWorksmobileRemoteOrgUnit(root domain.Tenant, localTenants []domain.Tenant, remote WorksmobileRemoteGroup) bool {
if strings.TrimSpace(remote.ParentID) == "" {
return true
}
candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
if len(candidates) == 0 {
return false
}
for _, tenant := range localTenants {
if tenant.ParentID == nil || *tenant.ParentID != root.ID || tenant.Type != domain.TenantTypeCompany {
continue
}
if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
return true
}
}
return false
}
func worksmobileRemoteGroupLocalPartCandidates(remote WorksmobileRemoteGroup) map[string]bool {
result := map[string]bool{}
if localPart := normalizeWorksmobileSlugLocalPart(remote.MailLocalPart); localPart != "" {
result[localPart] = true
}
if localPart, err := domain.ExtractNormalizedEmailLocalPart(remote.Email); err == nil && localPart != "" {
result[localPart] = true
}
return result
}
func normalizeWorksmobileSlugLocalPart(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if tenant.Type == domain.TenantTypeOrganization {
return true
@@ -703,6 +820,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
BaronName: user.Name,
BaronEmail: user.Email,
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
Status: "missing_in_worksmobile",
}
@@ -796,24 +914,30 @@ func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]dom
return ""
}
func worksmobileUserPrimaryOrgSlug(user domain.User, localTenants map[string]domain.Tenant) string {
tenantID := worksmobileUserPrimaryOrgID(user)
if tenantID == "" {
return ""
}
if tenant, ok := localTenants[tenantID]; ok {
return strings.TrimSpace(tenant.Slug)
}
if user.Tenant != nil && user.Tenant.ID == tenantID {
return strings.TrimSpace(user.Tenant.Slug)
}
return ""
}
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
remoteByMailLocalPart := map[string]WorksmobileRemoteGroup{}
ambiguousMailLocalParts := map[string]bool{}
remoteByID := map[string]WorksmobileRemoteGroup{}
for _, remote := range remoteGroups {
if remote.ID != "" {
remoteByID[remote.ID] = remote
}
if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote
}
if remote.ExternalID == "" && remote.MailLocalPart != "" {
if _, exists := remoteByMailLocalPart[remote.MailLocalPart]; exists {
delete(remoteByMailLocalPart, remote.MailLocalPart)
ambiguousMailLocalParts[remote.MailLocalPart] = true
continue
}
if !ambiguousMailLocalParts[remote.MailLocalPart] {
remoteByMailLocalPart[remote.MailLocalPart] = remote
}
}
}
tenantByID := worksmobileTenantByID(localTenants)
localByID := map[string]domain.Tenant{}
@@ -827,9 +951,6 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
}
localByID[tenant.ID] = tenant
remote, matched := remoteByExternalID[tenant.ID]
if !matched {
remote, matched = remoteByMailLocalPart[worksmobileMailLocalPart(tenant.Slug)]
}
if matched && !includeMatched {
matchedRemoteIDs[remote.ID] = true
continue
@@ -837,8 +958,10 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
item := WorksmobileComparisonItem{
ResourceType: "GROUP",
BaronID: tenant.ID,
BaronSlug: tenant.Slug,
BaronName: tenant.Name,
BaronParentID: worksmobileTenantParentID(tenant),
BaronParentSlug: worksmobileTenantParentSlug(tenant, tenantByID),
BaronParentName: worksmobileTenantParentName(tenant, tenantByID),
Status: "missing_in_worksmobile",
}
@@ -852,6 +975,19 @@ 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 {
item.BaronParentWorksmobileID = parentRemote.ID
item.BaronParentWorksmobileName = parentRemote.DisplayName
item.BaronParentWorksmobileEmail = parentRemote.Email
}
if parentRemote, ok := remoteByID[remote.ParentID]; ok {
if item.WorksmobileParentName == "" {
item.WorksmobileParentName = parentRemote.DisplayName
}
item.WorksmobileParentEmail = parentRemote.Email
item.WorksmobileParentExternalKey = parentRemote.ExternalID
}
item = fillWorksmobileParentFromBaronParentMatch(item)
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
@@ -873,6 +1009,14 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
WorksmobileParentName: remote.ParentName,
Status: "missing_external_key",
})
if parentRemote, ok := remoteByID[remote.ParentID]; ok {
last := &result[len(result)-1]
if last.WorksmobileParentName == "" {
last.WorksmobileParentName = parentRemote.DisplayName
}
last.WorksmobileParentEmail = parentRemote.Email
last.WorksmobileParentExternalKey = parentRemote.ExternalID
}
continue
}
if ignoredLocalByID[remote.ExternalID] {
@@ -891,11 +1035,35 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
WorksmobileParentName: remote.ParentName,
Status: "missing_in_baron",
})
if parentRemote, ok := remoteByID[remote.ParentID]; ok {
last := &result[len(result)-1]
if last.WorksmobileParentName == "" {
last.WorksmobileParentName = parentRemote.DisplayName
}
last.WorksmobileParentEmail = parentRemote.Email
last.WorksmobileParentExternalKey = parentRemote.ExternalID
}
}
}
return result
}
func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem {
if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID {
return item
}
if item.WorksmobileParentName == "" {
item.WorksmobileParentName = item.BaronParentWorksmobileName
}
if item.WorksmobileParentEmail == "" {
item.WorksmobileParentEmail = item.BaronParentWorksmobileEmail
}
if item.WorksmobileParentExternalKey == "" {
item.WorksmobileParentExternalKey = item.BaronParentID
}
return item
}
func worksmobileTenantByID(tenants []domain.Tenant) map[string]domain.Tenant {
result := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
@@ -918,3 +1086,11 @@ func worksmobileTenantParentName(tenant domain.Tenant, tenantByID map[string]dom
}
return strings.TrimSpace(tenantByID[parentID].Name)
}
func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string {
parentID := worksmobileTenantParentID(tenant)
if parentID == "" {
return ""
}
return strings.TrimSpace(tenantByID[parentID].Slug)
}

View File

@@ -166,10 +166,10 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup},
[]WorksmobileRemoteGroup{
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.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},
{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-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
},
@@ -181,6 +181,13 @@ func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t
require.Equal(t, "matched", items[0].Status)
require.Equal(t, organization.ID, items[1].BaronID)
require.Equal(t, "matched", items[1].Status)
require.Equal(t, "works-hanmac", items[1].WorksmobileParentID)
require.Equal(t, hanmac.Name, items[1].WorksmobileParentName)
require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].WorksmobileParentEmail)
require.Equal(t, hanmac.ID, items[1].WorksmobileParentExternalKey)
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)
}
@@ -304,6 +311,381 @@ func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, organizationID, request.OrgUnitExternalKey)
require.Empty(t, request.ParentOrgUnitID)
require.Equal(t, 1, request.DisplayOrder)
}
func TestWorksmobileSyncServiceEnqueuesExternalKeyMissingOrgUnitDelete(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
client := &fakeWorksmobileDirectoryClient{
groups: []WorksmobileRemoteGroup{
{
ID: "works-org-1",
DisplayName: "WORKS 전용 조직",
DomainID: 1001,
ParentID: "works-parent",
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}},
&fakeWorksmobileUserRepo{},
outboxRepo,
client,
)
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"])
}
func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
client := &fakeWorksmobileDirectoryClient{
groups: []WorksmobileRemoteGroup{
{
ID: "works-org-1",
ExternalID: "baron-tenant-1",
ParentID: "works-parent",
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}},
&fakeWorksmobileUserRepo{},
outboxRepo,
client,
)
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"])
}
func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1001")
rootID := "root-tenant"
orgID := "baron-org-1"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
organization := domain.Tenant{
ID: orgID,
Slug: "tech-dev-center",
Name: "기술개발센터",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
client := &fakeWorksmobileDirectoryClient{
groups: []WorksmobileRemoteGroup{
{
ID: "works-org-1",
ExternalID: "legacy-external-key",
DisplayName: "기술개발센터",
MailLocalPart: "tech-dev-center",
DomainID: 1001,
ParentID: "works-parent",
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, orgID: organization},
list: []domain.Tenant{root, organization},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
client,
)
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1")
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, orgID, request.OrgUnitExternalKey)
require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"])
}
func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) {
rootID := "root-tenant"
parent := domain.Tenant{
ID: "parent-tenant",
Name: "삼안",
Slug: "saman",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
child := domain.Tenant{
ID: "child-tenant",
Name: "업무",
Slug: "operations",
Type: domain.TenantTypeOrganization,
ParentID: &parent.ID,
}
items := compareWorksmobileGroups(
[]domain.Tenant{
{ID: rootID, Name: "한맥가족", Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup},
parent,
child,
},
[]WorksmobileRemoteGroup{
{ID: "works-parent", ExternalID: parent.ID, DisplayName: "삼안", Email: "saman@samaneng.com"},
{ID: "works-child", ExternalID: child.ID, DisplayName: "업무", ParentID: "works-parent"},
},
true,
)
require.Len(t, items, 1)
require.Equal(t, child.ID, items[0].BaronID)
require.Equal(t, "works-parent", items[0].WorksmobileParentID)
require.Equal(t, "삼안", items[0].WorksmobileParentName)
require.Equal(t, "saman@samaneng.com", items[0].WorksmobileParentEmail)
require.Equal(t, parent.ID, items[0].WorksmobileParentExternalKey)
}
func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtectedDeleteGuard(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
orgID := "baron-operations"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
samanID := "saman-tenant"
saman := domain.Tenant{
ID: samanID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
organization := domain.Tenant{
ID: orgID,
Slug: "operations",
Name: "업무",
Type: domain.TenantTypeOrganization,
ParentID: &samanID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
client := &fakeWorksmobileDirectoryClient{
groups: []WorksmobileRemoteGroup{
{
ID: "works-operations",
ExternalID: "legacy-operations-id",
DisplayName: "업무팀",
Email: "operations@samaneng.com",
MailLocalPart: "operations",
DomainID: 1001,
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, samanID: saman, orgID: organization},
list: []domain.Tenant{root, saman, organization},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
client,
)
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-operations")
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, orgID, request.OrgUnitExternalKey)
require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"])
}
func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
client := &fakeWorksmobileDirectoryClient{
groups: []WorksmobileRemoteGroup{
{
ID: "works-root",
DisplayName: "한맥기술",
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
&fakeWorksmobileUserRepo{},
outboxRepo,
client,
)
item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-root")
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "protected worksmobile domain root")
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
rootID := "root-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
tests := []struct {
name string
company domain.Tenant
organization domain.Tenant
wantDomainID int64
wantEmail string
}{
{
name: "saman",
company: domain.Tenant{
ID: "company-saman",
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
},
organization: domain.Tenant{
ID: "org-saman-planning",
Slug: "saman-planning",
Name: "삼안 기획팀",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1001,
wantEmail: "saman-planning@samaneng.com",
},
{
name: "hanmac",
company: domain.Tenant{
ID: "company-hanmac",
Slug: "hanmac",
Name: "한맥기술",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
},
organization: domain.Tenant{
ID: "org-hanmac-planning",
Slug: "hanmac-planning",
Name: "한맥 기획팀",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1002,
wantEmail: "hanmac-planning@hanmaceng.co.kr",
},
{
name: "gpdtdc",
company: domain.Tenant{
ID: "company-gpdtdc",
Slug: "gpdtdc",
Name: "총괄기획&기술개발센터",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
},
organization: domain.Tenant{
ID: "org-gpdtdc-planning",
Slug: "gpdtdc-planning",
Name: "총괄 기획팀",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1003,
wantEmail: "gpdtdc-planning@baroncs.co.kr",
},
{
name: "baron-group",
company: domain.Tenant{
ID: "company-barongroup",
Slug: "baron-group",
Name: "바론그룹",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
},
organization: domain.Tenant{
ID: "org-baron-planning",
Slug: "baron-planning",
Name: "바론 기획팀",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1004,
wantEmail: "baron-planning@brsw.kr",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
organization := tt.organization
organization.ParentID = &tt.company.ID
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{
rootID: root,
tt.company.ID: tt.company,
organization.ID: organization,
},
list: []domain.Tenant{root, tt.company, 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, organization.ID, request.OrgUnitExternalKey)
require.Equal(t, tt.wantDomainID, request.DomainID)
require.Equal(t, tt.wantEmail, request.Email)
require.Empty(t, request.ParentOrgUnitID)
})
}
}
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {