forked from baron/baron-sso
feat: improve Worksmobile tenant sync handling
This commit is contained in:
@@ -17,6 +17,7 @@ type UserGroupService interface {
|
||||
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
|
||||
Delete(ctx context.Context, tenantID, groupID string) error
|
||||
Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error)
|
||||
SetWorksmobileSyncer(syncer WorksmobileSyncer)
|
||||
|
||||
// Member Management with Keto Sync
|
||||
AddMember(ctx context.Context, groupID, userID string) error
|
||||
@@ -35,6 +36,7 @@ type userGroupService struct {
|
||||
ketoService KetoService
|
||||
outboxRepo repository.KetoOutboxRepository
|
||||
kratos KratosAdminService
|
||||
worksmobile WorksmobileSyncer
|
||||
}
|
||||
|
||||
func NewUserGroupService(
|
||||
@@ -55,6 +57,10 @@ func NewUserGroupService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *userGroupService) SetWorksmobileSyncer(syncer WorksmobileSyncer) {
|
||||
s.worksmobile = syncer
|
||||
}
|
||||
|
||||
func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
|
||||
// For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent.
|
||||
actualParentID := parentID
|
||||
@@ -261,6 +267,10 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
localUser.Department = group.Name
|
||||
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
||||
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
||||
} else if s.worksmobile != nil {
|
||||
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
|
||||
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,27 @@ func (m *MockUserRepository) DB() *gorm.DB {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeUserGroupWorksmobileSyncer struct {
|
||||
userUpserts []domain.User
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
|
||||
f.userUpserts = append(f.userUpserts, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MockKetoOutboxRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -337,6 +358,57 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
mockUserGroupRepo := new(MockUserGroupRepository)
|
||||
mockUserRepo := new(MockUserRepository)
|
||||
mockTenantRepo := new(MockTenantRepository)
|
||||
mockKratos := new(MockKratosAdminServiceShared)
|
||||
worksmobile := &fakeUserGroupWorksmobileSyncer{}
|
||||
svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos)
|
||||
svc.SetWorksmobileSyncer(worksmobile)
|
||||
|
||||
groupID := "group-1"
|
||||
userID := "user-1"
|
||||
tenantID := "tenant-1"
|
||||
|
||||
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
|
||||
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{
|
||||
ID: userID,
|
||||
Email: "user@test.com",
|
||||
Name: "User Test",
|
||||
Status: "active",
|
||||
}, nil)
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil)
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
|
||||
err := svc.AddMember(context.Background(), groupID, userID)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, worksmobile.userUpserts, 1)
|
||||
assert.Equal(t, userID, worksmobile.userUpserts[0].ID)
|
||||
assert.NotNil(t, worksmobile.userUpserts[0].TenantID)
|
||||
assert.Equal(t, tenantID, *worksmobile.userUpserts[0].TenantID)
|
||||
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
mockUserGroupRepo := new(MockUserGroupRepository)
|
||||
|
||||
@@ -658,6 +658,84 @@ func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testin
|
||||
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesOrgUnitParentsBeforeChildren(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-child",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "child-tenant",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"request": map[string]any{
|
||||
"domainId": 300293726,
|
||||
"orgUnitExternalKey": "child-tenant",
|
||||
"orgUnitName": "child",
|
||||
"parentOrgUnitId": "externalKey:parent-tenant",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "job-parent",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "parent-tenant",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"request": map[string]any{
|
||||
"domainId": 300293726,
|
||||
"orgUnitExternalKey": "parent-tenant",
|
||||
"orgUnitName": "parent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-parent", "job-child"}, repo.processingIDs)
|
||||
require.Equal(t, []string{"parent-tenant", "child-tenant"}, []string{
|
||||
client.createdOrgUnits[0].OrgUnitExternalKey,
|
||||
client.createdOrgUnits[1].OrgUnitExternalKey,
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerSkipsDispatchWhenJobClaimFails(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
markProcessingClaims: map[string]bool{"job-claimed-by-other-worker": false},
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-claimed-by-other-worker",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "org-1",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"request": map[string]any{
|
||||
"domainId": 300293726,
|
||||
"orgUnitExternalKey": "org-1",
|
||||
"orgUnitName": "org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, repo.processingIDs)
|
||||
require.Empty(t, repo.processedIDs)
|
||||
require.Empty(t, client.createdOrgUnits)
|
||||
}
|
||||
|
||||
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
|
||||
jobs := []domain.WorksmobileOutbox{
|
||||
{
|
||||
@@ -1094,14 +1172,17 @@ func boolPtr(value bool) *bool {
|
||||
}
|
||||
|
||||
type fakeWorksmobileOutboxRepo struct {
|
||||
recent []domain.WorksmobileOutbox
|
||||
ready []domain.WorksmobileOutbox
|
||||
created []domain.WorksmobileOutbox
|
||||
credentialBatchJobs []domain.WorksmobileOutbox
|
||||
payloadUpdates []domain.JSONMap
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
failedIDs []string
|
||||
recent []domain.WorksmobileOutbox
|
||||
ready []domain.WorksmobileOutbox
|
||||
created []domain.WorksmobileOutbox
|
||||
credentialBatchJobs []domain.WorksmobileOutbox
|
||||
payloadUpdates []domain.JSONMap
|
||||
deletedPendingTenantRootID string
|
||||
deletedPendingCount int
|
||||
markProcessingClaims map[string]bool
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
failedIDs []string
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
||||
@@ -1137,6 +1218,11 @@ func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
|
||||
f.deletedPendingTenantRootID = tenantRootID
|
||||
return int64(f.deletedPendingCount), nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
return f.ready, nil
|
||||
}
|
||||
@@ -1149,9 +1235,12 @@ func (f *fakeWorksmobileOutboxRepo) MarkRetry(ctx context.Context, id string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
|
||||
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) (bool, error) {
|
||||
if f.markProcessingClaims != nil && !f.markProcessingClaims[id] {
|
||||
return false, nil
|
||||
}
|
||||
f.processingIDs = append(f.processingIDs, id)
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
|
||||
|
||||
@@ -126,6 +126,9 @@ func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tena
|
||||
|
||||
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
|
||||
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
|
||||
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv("WORKS_DEFAULT_DOMAIN_" + envKey))); domainName != "" {
|
||||
return domainName
|
||||
}
|
||||
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
|
||||
return domainName
|
||||
}
|
||||
@@ -136,6 +139,8 @@ func worksmobileTenantMailDomain(tenant domain.Tenant) string {
|
||||
return "hanmaceng.co.kr"
|
||||
case "GPDTDC":
|
||||
return "baroncs.co.kr"
|
||||
case "HALLA":
|
||||
return "hallasanup.com"
|
||||
case "BARONGROUP":
|
||||
return "brsw.kr"
|
||||
default:
|
||||
@@ -493,6 +498,10 @@ func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant dom
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "hallasanup.com":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("HALLA_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "brsw.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
@@ -524,6 +533,8 @@ func worksmobileDomainIDEnvKeyFromEmail(email string) string {
|
||||
return "HANMAC_DOMAIN_ID"
|
||||
case "baroncs.co.kr":
|
||||
return "GPDTDC_DOMAIN_ID"
|
||||
case "hallasanup.com":
|
||||
return "HALLA_DOMAIN_ID"
|
||||
case "brsw.kr":
|
||||
return "BARONGROUP_DOMAIN_ID"
|
||||
default:
|
||||
@@ -574,6 +585,9 @@ func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
|
||||
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
|
||||
return "GPDTDC_DOMAIN_ID"
|
||||
}
|
||||
if isHallaWorksmobileTenant(tenant) {
|
||||
return "HALLA_DOMAIN_ID"
|
||||
}
|
||||
return "BARONGROUP_DOMAIN_ID"
|
||||
}
|
||||
|
||||
@@ -595,6 +609,7 @@ func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping {
|
||||
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
|
||||
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
|
||||
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
|
||||
{Key: "HALLA_DOMAIN_ID", Label: "한라산업개발"},
|
||||
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
|
||||
}
|
||||
}
|
||||
@@ -625,6 +640,10 @@ func isHanmacWorksmobileTenant(tenant domain.Tenant) bool {
|
||||
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
|
||||
}
|
||||
|
||||
func isHallaWorksmobileTenant(tenant domain.Tenant) bool {
|
||||
return tenantHasDomain(tenant, "hallasanup.com") || tenantMatchesAny(tenant, "halla", "hanlla", "한라산업개발")
|
||||
}
|
||||
|
||||
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
|
||||
domainName = strings.ToLower(strings.TrimSpace(domainName))
|
||||
for _, d := range tenant.Domains {
|
||||
|
||||
@@ -446,6 +446,7 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||
|
||||
tests := []struct {
|
||||
@@ -468,6 +469,16 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
|
||||
tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"},
|
||||
want: 1003,
|
||||
},
|
||||
{
|
||||
name: "halla",
|
||||
tenant: domain.Tenant{Slug: "halla", Name: "한라산업개발", Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}}},
|
||||
want: 1005,
|
||||
},
|
||||
{
|
||||
name: "hanlla legacy slug",
|
||||
tenant: domain.Tenant{Slug: "hanlla", Name: "한라산업개발"},
|
||||
want: 1005,
|
||||
},
|
||||
{
|
||||
name: "barongroup fallback",
|
||||
tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"},
|
||||
@@ -484,6 +495,58 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorksmobileAccountDomainIDUsesHallaEmailDomain(t *testing.T) {
|
||||
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||
tenant := domain.Tenant{
|
||||
Slug: "halla",
|
||||
Name: "한라산업개발",
|
||||
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
|
||||
}
|
||||
|
||||
got, err := ResolveWorksmobileAccountDomainIDFromEmail("user@hallasanup.com", tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1005), got)
|
||||
}
|
||||
|
||||
func TestWorksmobileDomainIDsFromEnvIncludesHallaBeforeFallback(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||
|
||||
got := WorksmobileDomainIDsFromEnv()
|
||||
|
||||
require.Equal(t, []int64{1001, 1002, 1003, 1005, 1004}, got)
|
||||
require.Equal(t, "한라산업개발", WorksmobileDomainLabelForID(1005))
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesHallaDomain(t *testing.T) {
|
||||
t.Setenv("HALLA_DOMAIN_ID", "1005")
|
||||
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "main@hallasanup.com",
|
||||
Name: "Halla User",
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "halla",
|
||||
Name: "한라산업개발",
|
||||
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1005), payload.DomainID)
|
||||
require.Equal(t, "main@hallasanup.com", payload.Email)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) {
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -53,6 +54,7 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs = sortWorksmobileReadyJobs(jobs)
|
||||
for _, job := range jobs {
|
||||
if err := w.processJob(ctx, job); err != nil {
|
||||
slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err)
|
||||
@@ -62,11 +64,15 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error {
|
||||
if err := w.repo.MarkProcessing(ctx, job.ID); err != nil {
|
||||
claimed, err := w.repo.MarkProcessing(ctx, job.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := w.dispatch(ctx, job)
|
||||
err = w.dispatch(ctx, job)
|
||||
if err != nil {
|
||||
nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount))
|
||||
_ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt)
|
||||
@@ -136,6 +142,91 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
}
|
||||
}
|
||||
|
||||
func sortWorksmobileReadyJobs(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
|
||||
sorted := append([]domain.WorksmobileOutbox(nil), jobs...)
|
||||
depthByID := worksmobileOrgUnitDepths(sorted)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
leftClass := worksmobileRelayOrderClass(sorted[i])
|
||||
rightClass := worksmobileRelayOrderClass(sorted[j])
|
||||
if leftClass != rightClass {
|
||||
return leftClass < rightClass
|
||||
}
|
||||
leftDepth := depthByID[sorted[i].ID]
|
||||
rightDepth := depthByID[sorted[j].ID]
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func worksmobileRelayOrderClass(job domain.WorksmobileOutbox) int {
|
||||
if job.ResourceType == domain.WorksmobileResourceOrgUnit && job.Action == domain.WorksmobileActionUpsert {
|
||||
return 0
|
||||
}
|
||||
if job.ResourceType == domain.WorksmobileResourceUser {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitDepths(jobs []domain.WorksmobileOutbox) map[string]int {
|
||||
type orgUnitJob struct {
|
||||
jobID string
|
||||
parentKey string
|
||||
}
|
||||
byExternalKey := map[string]orgUnitJob{}
|
||||
for _, job := range jobs {
|
||||
externalKey, parentKey := worksmobileOrgUnitExternalKeys(job)
|
||||
if externalKey == "" {
|
||||
continue
|
||||
}
|
||||
byExternalKey[externalKey] = orgUnitJob{jobID: job.ID, parentKey: parentKey}
|
||||
}
|
||||
|
||||
depthByExternalKey := map[string]int{}
|
||||
var depth func(externalKey string, seen map[string]bool) int
|
||||
depth = func(externalKey string, seen map[string]bool) int {
|
||||
if value, ok := depthByExternalKey[externalKey]; ok {
|
||||
return value
|
||||
}
|
||||
job, ok := byExternalKey[externalKey]
|
||||
if !ok || job.parentKey == "" || seen[externalKey] {
|
||||
depthByExternalKey[externalKey] = 0
|
||||
return 0
|
||||
}
|
||||
seen[externalKey] = true
|
||||
value := depth(job.parentKey, seen) + 1
|
||||
delete(seen, externalKey)
|
||||
depthByExternalKey[externalKey] = value
|
||||
return value
|
||||
}
|
||||
|
||||
depthByJobID := map[string]int{}
|
||||
for externalKey, job := range byExternalKey {
|
||||
depthByJobID[job.jobID] = depth(externalKey, map[string]bool{})
|
||||
}
|
||||
return depthByJobID
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitExternalKeys(job domain.WorksmobileOutbox) (string, string) {
|
||||
if job.ResourceType != domain.WorksmobileResourceOrgUnit || job.Action != domain.WorksmobileActionUpsert {
|
||||
return "", ""
|
||||
}
|
||||
var payload WorksmobileOrgUnitPayload
|
||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
parentKey := strings.TrimSpace(payload.ParentOrgUnitID)
|
||||
if strings.HasPrefix(parentKey, "externalKey:") {
|
||||
parentKey = strings.TrimSpace(strings.TrimPrefix(parentKey, "externalKey:"))
|
||||
} else {
|
||||
parentKey = ""
|
||||
}
|
||||
return strings.TrimSpace(payload.OrgUnitExternalKey), parentKey
|
||||
}
|
||||
|
||||
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
|
||||
userID := stringValue(job.Payload["loginEmail"])
|
||||
if userID == "" {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const HanmacFamilyTenantSlug = "hanmac-family"
|
||||
const worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||
|
||||
type WorksmobileSyncer interface {
|
||||
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||
@@ -31,6 +32,7 @@ type WorksmobileAdminService interface {
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||
DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error)
|
||||
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
|
||||
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
|
||||
@@ -54,6 +56,10 @@ type WorksmobileBackfillDryRun struct {
|
||||
UserCount int `json:"userCount"`
|
||||
}
|
||||
|
||||
type WorksmobilePendingJobDeleteResult struct {
|
||||
DeletedCount int `json:"deletedCount"`
|
||||
}
|
||||
|
||||
type WorksmobileInitialPasswordCredential struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -178,6 +184,21 @@ func worksmobileDirectoryAuthConfigured() bool {
|
||||
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
|
||||
}
|
||||
|
||||
func WorksmobileExcluded(config domain.JSONMap) bool {
|
||||
rawValue, ok := config[worksmobileExcludedConfigKey]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch value := rawValue.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(value), "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
|
||||
for i := range jobs {
|
||||
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
|
||||
@@ -394,6 +415,9 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target tenant is excluded from Worksmobile sync")
|
||||
}
|
||||
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
|
||||
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
|
||||
}
|
||||
@@ -511,13 +535,16 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||
}
|
||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
*user,
|
||||
*tenant,
|
||||
@@ -582,6 +609,9 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||
}
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -722,6 +752,18 @@ func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID s
|
||||
return s.outboxRepo.FindByID(ctx, jobID)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return WorksmobilePendingJobDeleteResult{}, err
|
||||
}
|
||||
deleted, err := s.outboxRepo.DeletePendingByTenantRoot(ctx, root.ID)
|
||||
if err != nil {
|
||||
return WorksmobilePendingJobDeleteResult{}, err
|
||||
}
|
||||
return WorksmobilePendingJobDeleteResult{DeletedCount: int(deleted)}, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||
root, ok, err := s.rootForTenant(ctx, tenant)
|
||||
if err != nil || !ok {
|
||||
@@ -732,6 +774,9 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex
|
||||
return err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil
|
||||
}
|
||||
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
||||
return nil
|
||||
}
|
||||
@@ -767,6 +812,9 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
|
||||
return err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil
|
||||
}
|
||||
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
||||
return nil
|
||||
}
|
||||
@@ -795,6 +843,10 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[*user.TenantID]; !ok {
|
||||
return nil
|
||||
}
|
||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
|
||||
return err
|
||||
@@ -802,7 +854,6 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
*tenant,
|
||||
@@ -833,10 +884,18 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, ok, err := s.rootForTenant(ctx, *tenant)
|
||||
root, ok, err := s.rootForTenant(ctx, *tenant)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[*user.TenantID]; !ok {
|
||||
return nil
|
||||
}
|
||||
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
|
||||
return err
|
||||
}
|
||||
@@ -891,6 +950,9 @@ func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID strin
|
||||
var visit func(id string)
|
||||
visit = func(id string) {
|
||||
for _, child := range byParent[id] {
|
||||
if WorksmobileExcluded(child.Config) {
|
||||
continue
|
||||
}
|
||||
result = append(result, child)
|
||||
visit(child.ID)
|
||||
}
|
||||
@@ -1011,6 +1073,9 @@ func normalizeWorksmobileSlugLocalPart(value string) string {
|
||||
}
|
||||
|
||||
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
||||
if isWorksmobileDomainRootTenant(tenant) {
|
||||
return false
|
||||
}
|
||||
if tenant.Type == domain.TenantTypeOrganization {
|
||||
return true
|
||||
}
|
||||
@@ -1048,12 +1113,13 @@ func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[
|
||||
func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
||||
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
|
||||
switch slug {
|
||||
case "saman", "hanmac", "gpdtdc", "baron-group":
|
||||
case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group":
|
||||
return true
|
||||
}
|
||||
if tenantHasDomain(tenant, "samaneng.com") ||
|
||||
tenantHasDomain(tenant, "hanmaceng.co.kr") ||
|
||||
tenantHasDomain(tenant, "baroncs.co.kr") ||
|
||||
tenantHasDomain(tenant, "hallasanup.com") ||
|
||||
tenantHasDomain(tenant, "brsw.kr") {
|
||||
return true
|
||||
}
|
||||
@@ -1061,6 +1127,7 @@ func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
||||
return name == "삼안" ||
|
||||
name == "한맥기술" ||
|
||||
name == "총괄기획&기술개발센터" ||
|
||||
name == "한라산업개발" ||
|
||||
name == "바론그룹"
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,70 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
|
||||
}, orgPayload["requestSummary"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
companyID := "saman-tenant"
|
||||
newParentID := "new-parent-org"
|
||||
childID := "child-org"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
Type: domain.TenantTypeCompanyGroup,
|
||||
}
|
||||
company := domain.Tenant{
|
||||
ID: companyID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
newParent := domain.Tenant{
|
||||
ID: newParentID,
|
||||
Slug: "planning",
|
||||
Name: "총괄기획",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &companyID,
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: childID,
|
||||
Slug: "people-growth",
|
||||
Name: "인재성장",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &newParentID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{
|
||||
rootID: root,
|
||||
companyID: company,
|
||||
newParentID: newParent,
|
||||
childID: child,
|
||||
},
|
||||
list: []domain.Tenant{root, company, newParent, child},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
err := service.EnqueueTenantUpsertIfInScope(context.Background(), child)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Equal(t, childID, outboxRepo.created[0].ResourceID)
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, childID, request.OrgUnitExternalKey)
|
||||
require.Equal(t, "externalKey:"+newParentID, request.ParentOrgUnitID)
|
||||
require.Equal(t, "people-growth", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
|
||||
parentID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
@@ -1085,10 +1149,34 @@ func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testin
|
||||
require.Empty(t, outboxRepo.created)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDeletesPendingJobsForTenantRoot(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{deletedPendingCount: 2}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := service.DeletePendingJobs(context.Background(), rootID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, result.DeletedCount)
|
||||
require.Equal(t, rootID, outboxRepo.deletedPendingTenantRootID)
|
||||
}
|
||||
|
||||
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("HALLA_DOMAIN_ID", "1005")
|
||||
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
|
||||
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
@@ -1177,6 +1265,43 @@ func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *
|
||||
wantDomainID: 1004,
|
||||
wantEmail: "baron-planning@brsw.kr",
|
||||
},
|
||||
{
|
||||
name: "halla",
|
||||
company: domain.Tenant{
|
||||
ID: "company-halla",
|
||||
Slug: "halla",
|
||||
Name: "한라산업개발",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
|
||||
},
|
||||
organization: domain.Tenant{
|
||||
ID: "org-halla-planning",
|
||||
Slug: "halla-planning",
|
||||
Name: "한라 기획팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
wantDomainID: 1005,
|
||||
wantEmail: "halla-planning@hallasanup.com",
|
||||
},
|
||||
{
|
||||
name: "hanlla legacy slug",
|
||||
company: domain.Tenant{
|
||||
ID: "company-hanlla",
|
||||
Slug: "hanlla",
|
||||
Name: "한라산업개발",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
},
|
||||
organization: domain.Tenant{
|
||||
ID: "org-hanlla-construction-sites",
|
||||
Slug: "hanlla-construction-sites",
|
||||
Name: "시공현장",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
},
|
||||
wantDomainID: 1005,
|
||||
wantEmail: "hanlla-construction-sites@hallasanup.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1467,6 +1592,181 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceBackfillDryRunSkipsWorksmobileExcludedSubtree(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
excludedCompanyID := "excluded-company"
|
||||
excludedOrgID := "excluded-org"
|
||||
includedCompanyID := "included-company"
|
||||
includedOrgID := "included-org"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
excludedCompany := domain.Tenant{
|
||||
ID: excludedCompanyID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
excludedOrg := domain.Tenant{
|
||||
ID: excludedOrgID,
|
||||
Slug: "excluded-team",
|
||||
Name: "제외팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &excludedCompanyID,
|
||||
}
|
||||
includedCompany := domain.Tenant{
|
||||
ID: includedCompanyID,
|
||||
Slug: "halla",
|
||||
Name: "한라산업개발",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
includedOrg := domain.Tenant{
|
||||
ID: includedOrgID,
|
||||
Slug: "included-team",
|
||||
Name: "연동팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &includedCompanyID,
|
||||
}
|
||||
excludedUser := domain.User{
|
||||
ID: "excluded-user",
|
||||
Email: "excluded@samaneng.com",
|
||||
Name: "Excluded User",
|
||||
TenantID: &excludedOrgID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
includedUser := domain.User{
|
||||
ID: "included-user",
|
||||
Email: "included@hallasanup.com",
|
||||
Name: "Included User",
|
||||
TenantID: &includedOrgID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{
|
||||
rootID: root,
|
||||
excludedCompanyID: excludedCompany,
|
||||
excludedOrgID: excludedOrg,
|
||||
includedCompanyID: includedCompany,
|
||||
includedOrgID: includedOrg,
|
||||
},
|
||||
list: []domain.Tenant{root, excludedCompany, excludedOrg, includedCompany, includedOrg},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{byTenant: []domain.User{excludedUser, includedUser}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, dryRun.OrgUnitCount)
|
||||
require.Equal(t, 1, dryRun.UserCount)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.ElementsMatch(t, []string{includedOrgID}, outboxRepo.created[0].Payload["tenantIds"])
|
||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
excludedCompanyID := "excluded-company"
|
||||
excludedOrgID := "excluded-org"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
excludedCompany := domain.Tenant{
|
||||
ID: excludedCompanyID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
excludedOrg := domain.Tenant{
|
||||
ID: excludedOrgID,
|
||||
Slug: "excluded-team",
|
||||
Name: "제외팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &excludedCompanyID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
|
||||
list: []domain.Tenant{root, excludedCompany, excludedOrg},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, excludedOrgID)
|
||||
|
||||
require.Nil(t, item)
|
||||
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
excludedCompanyID := "excluded-company"
|
||||
excludedOrgID := "excluded-org"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
excludedCompany := domain.Tenant{
|
||||
ID: excludedCompanyID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
excludedOrg := domain.Tenant{
|
||||
ID: excludedOrgID,
|
||||
Slug: "excluded-team",
|
||||
Name: "제외팀",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &excludedCompanyID,
|
||||
}
|
||||
user := domain.User{
|
||||
ID: "excluded-user",
|
||||
Email: "excluded@samaneng.com",
|
||||
Name: "Excluded User",
|
||||
TenantID: &excludedOrgID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{
|
||||
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
|
||||
list: []domain.Tenant{root, excludedCompany, excludedOrg},
|
||||
},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{user.ID: user}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, service.EnqueueTenantUpsertIfInScope(context.Background(), excludedOrg))
|
||||
require.NoError(t, service.EnqueueTenantDeleteIfInScope(context.Background(), excludedOrg))
|
||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
@@ -1751,7 +2051,23 @@ func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
|
||||
return f.byTenant, nil
|
||||
if len(tenantIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
allowed := make(map[string]bool, len(tenantIDs))
|
||||
for _, tenantID := range tenantIDs {
|
||||
allowed[tenantID] = true
|
||||
}
|
||||
users := make([]domain.User, 0, len(f.byTenant))
|
||||
for _, user := range f.byTenant {
|
||||
if user.TenantID == nil {
|
||||
continue
|
||||
}
|
||||
if allowed[*user.TenantID] {
|
||||
users = append(users, user)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
||||
|
||||
Reference in New Issue
Block a user