1
0
forked from baron/baron-sso

동기화 기초구조 마련

This commit is contained in:
2026-05-12 12:25:31 +09:00
parent 3063450ee0
commit 5e649c279f
33 changed files with 3364 additions and 408 deletions

View File

@@ -628,6 +628,7 @@ func normalizeTenantDomainInputs(values []string) []string {
func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
normalized := make(domain.JSONMap, len(config))
orgUnitTypeError := "orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속"
for key, value := range config {
if key == "userSchema" {
fields, err := normalizeTenantUserSchema(value)
@@ -656,14 +657,14 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
if key == "orgUnitType" {
orgUnitType, ok := value.(string)
if !ok {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부")
return nil, errors.New(orgUnitTypeError)
}
orgUnitType = strings.TrimSpace(orgUnitType)
if orgUnitType == "" {
continue
}
if !isAllowedOrgUnitType(orgUnitType) {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부")
return nil, errors.New(orgUnitTypeError)
}
normalized[key] = orgUnitType
continue
@@ -675,7 +676,7 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
func isAllowedOrgUnitType(value string) bool {
switch value {
case "실", "팀", "디비전", "셀", "본부", "지역본부", "부":
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
return true
default:
return false

View File

@@ -730,12 +730,25 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{
"visibility": "internal",
"orgUnitType": "",
"orgUnitType": "센터",
})
assert.NoError(t, err)
assert.Equal(t, "internal", config["visibility"])
assert.Equal(t, "", config["orgUnitType"])
assert.Equal(t, "센터", config["orgUnitType"])
}
func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {
for _, orgUnitType := range []string{"TF", "TF팀", "임원직속"} {
t.Run(orgUnitType, func(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{
"orgUnitType": orgUnitType,
})
assert.NoError(t, err)
assert.Equal(t, orgUnitType, config["orgUnitType"])
})
}
}
func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) {

View File

@@ -118,6 +118,47 @@ func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any,
return ""
}
func bulkUserEmailDomainCandidates(emailDomain string, email string) []string {
values := make([]string, 0, 2)
seen := map[string]bool{}
add := func(value string) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" || seen[normalized] {
return
}
seen[normalized] = true
values = append(values, normalized)
}
for _, value := range strings.FieldsFunc(emailDomain, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r'
}) {
add(value)
}
if _, domainPart, err := domain.SplitEmailDomain(email); err == nil {
add(domainPart)
}
return values
}
func bulkUserAssignmentContainsTenant(appointments []any, primaryTenantID string, tenantID string) bool {
if strings.TrimSpace(tenantID) == "" {
return true
}
if primaryTenantID != "" && primaryTenantID == tenantID {
return true
}
for _, item := range appointments {
appointment, ok := item.(map[string]any)
if !ok {
continue
}
if normalizeMetadataString(appointment["tenantId"]) == tenantID {
return true
}
}
return false
}
func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
value, ok := metadata[key]
@@ -664,17 +705,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
type bulkUserItem struct {
Email string `json:"email"`
LoginID string `json:"loginId"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
Email string `json:"email"`
LoginID string `json:"loginId"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
TenantID string `json:"tenantId"`
TenantSlug string `json:"tenantSlug"`
EmailDomain string `json:"emailDomain"`
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
Metadata map[string]any `json:"metadata"`
}
type bulkUserResult struct {
@@ -720,15 +764,103 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
// Pre-fetch tenant data to avoid redundant DB calls
type tenantCacheItem struct {
ID string
Slug string
Name string
Schema []interface{}
Groups []domain.UserGroup
LoginIDField string
}
tenantCache := make(map[string]tenantCacheItem)
tenantCacheByID := make(map[string]tenantCacheItem)
tenantCacheByDomain := make(map[string]tenantCacheItem)
buildTenantCacheItem := func(tenant *domain.Tenant) tenantCacheItem {
tItem := tenantCacheItem{
ID: tenant.ID,
Slug: tenant.Slug,
Name: tenant.Name,
}
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
tItem.Schema = s
}
if lf, ok := tenant.Config["loginIdField"].(string); ok {
tItem.LoginIDField = lf
}
if h.UserGroupRepo != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
tItem.Groups = groups
}
}
return tItem
}
cacheTenantItem := func(tItem tenantCacheItem) tenantCacheItem {
if tItem.Slug != "" {
tenantCache[strings.ToLower(strings.TrimSpace(tItem.Slug))] = tItem
}
if tItem.ID != "" {
tenantCacheByID[tItem.ID] = tItem
}
return tItem
}
resolveTenantBySlug := func(slug string) (tenantCacheItem, error) {
normalizedSlug := strings.ToLower(strings.TrimSpace(slug))
if normalizedSlug == "" {
return tenantCacheItem{}, errors.New("tenantSlug is required")
}
if tItem, exists := tenantCache[normalizedSlug]; exists {
return tItem, nil
}
if h.TenantService == nil {
return tenantCacheItem{}, errors.New("tenant service unavailable")
}
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), normalizedSlug)
if err != nil || tenant == nil {
return tenantCacheItem{}, errors.New("invalid tenantSlug: tenant not found")
}
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
}
resolveTenantByID := func(tenantID string) (tenantCacheItem, error) {
normalizedID := strings.TrimSpace(tenantID)
if normalizedID == "" {
return tenantCacheItem{}, errors.New("tenantId is required")
}
if tItem, exists := tenantCacheByID[normalizedID]; exists {
return tItem, nil
}
if h.TenantService == nil {
return tenantCacheItem{}, errors.New("tenant service unavailable")
}
tenant, err := h.TenantService.GetTenant(c.Context(), normalizedID)
if err != nil || tenant == nil {
return tenantCacheItem{}, errors.New("invalid tenantId: tenant not found")
}
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
}
resolveTenantByDomain := func(domainName string) (tenantCacheItem, bool) {
normalizedDomain := strings.ToLower(strings.TrimSpace(domainName))
if normalizedDomain == "" || h.TenantService == nil {
return tenantCacheItem{}, false
}
if tItem, exists := tenantCacheByDomain[normalizedDomain]; exists {
return tItem, true
}
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), normalizedDomain)
if err != nil || tenant == nil {
return tenantCacheItem{}, false
}
tItem := cacheTenantItem(buildTenantCacheItem(tenant))
tenantCacheByDomain[normalizedDomain] = tItem
return tItem, true
}
for _, item := range req.Users {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := strings.TrimSpace(item.TenantSlug)
dept := strings.TrimSpace(item.Department)
@@ -737,9 +869,38 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
if tenantSlug == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"})
continue
var tItem tenantCacheItem
var err error
if tenantID != "" {
tItem, err = resolveTenantByID(tenantID)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
if tenantSlug != "" && !strings.EqualFold(tenantSlug, tItem.Slug) {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantId and tenantSlug do not match"})
continue
}
tenantSlug = tItem.Slug
} else if tenantSlug != "" {
tItem, err = resolveTenantBySlug(tenantSlug)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
tenantSlug = tItem.Slug
} else {
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
tItem = domainTenant
tenantSlug = domainTenant.Slug
break
}
}
if tenantSlug == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant assignment is required"})
continue
}
}
// Role-based access check
@@ -750,33 +911,47 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
// Verify Tenant Existence and Resolve ID (with Cache)
var tItem tenantCacheItem
var exists bool
if tItem, exists = tenantCache[tenantSlug]; !exists {
if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err != nil || tenant == nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"})
resolvedAppointments := make([]any, 0, len(item.AdditionalAppointments)+2)
if len(item.AdditionalAppointments) > 0 {
appointmentFailed := false
for _, rawAppointment := range item.AdditionalAppointments {
appointmentTenantSlug := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantSlug"]))
if appointmentTenantSlug == "" {
continue
}
tItem.ID = tenant.ID
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
tItem.Schema = s
if requester != nil && requester.Role == domain.RoleTenantAdmin && appointmentTenantSlug != requester.CompanyCode {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
appointmentFailed = true
break
}
if lf, ok := tenant.Config["loginIdField"].(string); ok {
tItem.LoginIDField = lf
}
// [Fix] Cache user groups for this tenant to match department
if h.UserGroupRepo != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
tItem.Groups = groups
appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)]
if !exists {
appointmentTenant, err = resolveTenantBySlug(appointmentTenantSlug)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: strings.Replace(err.Error(), "tenantSlug", "additional tenantSlug", 1)})
appointmentFailed = true
break
}
}
tenantCache[tenantSlug] = tItem
} else {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
appointment := make(map[string]any, len(rawAppointment)+3)
for key, value := range rawAppointment {
if key == "tenantSlug" || key == "tenantId" || key == "tenantName" {
continue
}
appointment[key] = value
}
appointment["tenantId"] = appointmentTenant.ID
appointment["tenantSlug"] = appointmentTenant.Slug
if name := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantName"])); name != "" {
appointment["tenantName"] = name
} else {
appointment["tenantName"] = appointmentTenant.Name
}
resolvedAppointments = append(resolvedAppointments, appointment)
}
if appointmentFailed {
continue
}
}
@@ -836,6 +1011,26 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, userEmail) {
domainTenant, ok := resolveTenantByDomain(domainName)
if !ok || bulkUserAssignmentContainsTenant(resolvedAppointments, tItem.ID, domainTenant.ID) {
continue
}
resolvedAppointments = append(resolvedAppointments, map[string]any{
"tenantId": domainTenant.ID,
"tenantSlug": domainTenant.Slug,
"tenantName": domainTenant.Name,
"assignmentSource": "email_domain",
"sourceDomain": strings.ToLower(strings.TrimSpace(domainName)),
})
}
if len(resolvedAppointments) > 0 {
if item.Metadata == nil {
item.Metadata = map[string]any{}
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
password, _ := utils.GeneratePasswordWithPolicy(policy)
role := item.Role
if role == "" {

View File

@@ -139,6 +139,19 @@ func (m *MockTenantServiceForUser) GetTenant(ctx context.Context, id string) (*d
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantServiceForUser) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "GetTenantByDomain" {
args := m.Called(ctx, emailDomain)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
}
return nil, nil
}
func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
if args.Get(0) == nil {
@@ -432,6 +445,217 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
})
}
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-primary",
Slug: "test-tenant",
Name: "Primary Tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-primary").Return(&domain.Tenant{
ID: "t-primary",
Slug: "test-tenant",
Name: "Primary Tenant",
Config: domain.JSONMap{},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "second-tenant").Return(&domain.Tenant{
ID: "t-second",
Slug: "second-tenant",
Name: "Second Tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
appointments, ok := user.Attributes["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
if !ok {
return false
}
metadata, _ := appointment["metadata"].(map[string]any)
return appointment["tenantId"] == "t-second" &&
appointment["tenantSlug"] == "second-tenant" &&
appointment["tenantName"] == "Second Tenant" &&
appointment["department"] == "센터" &&
appointment["grade"] == "수석" &&
appointment["jobTitle"] == "Architecture" &&
metadata["employee_id"] == "EMP002"
}), mock.Anything).Return("u-appointment", nil).Once()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "dual@test.com",
"name": "Dual User",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"employee_id": "EMP001"},
"additionalAppointments": []map[string]interface{}{
{
"tenantSlug": "second-tenant",
"department": "센터",
"grade": "수석",
"jobTitle": "Architecture",
"metadata": map[string]interface{}{"employee_id": "EMP002"},
},
},
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "gpdtdc").Return(&domain.Tenant{
ID: "t-gpdtdc",
Slug: "gpdtdc",
Name: "GPDTDC",
Config: domain.JSONMap{},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-gpdtdc").Return(&domain.Tenant{
ID: "t-gpdtdc",
Slug: "gpdtdc",
Name: "GPDTDC",
Config: domain.JSONMap{},
}, nil)
mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
if user.Attributes["tenant_id"] != "t-gpdtdc" || user.Attributes["companyCode"] != "gpdtdc" {
return false
}
appointments, ok := user.Attributes["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
if !ok {
return false
}
return appointment["tenantId"] == "t-saman" &&
appointment["tenantSlug"] == "saman" &&
appointment["tenantName"] == "삼안" &&
appointment["assignmentSource"] == "email_domain" &&
appointment["sourceDomain"] == "samaneng.com"
}), mock.Anything).Return("u-domain-assigned", nil).Once()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "user@samaneng.com",
"name": "Domain User",
"tenantSlug": "gpdtdc",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{})
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitTenantMissing(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-saman").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["tenant_id"] == "t-saman" &&
user.Attributes["companyCode"] == "saman" &&
user.Attributes["additionalAppointments"] == nil
}), mock.Anything).Return("u-domain-primary", nil).Once()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "user@samaneng.com",
"name": "Domain Primary User",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{})
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)

View File

@@ -28,6 +28,7 @@ const (
type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error
@@ -36,14 +37,15 @@ type WorksmobileDirectoryClient interface {
}
type WorksmobileHTTPClient struct {
BaseURL string
DirectoryToken string
SCIMToken string
HTTPClient *http.Client
OAuthConfig WorksmobileOAuthConfig
DomainIDs []int64
tokenCache worksmobileAccessTokenCache
now func() time.Time
BaseURL string
DirectoryToken string
SCIMToken string
HTTPClient *http.Client
OAuthConfig WorksmobileOAuthConfig
DomainIDs []int64
OrgUnitWriteDelay time.Duration
tokenCache worksmobileAccessTokenCache
now func() time.Time
}
type WorksmobileOAuthConfig struct {
@@ -186,6 +188,103 @@ func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload Works
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
}
func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
err := c.CreateOrgUnit(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
return c.BackfillOrgUnitExternalKeyByLocalPart(ctx, payload, matchLocalPart)
}
return err
}
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
}
for _, group := range groups {
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
continue
}
if group.ExternalID == payload.OrgUnitExternalKey {
if strings.TrimSpace(group.ID) == "" {
return nil
}
if delay := c.orgUnitWriteDelay(); delay > 0 {
time.Sleep(delay)
}
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 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 {
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)
}
patch := NewWorksmobileOrgUnitPatchPayload(payload)
if patch.Email == "" {
patch.Email = remote.Email
}
return c.PatchOrgUnit(ctx, remote.ID, patch)
}
func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" {
return fmt.Errorf("worksmobile orgunit id is required")
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload)
}
func (c *WorksmobileHTTPClient) ClearOrgUnitExternalKey(ctx context.Context, orgUnitID string, domainID int64) error {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" {
return fmt.Errorf("worksmobile orgunit id is required")
}
payload := map[string]any{
"domainId": domainID,
"orgUnitExternalKey": "",
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload)
}
func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" {
return fmt.Errorf("worksmobile orgunit id is required")
}
if delay := c.orgUnitWriteDelay(); delay > 0 {
time.Sleep(delay)
}
return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), nil)
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
}
@@ -611,6 +710,15 @@ type WorksmobileUserPatchPayload struct {
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
type WorksmobileOrgUnitPatchPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"`
OrgUnitName string `json:"orgUnitName,omitempty"`
OrgUnitExternalKey string `json:"orgUnitExternalKey,omitempty"`
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
DisplayOrder int `json:"displayOrder,omitempty"`
}
type WorksmobileRemoteUser struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
@@ -631,13 +739,15 @@ type WorksmobileRemoteUser struct {
}
type WorksmobileRemoteGroup struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
DisplayName string `json:"displayName"`
DomainID int64 `json:"domainId"`
DomainName string `json:"domainName"`
ParentID string `json:"parentId"`
ParentName string `json:"parentName"`
ID string `json:"id"`
ExternalID string `json:"externalId"`
DisplayName string `json:"displayName"`
Email string `json:"email,omitempty"`
MailLocalPart string `json:"mailLocalPart,omitempty"`
DomainID int64 `json:"domainId"`
DomainName string `json:"domainName"`
ParentID string `json:"parentId"`
ParentName string `json:"parentName"`
}
func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload {
@@ -681,6 +791,17 @@ func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileU
}
}
func NewWorksmobileOrgUnitPatchPayload(payload WorksmobileOrgUnitPayload) WorksmobileOrgUnitPatchPayload {
return WorksmobileOrgUnitPatchPayload{
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,
}
}
func worksmobileSCIMPreferredLanguage(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
@@ -716,10 +837,13 @@ func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser {
}
func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup {
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
group := WorksmobileRemoteGroup{
ID: stringFromMap(resource, "id"),
ExternalID: stringFromMap(resource, "externalId"),
DisplayName: stringFromMap(resource, "displayName"),
ID: stringFromMap(resource, "id"),
ExternalID: stringFromMap(resource, "externalId"),
DisplayName: stringFromMap(resource, "displayName"),
Email: email,
MailLocalPart: worksmobileMailLocalPart(email),
}
group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource)
return group
@@ -751,15 +875,29 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
}
func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup {
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
return WorksmobileRemoteGroup{
ID: firstStringFromMap(resource, "orgUnitId", "id"),
ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"),
DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"),
ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"),
ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"),
ID: firstStringFromMap(resource, "orgUnitId", "id"),
ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"),
DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"),
Email: email,
MailLocalPart: worksmobileMailLocalPart(email),
ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"),
ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"),
}
}
func worksmobileMailLocalPart(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return ""
}
if at := strings.Index(normalized, "@"); at >= 0 {
normalized = normalized[:at]
}
return strings.TrimSpace(normalized)
}
func parseWorksmobileDirectoryUserName(resource map[string]any) string {
if value := firstStringFromMap(resource, "displayName", "name"); value != "" {
return value
@@ -969,3 +1107,13 @@ func (c *WorksmobileHTTPClient) currentTime() time.Time {
}
return time.Now()
}
func (c *WorksmobileHTTPClient) orgUnitWriteDelay() time.Duration {
if c.OrgUnitWriteDelay < 0 {
return 0
}
if c.OrgUnitWriteDelay > 0 {
return c.OrgUnitWriteDelay
}
return 1100 * time.Millisecond
}

View File

@@ -262,6 +262,68 @@ func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) {
require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path)
}
func TestWorksmobileHTTPClientUpsertOrgUnitBackfillsExternalKeyByMailLocalPart(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"기술개발센터","email":"tech-dev-center@samaneng.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
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: 0,
}, "tech-dev-center")
require.NoError(t, err)
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.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)
require.Equal(t, "/v1.0/orgunits/works-org-1", transport.requests[2].URL.Path)
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
}
func TestWorksmobileHTTPClientUpsertOrgUnitTreatsExistingExternalKeyConflictAsSuccess(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT"}`},
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitExternalKey":"tenant-tech-dev-center","orgUnitName":"기술개발센터"}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300285955},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertOrgUnit(context.Background(), WorksmobileOrgUnitPayload{
DomainID: 300285955,
OrgUnitName: "기술개발센터",
OrgUnitExternalKey: "tenant-tech-dev-center",
}, "")
require.NoError(t, err)
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, http.MethodGet, transport.requests[1].Method)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
require.Contains(t, string(transport.requestBodies[1]), `"orgUnitExternalKey":"tenant-tech-dev-center"`)
}
func TestWorksmobileLiveJWTTokenExchange(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" {
t.Skip("live Worksmobile token exchange is disabled")
@@ -486,6 +548,35 @@ func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenInclu
require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName)
}
func TestCompareWorksmobileGroupsMatchesBySlugLocalPartWhenExternalIDMissing(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: "기술개발센터",
Email: "tech-dev-center@samaneng.com",
MailLocalPart: "tech-dev-center",
},
}
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.Equal(t, "tenant-tech-dev-center", all[0].BaronID)
require.Equal(t, "works-org-1", all[0].WorksmobileID)
require.Empty(t, all[0].ExternalKey)
}
func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) {
user := parseWorksmobileRemoteUser(map[string]any{
"id": "works-1",
@@ -564,6 +655,17 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
require.True(t, *user.PrimaryOrgUnitIsManager)
}
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
group := parseWorksmobileDirectoryGroup(map[string]any{
"orgUnitId": "works-org-1",
"orgUnitName": "기술개발센터",
"email": "tech-dev-center@samaneng.com",
})
require.Equal(t, "tech-dev-center@samaneng.com", group.Email)
require.Equal(t, "tech-dev-center", group.MailLocalPart)
}
type fakeWorksmobileOutboxRepo struct {
ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox
@@ -609,9 +711,10 @@ func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, m
}
type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload
deletedUsers []string
createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload
deletedUsers []string
orgUnitMatchKeys []string
}
type captureRoundTripper struct {
@@ -679,6 +782,12 @@ func (f *fakeWorksmobileDirectoryClient) CreateOrgUnit(ctx context.Context, payl
return nil
}
func (f *fakeWorksmobileDirectoryClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
f.createdOrgUnits = append(f.createdOrgUnits, payload)
f.orgUnitMatchKeys = append(f.orgUnitMatchKeys, matchLocalPart)
return nil
}
func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
f.createdUsers = append(f.createdUsers, payload)
return nil

View File

@@ -4,8 +4,13 @@ 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"
@@ -105,6 +110,444 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
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 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 = &currentCopy
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 createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) {
t.Helper()
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1)
@@ -119,6 +562,435 @@ func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, cl
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")

View File

@@ -21,6 +21,7 @@ const (
type WorksmobileOrgUnitPayload struct {
DomainID int64 `json:"domainId"`
OrgUnitName string `json:"orgUnitName"`
Email string `json:"email,omitempty"`
OrgUnitExternalKey string `json:"orgUnitExternalKey"`
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
DisplayOrder int `json:"displayOrder"`
@@ -78,6 +79,7 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
payload := WorksmobileOrgUnitPayload{
DomainID: domainID,
OrgUnitName: strings.TrimSpace(tenant.Name),
Email: buildWorksmobileOrgUnitEmail(tenant, domainTenant),
OrgUnitExternalKey: tenant.ID,
DisplayOrder: displayOrder,
}
@@ -90,6 +92,48 @@ func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainT
return payload, nil
}
func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tenant) string {
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
if slug == "" {
return ""
}
if domainName := worksmobileTenantMailDomain(domainTenant); domainName != "" {
return slug + "@" + domainName
}
for _, candidate := range append([]domain.TenantDomain{}, domainTenant.Domains...) {
domainName := strings.ToLower(strings.TrimSpace(candidate.Domain))
if domainName != "" {
return slug + "@" + domainName
}
}
for _, candidate := range tenant.Domains {
domainName := strings.ToLower(strings.TrimSpace(candidate.Domain))
if domainName != "" {
return slug + "@" + domainName
}
}
return ""
}
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
return domainName
}
switch envKey {
case "SAMAN":
return "samaneng.com"
case "HANMAC":
return "hanmaceng.co.kr"
case "GPDTDC":
return "baroncs.co.kr"
case "BARONGROUP":
return "brsw.kr"
default:
return ""
}
}
func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig)
}

View File

@@ -13,6 +13,7 @@ func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassifi
parentID := "11111111-1111-1111-1111-111111111111"
tenant := domain.Tenant{
ID: "22222222-2222-2222-2222-222222222222",
Slug: "tech-dev-center",
Name: "Saman Engineering",
ParentID: &parentID,
Domains: []domain.TenantDomain{
@@ -32,11 +33,29 @@ func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassifi
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "Saman Engineering", payload.OrgUnitName)
require.Equal(t, "tech-dev-center@samaneng.com", payload.Email)
require.Equal(t, tenant.ID, payload.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+parentID, payload.ParentOrgUnitID)
require.Equal(t, 7, payload.DisplayOrder)
}
func TestBuildWorksmobileOrgUnitPayloadUsesWorksmobileMailDomainForBarongroup(t *testing.T) {
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tenant := domain.Tenant{
ID: "11111111-1111-1111-1111-111111111111",
Slug: "jangheon",
Name: "(주)장헌",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "jangheon.com"}},
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, tenant, nil, 1)
require.NoError(t, err)
require.Equal(t, int64(1004), payload.DomainID)
require.Equal(t, "jangheon@brsw.kr", payload.Email)
}
func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) {
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID}

View File

@@ -89,11 +89,7 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err
}
err := w.client.CreateOrgUnit(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 {
return nil
}
return err
return w.client.UpsertOrgUnit(ctx, payload, stringValue(job.Payload["matchLocalPart"]))
case domain.WorksmobileResourceUser:
switch job.Action {
case domain.WorksmobileActionUpsert:

View File

@@ -258,7 +258,10 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload},
Payload: domain.JSONMap{
"request": payload,
"matchLocalPart": tenant.Slug,
},
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
@@ -392,7 +395,10 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload},
Payload: domain.JSONMap{
"request": payload,
"matchLocalPart": tenant.Slug,
},
})
}
@@ -596,8 +602,7 @@ func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool {
func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
current := tenant
for {
envKey := worksmobileTenantDomainIDEnvKey(current)
if envKey != "BARONGROUP_DOMAIN_ID" || current.Type == domain.TenantTypeCompany {
if current.Type == domain.TenantTypeCompany || len(current.Domains) > 0 {
return current
}
parentID := worksmobileTenantParentID(current)
@@ -635,8 +640,10 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
payload.ParentOrgUnitID = ""
}
if tenant.ParentID != nil {
if parent, ok := tenantByID[*tenant.ParentID]; ok && parent.Slug == "baron-group" {
payload.ParentOrgUnitID = ""
if parent, ok := tenantByID[*tenant.ParentID]; ok {
if parent.Slug == "baron-group" || !isWorksmobileOrgUnitTenant(parent, tenantByID) {
payload.ParentOrgUnitID = ""
}
}
}
return payload
@@ -785,14 +792,27 @@ func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]dom
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
remoteByMailLocalPart := map[string]WorksmobileRemoteGroup{}
ambiguousMailLocalParts := map[string]bool{}
for _, remote := range remoteGroups {
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{}
ignoredLocalByID := map[string]bool{}
matchedRemoteIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, tenant := range localTenants {
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
@@ -801,7 +821,11 @@ 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
}
item := WorksmobileComparisonItem{
@@ -817,20 +841,26 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
item.WorksmobileEmail = remote.Email
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
item.WorksmobileParentID = remote.ParentID
item.WorksmobileParentName = remote.ParentName
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
}
for _, remote := range remoteGroups {
if matchedRemoteIDs[remote.ID] {
continue
}
if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{
ResourceType: "GROUP",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID,
@@ -848,6 +878,7 @@ func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []Works
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID,

View File

@@ -258,7 +258,43 @@ func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
require.Len(t, outboxRepo.created, 1)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, organizationID, request.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+companyID, request.ParentOrgUnitID)
require.Empty(t, request.ParentOrgUnitID)
}
func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "root-tenant"
companyID := "company-tenant"
organizationID := "organization-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Slug: "gpdtdc",
Name: "총괄기획&기술개발센터",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "baroncs.co.kr"}},
}
organization := domain.Tenant{
ID: organizationID,
Slug: "gpd",
Name: "총괄기획실",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
tenantByID := worksmobileTenantByID([]domain.Tenant{root, company, organization})
domainTenant := worksmobileDomainClassificationTenant(organization, tenantByID)
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(organization, domainTenant, nil, 1)
require.NoError(t, err)
require.Equal(t, companyID, domainTenant.ID)
require.Equal(t, int64(1003), payload.DomainID)
require.Equal(t, "gpd@baroncs.co.kr", payload.Email)
}
func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) {