forked from baron/baron-sso
feat: improve Worksmobile tenant sync handling
This commit is contained in:
@@ -349,6 +349,7 @@ func main() {
|
||||
}
|
||||
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupService.SetWorksmobileSyncer(worksmobileService)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
|
||||
hydraService := service.NewHydraAdminService()
|
||||
@@ -759,6 +760,7 @@ func main() {
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
||||
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization")
|
||||
|
||||
@@ -37,6 +37,7 @@ type InitialTenantConfig struct {
|
||||
ParentSlug string
|
||||
Description string
|
||||
Domains []string
|
||||
Config domain.JSONMap
|
||||
}
|
||||
|
||||
func SeedTenants(db *gorm.DB) error {
|
||||
@@ -149,6 +150,9 @@ func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
|
||||
return err
|
||||
}
|
||||
tenant.Status = domain.TenantStatusActive
|
||||
if len(config.Config) > 0 {
|
||||
tenant.Config = config.Config
|
||||
}
|
||||
if err := db.Save(tenant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -265,6 +269,11 @@ func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
|
||||
return nil, fmt.Errorf("row %d: slug is required", i+2)
|
||||
}
|
||||
|
||||
config, err := seedTenantCSVRecordConfig(row, header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d: %w", i+2, err)
|
||||
}
|
||||
|
||||
configs = append(configs, InitialTenantConfig{
|
||||
TenantID: seedTenantCSVValue(row, header, "tenant_id"),
|
||||
Name: name,
|
||||
@@ -273,6 +282,7 @@ func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
|
||||
Slug: slug,
|
||||
Description: seedTenantCSVValue(row, header, "memo"),
|
||||
Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")),
|
||||
Config: config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -298,6 +308,18 @@ func seedTenantCSVHeaderIndex(header []string) map[string]int {
|
||||
"email_domain": "email_domain",
|
||||
"domain": "email_domain",
|
||||
"domains": "email_domain",
|
||||
"visibility": "visibility",
|
||||
"public_setting": "visibility",
|
||||
"publicsetting": "visibility",
|
||||
"org_unit_type": "org_unit_type",
|
||||
"orgunittype": "org_unit_type",
|
||||
"organization_type": "org_unit_type",
|
||||
"organizationtype": "org_unit_type",
|
||||
"worksmobile": "worksmobile_sync",
|
||||
"worksmobilesync": "worksmobile_sync",
|
||||
"worksmobile_sync": "worksmobile_sync",
|
||||
"works_sync": "worksmobile_sync",
|
||||
"works": "worksmobile_sync",
|
||||
}
|
||||
for i, column := range header {
|
||||
key := strings.ToLower(strings.TrimSpace(column))
|
||||
@@ -317,6 +339,67 @@ func seedTenantCSVValue(row []string, header map[string]int, key string) string
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
func seedTenantCSVRecordConfig(row []string, header map[string]int) (domain.JSONMap, error) {
|
||||
config := domain.JSONMap{}
|
||||
visibility := strings.TrimSpace(seedTenantCSVValue(row, header, "visibility"))
|
||||
if visibility != "" {
|
||||
normalizedVisibility, err := normalizeSeedTenantVisibility(visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config["visibility"] = normalizedVisibility
|
||||
}
|
||||
orgUnitType := strings.TrimSpace(seedTenantCSVValue(row, header, "org_unit_type"))
|
||||
if orgUnitType != "" {
|
||||
if !isAllowedSeedTenantOrgUnitType(orgUnitType) {
|
||||
return nil, errors.New("orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속")
|
||||
}
|
||||
config["orgUnitType"] = orgUnitType
|
||||
}
|
||||
if worksmobileSync := strings.TrimSpace(seedTenantCSVValue(row, header, "worksmobile_sync")); worksmobileSync != "" {
|
||||
excluded, err := normalizeSeedTenantWorksmobileExcluded(worksmobileSync)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config["worksmobileExcluded"] = excluded
|
||||
}
|
||||
if len(config) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func normalizeSeedTenantWorksmobileExcluded(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
|
||||
return false, nil
|
||||
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("worksmobile_sync must be yes or no")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSeedTenantVisibility(value string) (string, error) {
|
||||
visibility := strings.ToLower(strings.TrimSpace(value))
|
||||
if visibility == "" || visibility == "public" {
|
||||
return "public", nil
|
||||
}
|
||||
if visibility != "internal" && visibility != "private" {
|
||||
return "", errors.New("visibility must be public, internal, or private")
|
||||
}
|
||||
return visibility, nil
|
||||
}
|
||||
|
||||
func isAllowedSeedTenantOrgUnitType(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func seedTenantCSVRowIsEmpty(row []string) bool {
|
||||
for _, value := range row {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -405,6 +488,7 @@ func createSeedTenant(
|
||||
Description: config.Description,
|
||||
Status: domain.TenantStatusActive,
|
||||
ParentID: parentID,
|
||||
Config: config.Config,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, tenant); err != nil {
|
||||
|
||||
@@ -61,6 +61,7 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
slug: "baron-group",
|
||||
tenantType: domain.TenantTypeCompanyGroup,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"brsw.kr"},
|
||||
},
|
||||
{
|
||||
name: "(주)장헌",
|
||||
@@ -78,10 +79,10 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "한라산업개발",
|
||||
slug: "hanlla",
|
||||
slug: "halla",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"hanllasanup.co.kr"},
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"hallasanup.com"},
|
||||
},
|
||||
{
|
||||
name: "(주)피티씨",
|
||||
@@ -97,30 +98,64 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if len(configs) != len(expected) {
|
||||
t.Fatalf("expected %d seed tenants, got %d", len(expected), len(configs))
|
||||
if len(configs) < len(expected) {
|
||||
t.Fatalf("expected at least %d seed tenants, got %d", len(expected), len(configs))
|
||||
}
|
||||
|
||||
for i, want := range expected {
|
||||
got := configs[i]
|
||||
wantFamilyChildOrder := []string{
|
||||
"gpdtdc",
|
||||
"saman",
|
||||
"hanmac",
|
||||
"baron-group",
|
||||
"halla",
|
||||
}
|
||||
policyFamilyChildSlugs := map[string]bool{}
|
||||
for _, slug := range wantFamilyChildOrder {
|
||||
policyFamilyChildSlugs[slug] = true
|
||||
}
|
||||
gotFamilyChildOrder := make([]string, 0, len(wantFamilyChildOrder))
|
||||
for _, config := range configs {
|
||||
if config.ParentSlug == "hanmac-family" && policyFamilyChildSlugs[config.Slug] {
|
||||
gotFamilyChildOrder = append(gotFamilyChildOrder, config.Slug)
|
||||
}
|
||||
}
|
||||
if len(gotFamilyChildOrder) != len(wantFamilyChildOrder) {
|
||||
t.Fatalf("hanmac-family child order = %#v, want %#v", gotFamilyChildOrder, wantFamilyChildOrder)
|
||||
}
|
||||
for i, wantSlug := range wantFamilyChildOrder {
|
||||
if gotFamilyChildOrder[i] != wantSlug {
|
||||
t.Fatalf("hanmac-family child order[%d] = %q, want %q", i, gotFamilyChildOrder[i], wantSlug)
|
||||
}
|
||||
}
|
||||
|
||||
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||
for _, config := range configs {
|
||||
configBySlug[config.Slug] = config
|
||||
}
|
||||
|
||||
for _, want := range expected {
|
||||
got, ok := configBySlug[want.slug]
|
||||
if !ok {
|
||||
t.Fatalf("tenant slug %q not found in seed configs", want.slug)
|
||||
}
|
||||
if got.Name != want.name {
|
||||
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
|
||||
t.Fatalf("tenant[%s] name = %q, want %q", want.slug, got.Name, want.name)
|
||||
}
|
||||
if got.Slug != want.slug {
|
||||
t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug)
|
||||
t.Fatalf("tenant[%s] slug = %q, want %q", want.slug, got.Slug, want.slug)
|
||||
}
|
||||
if got.Type != want.tenantType {
|
||||
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
|
||||
t.Fatalf("tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
|
||||
}
|
||||
if got.ParentSlug != want.parentSlug {
|
||||
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, got.ParentSlug, want.parentSlug)
|
||||
t.Fatalf("tenant[%s] parent slug = %q, want %q", want.slug, got.ParentSlug, want.parentSlug)
|
||||
}
|
||||
if len(got.Domains) != len(want.domains) {
|
||||
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
|
||||
t.Fatalf("tenant[%s] domains = %#v, want %#v", want.slug, got.Domains, want.domains)
|
||||
}
|
||||
for j, wantDomain := range want.domains {
|
||||
if got.Domains[j] != wantDomain {
|
||||
t.Fatalf("tenant[%d] domain[%d] = %q, want %q", i, j, got.Domains[j], wantDomain)
|
||||
t.Fatalf("tenant[%s] domain[%d] = %q, want %q", want.slug, j, got.Domains[j], wantDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,9 +170,9 @@ func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
|
||||
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seed-tenant.csv")
|
||||
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
|
||||
"Root,COMPANY_GROUP,,root,Root memo,\n" +
|
||||
"Child,COMPANY,root,child,Child memo,child.example.com\n"
|
||||
csv := "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
|
||||
"Root,COMPANY_GROUP,,root,Root memo,,,,\n" +
|
||||
"Child,USER_GROUP,root,child,Child memo,child.example.com,private,팀,no\n"
|
||||
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
|
||||
t.Fatalf("failed to write seed csv: %v", err)
|
||||
}
|
||||
@@ -156,6 +191,41 @@ func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
|
||||
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
|
||||
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
|
||||
}
|
||||
if configs[1].Config["visibility"] != "private" {
|
||||
t.Fatalf("child visibility = %#v, want private", configs[1].Config["visibility"])
|
||||
}
|
||||
if configs[1].Config["orgUnitType"] != "팀" {
|
||||
t.Fatalf("child orgUnitType = %#v, want 팀", configs[1].Config["orgUnitType"])
|
||||
}
|
||||
if configs[1].Config["worksmobileExcluded"] != true {
|
||||
t.Fatalf("child worksmobileExcluded = %#v, want true", configs[1].Config["worksmobileExcluded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTenantCSVDefinesMHDAsPrivateUserGroup(t *testing.T) {
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||
}
|
||||
|
||||
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||
for _, config := range configs {
|
||||
configBySlug[config.Slug] = config
|
||||
}
|
||||
|
||||
mhd, ok := configBySlug["mhd"]
|
||||
if !ok {
|
||||
t.Fatal("mhd seed tenant not found")
|
||||
}
|
||||
if mhd.Type != domain.TenantTypeUserGroup {
|
||||
t.Fatalf("mhd type = %q, want %q", mhd.Type, domain.TenantTypeUserGroup)
|
||||
}
|
||||
if mhd.Config["visibility"] != "private" {
|
||||
t.Fatalf("mhd visibility = %#v, want private", mhd.Config["visibility"])
|
||||
}
|
||||
if mhd.Config["worksmobileExcluded"] != true {
|
||||
t.Fatalf("mhd worksmobileExcluded = %#v, want true", mhd.Config["worksmobileExcluded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
|
||||
|
||||
@@ -117,16 +117,18 @@ type tenantDomainConflict struct {
|
||||
}
|
||||
|
||||
type tenantCSVRecord struct {
|
||||
TenantID string
|
||||
Name string
|
||||
Type string
|
||||
ParentTenantID *string
|
||||
ParentTenantSlug string
|
||||
Slug string
|
||||
Memo string
|
||||
Domains []string
|
||||
Visibility string
|
||||
OrgUnitType string
|
||||
TenantID string
|
||||
Name string
|
||||
Type string
|
||||
ParentTenantID *string
|
||||
ParentTenantSlug string
|
||||
Slug string
|
||||
Memo string
|
||||
Domains []string
|
||||
Visibility string
|
||||
OrgUnitType string
|
||||
WorksmobileSync string
|
||||
WorksmobileSyncSet bool
|
||||
}
|
||||
|
||||
type orgContextTenant struct {
|
||||
@@ -420,10 +422,10 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
writer := csv.NewWriter(&buf)
|
||||
includeIDs := includeCSVIds(c)
|
||||
if includeIDs {
|
||||
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil {
|
||||
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil {
|
||||
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
slugByID := make(map[string]string, len(allTenants))
|
||||
@@ -444,7 +446,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
domains = append(domains, domainName)
|
||||
}
|
||||
}
|
||||
visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config)
|
||||
visibility, orgUnitType, worksmobileSync := tenantCSVOrgConfigValues(tenant.Config)
|
||||
row := []string{
|
||||
tenant.Name,
|
||||
tenant.Type,
|
||||
@@ -454,6 +456,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
strings.Join(domains, ";"),
|
||||
visibility,
|
||||
orgUnitType,
|
||||
worksmobileSync,
|
||||
}
|
||||
if includeIDs {
|
||||
row = []string{
|
||||
@@ -467,6 +470,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
strings.Join(domains, ";"),
|
||||
visibility,
|
||||
orgUnitType,
|
||||
worksmobileSync,
|
||||
}
|
||||
}
|
||||
if err := writer.Write(row); err != nil {
|
||||
@@ -683,17 +687,20 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
||||
parentID = &parentValue
|
||||
}
|
||||
|
||||
worksmobileSync, worksmobileSyncSet := tenantCSVWorksmobileSyncValue(row, header)
|
||||
records = append(records, tenantCSVRecord{
|
||||
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
||||
Name: name,
|
||||
Type: tenantType,
|
||||
ParentTenantID: parentID,
|
||||
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
|
||||
Slug: slug,
|
||||
Memo: tenantCSVValue(row, header, "memo"),
|
||||
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
||||
Visibility: tenantCSVValue(row, header, "visibility"),
|
||||
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
|
||||
TenantID: tenantCSVValue(row, header, "tenant_id"),
|
||||
Name: name,
|
||||
Type: tenantType,
|
||||
ParentTenantID: parentID,
|
||||
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
|
||||
Slug: slug,
|
||||
Memo: tenantCSVValue(row, header, "memo"),
|
||||
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
|
||||
Visibility: tenantCSVValue(row, header, "visibility"),
|
||||
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
|
||||
WorksmobileSync: worksmobileSync,
|
||||
WorksmobileSyncSet: worksmobileSyncSet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -703,35 +710,42 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
|
||||
func tenantCSVHeaderIndex(header []string) map[string]int {
|
||||
index := make(map[string]int, len(header))
|
||||
aliases := map[string]string{
|
||||
"id": "tenant_id",
|
||||
"tenantid": "tenant_id",
|
||||
"tenant_id": "tenant_id",
|
||||
"name": "name",
|
||||
"type": "type",
|
||||
"parentid": "parent_tenant_id",
|
||||
"parent_id": "parent_tenant_id",
|
||||
"parenttenantid": "parent_tenant_id",
|
||||
"parent_tenant_id": "parent_tenant_id",
|
||||
"parenttenantslug": "parent_tenant_slug",
|
||||
"parent_tenant_slug": "parent_tenant_slug",
|
||||
"slug": "slug",
|
||||
"memo": "memo",
|
||||
"description": "memo",
|
||||
"email-domain": "email_domain",
|
||||
"emaildomain": "email_domain",
|
||||
"email_domain": "email_domain",
|
||||
"domain": "email_domain",
|
||||
"domains": "email_domain",
|
||||
"visibility": "visibility",
|
||||
"public_setting": "visibility",
|
||||
"publicsetting": "visibility",
|
||||
"orgunittype": "org_unit_type",
|
||||
"org_unit_type": "org_unit_type",
|
||||
"org-unit-type": "org_unit_type",
|
||||
"organizationtype": "org_unit_type",
|
||||
"organization_type": "org_unit_type",
|
||||
"orgtype": "org_unit_type",
|
||||
"org_type": "org_unit_type",
|
||||
"id": "tenant_id",
|
||||
"tenantid": "tenant_id",
|
||||
"tenant_id": "tenant_id",
|
||||
"name": "name",
|
||||
"type": "type",
|
||||
"parentid": "parent_tenant_id",
|
||||
"parent_id": "parent_tenant_id",
|
||||
"parenttenantid": "parent_tenant_id",
|
||||
"parent_tenant_id": "parent_tenant_id",
|
||||
"parenttenantslug": "parent_tenant_slug",
|
||||
"parent_tenant_slug": "parent_tenant_slug",
|
||||
"slug": "slug",
|
||||
"memo": "memo",
|
||||
"description": "memo",
|
||||
"email-domain": "email_domain",
|
||||
"emaildomain": "email_domain",
|
||||
"email_domain": "email_domain",
|
||||
"domain": "email_domain",
|
||||
"domains": "email_domain",
|
||||
"visibility": "visibility",
|
||||
"public_setting": "visibility",
|
||||
"publicsetting": "visibility",
|
||||
"orgunittype": "org_unit_type",
|
||||
"org_unit_type": "org_unit_type",
|
||||
"org-unit-type": "org_unit_type",
|
||||
"organizationtype": "org_unit_type",
|
||||
"organization_type": "org_unit_type",
|
||||
"orgtype": "org_unit_type",
|
||||
"org_type": "org_unit_type",
|
||||
"worksmobile": "worksmobile_sync",
|
||||
"worksmobilesync": "worksmobile_sync",
|
||||
"worksmobile_sync": "worksmobile_sync",
|
||||
"works_sync": "worksmobile_sync",
|
||||
"works": "worksmobile_sync",
|
||||
"worksmobileexcluded": "worksmobile_excluded",
|
||||
"worksmobile_excluded": "worksmobile_excluded",
|
||||
}
|
||||
for i, column := range header {
|
||||
key := strings.ToLower(strings.TrimSpace(column))
|
||||
@@ -751,6 +765,28 @@ func tenantCSVValue(row []string, header map[string]int, key string) string {
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
func tenantCSVWorksmobileSyncValue(row []string, header map[string]int) (string, bool) {
|
||||
if _, ok := header["worksmobile_sync"]; ok {
|
||||
value := tenantCSVValue(row, header, "worksmobile_sync")
|
||||
if value == "" {
|
||||
return "yes", true
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
if _, ok := header["worksmobile_excluded"]; ok {
|
||||
value := tenantCSVValue(row, header, "worksmobile_excluded")
|
||||
excluded, err := normalizeTenantWorksmobileExcluded(value)
|
||||
if err == nil && excluded {
|
||||
return "no", true
|
||||
}
|
||||
if err == nil {
|
||||
return "yes", true
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func tenantCSVRowIsEmpty(row []string) bool {
|
||||
for _, value := range row {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -872,11 +908,38 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
|
||||
normalized[key] = orgUnitType
|
||||
continue
|
||||
}
|
||||
if key == "worksmobileExcluded" {
|
||||
excluded, err := normalizeTenantWorksmobileExcluded(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized[key] = excluded
|
||||
continue
|
||||
}
|
||||
normalized[key] = value
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTenantWorksmobileExcluded(value any) (bool, error) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, nil
|
||||
case string:
|
||||
normalized := strings.ToLower(strings.TrimSpace(typed))
|
||||
switch normalized {
|
||||
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
|
||||
return false, nil
|
||||
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("worksmobile_sync must be yes or no")
|
||||
}
|
||||
default:
|
||||
return false, fmt.Errorf("worksmobile_sync must be yes or no")
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedOrgUnitType(value string) bool {
|
||||
switch value {
|
||||
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
|
||||
@@ -948,10 +1011,14 @@ func tenantVisibility(config domain.JSONMap) string {
|
||||
}
|
||||
}
|
||||
|
||||
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string) {
|
||||
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string, string) {
|
||||
visibility := tenantVisibility(config)
|
||||
orgUnitType, _ := config["orgUnitType"].(string)
|
||||
return visibility, strings.TrimSpace(orgUnitType)
|
||||
worksmobileSync := "yes"
|
||||
if excluded, err := normalizeTenantWorksmobileExcluded(config["worksmobileExcluded"]); err == nil && excluded {
|
||||
worksmobileSync = "no"
|
||||
}
|
||||
return visibility, strings.TrimSpace(orgUnitType), worksmobileSync
|
||||
}
|
||||
|
||||
func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
|
||||
@@ -962,6 +1029,9 @@ func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
|
||||
if strings.TrimSpace(record.OrgUnitType) != "" {
|
||||
config["orgUnitType"] = record.OrgUnitType
|
||||
}
|
||||
if record.WorksmobileSyncSet {
|
||||
config["worksmobileExcluded"] = record.WorksmobileSync
|
||||
}
|
||||
if len(config) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -2319,7 +2389,7 @@ func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
|
||||
for _, domain := range tenant.Domains {
|
||||
domains = append(domains, domain.Domain)
|
||||
}
|
||||
visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config)
|
||||
visibility, orgUnitType, _ := tenantCSVOrgConfigValues(tenant.Config)
|
||||
return orgContextTenant{
|
||||
ID: tenant.ID,
|
||||
Type: tenant.Type,
|
||||
|
||||
@@ -991,8 +991,8 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
|
||||
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
|
||||
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터")
|
||||
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
|
||||
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터,yes")
|
||||
}
|
||||
|
||||
func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) {
|
||||
@@ -1027,7 +1027,7 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
|
||||
text := string(body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
|
||||
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
|
||||
assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,")
|
||||
assert.NotContains(t, text, "tenant_id")
|
||||
assert.NotContains(t, text, "parent_tenant_id")
|
||||
@@ -1114,7 +1114,7 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
|
||||
text := string(body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
|
||||
assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
|
||||
assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,")
|
||||
assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,")
|
||||
assert.NotContains(t, text, unrelatedID)
|
||||
@@ -1309,8 +1309,8 @@ func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
|
||||
|
||||
func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
|
||||
records, err := parseTenantCSVRecords(strings.NewReader(
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n" +
|
||||
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터\n",
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
|
||||
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터,no\n",
|
||||
))
|
||||
|
||||
assert.NoError(t, err)
|
||||
@@ -1318,6 +1318,10 @@ func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains)
|
||||
assert.Equal(t, "internal", records[0].Visibility)
|
||||
assert.Equal(t, "센터", records[0].OrgUnitType)
|
||||
assert.Equal(t, "no", records[0].WorksmobileSync)
|
||||
config, err := tenantCSVRecordConfig(records[0])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, config["worksmobileExcluded"])
|
||||
}
|
||||
|
||||
func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) {
|
||||
@@ -1378,13 +1382,15 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
|
||||
|
||||
func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
|
||||
config, err := normalizeTenantConfig(map[string]any{
|
||||
"visibility": "internal",
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "internal",
|
||||
"orgUnitType": "센터",
|
||||
"worksmobileExcluded": true,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "internal", config["visibility"])
|
||||
assert.Equal(t, "센터", config["orgUnitType"])
|
||||
assert.Equal(t, true, config["worksmobileExcluded"])
|
||||
}
|
||||
|
||||
func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -53,6 +54,8 @@ func (m *MockUserGroupService) List(ctx context.Context, tenantID string) ([]dom
|
||||
return args.Get(0).([]domain.UserGroup), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserGroupService) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {}
|
||||
|
||||
func (m *MockUserGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||
return m.Called(ctx, groupID, userID).Error(0)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,70 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
|
||||
if tenantService == nil || metadata == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cleared := false
|
||||
clearMetadataPrimary := func() {
|
||||
delete(metadata, "primaryTenantId")
|
||||
delete(metadata, "primaryTenantSlug")
|
||||
delete(metadata, "primaryTenantName")
|
||||
delete(metadata, "primaryTenantIsOwner")
|
||||
cleared = true
|
||||
}
|
||||
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
|
||||
clearMetadataPrimary()
|
||||
}
|
||||
|
||||
clearAppointment := func(appointment map[string]any) {
|
||||
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
|
||||
return
|
||||
}
|
||||
tenantID := normalizeMetadataString(appointment["tenantId"])
|
||||
tenantSlug := normalizeMetadataString(appointment["tenantSlug"])
|
||||
if tenantSlug == "" {
|
||||
tenantSlug = normalizeMetadataString(appointment["slug"])
|
||||
}
|
||||
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
|
||||
return
|
||||
}
|
||||
appointment["isPrimary"] = false
|
||||
appointment["primary"] = false
|
||||
appointment["representative"] = false
|
||||
appointment["isRepresentative"] = false
|
||||
clearMetadataPrimary()
|
||||
}
|
||||
|
||||
for _, appointment := range appointments {
|
||||
clearAppointment(appointment)
|
||||
}
|
||||
if rawAppointments, ok := metadata["additionalAppointments"].([]any); ok {
|
||||
for _, rawAppointment := range rawAppointments {
|
||||
if appointment, ok := rawAppointment.(map[string]any); ok {
|
||||
clearAppointment(appointment)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleared, nil
|
||||
}
|
||||
|
||||
func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
|
||||
var tenant *domain.Tenant
|
||||
var err error
|
||||
if strings.TrimSpace(tenantID) != "" {
|
||||
tenant, err = tenantService.GetTenant(ctx, strings.TrimSpace(tenantID))
|
||||
} else if strings.TrimSpace(tenantSlug) != "" {
|
||||
tenant, err = tenantService.GetTenantBySlug(ctx, strings.TrimSpace(tenantSlug))
|
||||
}
|
||||
if err != nil || tenant == nil {
|
||||
return false
|
||||
}
|
||||
visibility := tenantVisibility(tenant.Config)
|
||||
return visibility == "internal" || visibility == "private"
|
||||
}
|
||||
|
||||
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
|
||||
if value := strings.TrimSpace(primaryTenantID); value != "" {
|
||||
return value
|
||||
@@ -651,6 +715,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
req.CompanyCode = tenantSlug
|
||||
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
||||
representativeCleared := false
|
||||
if h.TenantService != nil {
|
||||
cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
representativeCleared = cleared
|
||||
if cleared {
|
||||
req.PrimaryTenantID = ""
|
||||
req.PrimaryTenantName = ""
|
||||
req.PrimaryTenantIsOwner = nil
|
||||
req.CompanyCode = ""
|
||||
}
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if email == "" {
|
||||
@@ -725,7 +803,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
// [Resolve TenantID and Custom Login IDs before Kratos creation]
|
||||
var tenantID string
|
||||
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments)
|
||||
primaryAppointments := req.AdditionalAppointments
|
||||
if representativeCleared {
|
||||
primaryAppointments = nil
|
||||
}
|
||||
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, primaryAppointments)
|
||||
if req.CompanyCode == "" && h.TenantService != nil {
|
||||
if requestedPrimaryTenantID != "" {
|
||||
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
|
||||
@@ -1995,6 +2077,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
req.CompanyCode = tenantSlug
|
||||
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
||||
if h.TenantService != nil {
|
||||
cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
if cleared {
|
||||
req.PrimaryTenantID = ""
|
||||
req.PrimaryTenantName = ""
|
||||
req.PrimaryTenantIsOwner = nil
|
||||
req.CompanyCode = nil
|
||||
}
|
||||
}
|
||||
if req.Role != nil {
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
||||
|
||||
@@ -205,6 +205,49 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
|
||||
assert.Contains(t, metadata, "userType")
|
||||
}
|
||||
|
||||
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
internalTenantID := "internal-tenant"
|
||||
publicTenantID := "public-tenant"
|
||||
metadata := map[string]any{
|
||||
"primaryTenantId": internalTenantID,
|
||||
"primaryTenantName": "비공개팀",
|
||||
"primaryTenantSlug": "private-team",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
|
||||
map[string]any{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
|
||||
},
|
||||
}
|
||||
appointments := []map[string]any{
|
||||
{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
|
||||
{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
|
||||
}
|
||||
|
||||
mockTenant.On("GetTenant", mock.Anything, internalTenantID).Return(&domain.Tenant{
|
||||
ID: internalTenantID,
|
||||
Slug: "private-team",
|
||||
Config: domain.JSONMap{"visibility": "private"},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, publicTenantID).Return(&domain.Tenant{
|
||||
ID: publicTenantID,
|
||||
Slug: "public-team",
|
||||
Config: domain.JSONMap{"visibility": "public"},
|
||||
}, nil).Maybe()
|
||||
|
||||
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cleared)
|
||||
assert.NotContains(t, metadata, "primaryTenantId")
|
||||
assert.NotContains(t, metadata, "primaryTenantName")
|
||||
assert.NotContains(t, metadata, "primaryTenantSlug")
|
||||
assert.Equal(t, false, appointments[0]["isPrimary"])
|
||||
metadataAppointments := metadata["additionalAppointments"].([]any)
|
||||
firstAppointment := metadataAppointments[0].(map[string]any)
|
||||
assert.Equal(t, false, firstAppointment["isPrimary"])
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
type MockTenantServiceForUser struct {
|
||||
mock.Mock
|
||||
service.TenantService
|
||||
|
||||
@@ -105,6 +105,14 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
||||
return c.JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DeletePendingJobs(c *fiber.Ctx) error {
|
||||
result, err := h.Service.DeletePendingJobs(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "delete_pending_jobs")
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
|
||||
if err != nil {
|
||||
|
||||
@@ -153,6 +153,24 @@ func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
|
||||
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerDeletesPendingJobs(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{
|
||||
pendingJobsDeleteResult: service.WorksmobilePendingJobDeleteResult{DeletedCount: 3},
|
||||
}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Delete("/tenants/:tenantId/worksmobile/jobs/pending", h.DeletePendingJobs)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/jobs/pending", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "hanmac-id", fakeService.deletedPendingJobsTenantID)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), `"deletedCount":3`)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
previous := slog.Default()
|
||||
@@ -184,6 +202,8 @@ type fakeWorksmobileAdminService struct {
|
||||
resetPasswordCredentialBatchID string
|
||||
downloadCredentialBatchID string
|
||||
deletedCredentialBatchID string
|
||||
deletedPendingJobsTenantID string
|
||||
pendingJobsDeleteResult service.WorksmobilePendingJobDeleteResult
|
||||
credentialBatches []service.WorksmobileCredentialBatch
|
||||
}
|
||||
|
||||
@@ -237,3 +257,8 @@ func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context
|
||||
f.deletedCredentialBatchID = credentialBatchID
|
||||
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) DeletePendingJobs(ctx context.Context, tenantID string) (service.WorksmobilePendingJobDeleteResult, error) {
|
||||
f.deletedPendingJobsTenantID = tenantID
|
||||
return f.pendingJobsDeleteResult, nil
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
// Auto-migrate
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{})
|
||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to migrate database: %s", err)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ type WorksmobileOutboxRepository interface {
|
||||
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
|
||||
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
|
||||
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
|
||||
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
||||
MarkRetry(ctx context.Context, id string) error
|
||||
MarkProcessing(ctx context.Context, id string) error
|
||||
MarkProcessing(ctx context.Context, id string) (bool, error)
|
||||
MarkProcessed(ctx context.Context, id string) error
|
||||
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
|
||||
}
|
||||
@@ -76,16 +77,88 @@ func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id stri
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
|
||||
result := r.db.WithContext(ctx).
|
||||
Where("status = ? AND payload ->> 'tenantRootId' = ?", domain.WorksmobileOutboxStatusPending, tenantRootID).
|
||||
Delete(&domain.WorksmobileOutbox{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []domain.WorksmobileOutbox
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.WorksmobileOutboxStatusPending, time.Now()).
|
||||
Order("created_at asc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
WITH RECURSIVE candidates AS (
|
||||
SELECT
|
||||
*,
|
||||
NULLIF(payload #>> '{request,orgUnitExternalKey}', '') AS org_external_key,
|
||||
CASE
|
||||
WHEN payload #>> '{request,parentOrgUnitId}' LIKE 'externalKey:%'
|
||||
THEN NULLIF(substr(payload #>> '{request,parentOrgUnitId}', length('externalKey:') + 1), '')
|
||||
ELSE ''
|
||||
END AS parent_external_key
|
||||
FROM worksmobile_outboxes
|
||||
WHERE status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
||||
),
|
||||
ready AS (
|
||||
SELECT candidates.*
|
||||
FROM candidates
|
||||
WHERE NOT (
|
||||
candidates.resource_type = ?
|
||||
AND candidates.action = ?
|
||||
AND candidates.parent_external_key <> ''
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM worksmobile_outboxes parent_job
|
||||
WHERE parent_job.resource_type = ?
|
||||
AND parent_job.action = ?
|
||||
AND parent_job.status <> ?
|
||||
AND NULLIF(parent_job.payload #>> '{request,orgUnitExternalKey}', '') = candidates.parent_external_key
|
||||
)
|
||||
)
|
||||
),
|
||||
org_depth AS (
|
||||
SELECT id, org_external_key, parent_external_key, 0 AS depth
|
||||
FROM ready
|
||||
UNION ALL
|
||||
SELECT child.id, child.org_external_key, child.parent_external_key, parent.depth + 1
|
||||
FROM ready child
|
||||
JOIN org_depth parent ON child.parent_external_key = parent.org_external_key
|
||||
WHERE child.resource_type = ? AND child.action = ? AND parent.depth < 64
|
||||
)
|
||||
SELECT ready.*
|
||||
FROM ready
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(depth) AS dependency_depth
|
||||
FROM org_depth
|
||||
WHERE org_depth.id = ready.id
|
||||
) AS depth_rank ON true
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN ready.resource_type = ? AND ready.action = ? THEN 0
|
||||
WHEN ready.resource_type = ? THEN 1
|
||||
ELSE 2
|
||||
END ASC,
|
||||
COALESCE(depth_rank.dependency_depth, 0) ASC,
|
||||
ready.created_at ASC
|
||||
LIMIT ?
|
||||
`,
|
||||
domain.WorksmobileOutboxStatusPending,
|
||||
time.Now(),
|
||||
domain.WorksmobileResourceOrgUnit,
|
||||
domain.WorksmobileActionUpsert,
|
||||
domain.WorksmobileResourceOrgUnit,
|
||||
domain.WorksmobileActionUpsert,
|
||||
domain.WorksmobileOutboxStatusProcessed,
|
||||
domain.WorksmobileResourceOrgUnit,
|
||||
domain.WorksmobileActionUpsert,
|
||||
domain.WorksmobileResourceOrgUnit,
|
||||
domain.WorksmobileActionUpsert,
|
||||
domain.WorksmobileResourceUser,
|
||||
limit,
|
||||
).Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
@@ -106,11 +179,12 @@ func (r *worksmobileOutboxRepository) MarkRetry(ctx context.Context, id string)
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
|
||||
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) (bool, error) {
|
||||
result := r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
|
||||
"status": domain.WorksmobileOutboxStatusProcessing,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
|
||||
repo := NewWorksmobileOutboxRepository(testDB)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
|
||||
|
||||
rows := []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000101",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-pending",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
DedupeKey: "pending-root",
|
||||
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000102",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-other-root",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
DedupeKey: "pending-other-root",
|
||||
Payload: domain.JSONMap{"tenantRootId": "root-2"},
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000103",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-failed",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
DedupeKey: "failed-root",
|
||||
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000104",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "org-processed",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
DedupeKey: "processed-root",
|
||||
Payload: domain.JSONMap{"tenantRootId": "root-1"},
|
||||
},
|
||||
}
|
||||
for i := range rows {
|
||||
require.NoError(t, repo.Create(ctx, &rows[i]))
|
||||
}
|
||||
|
||||
deleted, err := repo.DeletePendingByTenantRoot(ctx, "root-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), deleted)
|
||||
var remaining []domain.WorksmobileOutbox
|
||||
require.NoError(t, testDB.Order("id asc").Find(&remaining).Error)
|
||||
require.Len(t, remaining, 3)
|
||||
require.Equal(t, "00000000-0000-0000-0000-000000000102", remaining[0].ID)
|
||||
require.Equal(t, "00000000-0000-0000-0000-000000000103", remaining[1].ID)
|
||||
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
|
||||
repo := NewWorksmobileOutboxRepository(testDB)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
|
||||
|
||||
baseTime := time.Date(2026, 6, 2, 15, 21, 0, 0, time.UTC)
|
||||
child := domain.WorksmobileOutbox{
|
||||
ID: "00000000-0000-0000-0000-000000000201",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "child-tenant",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
DedupeKey: "orgunit:upsert:child-tenant",
|
||||
Payload: domain.JSONMap{
|
||||
"request": map[string]any{
|
||||
"orgUnitExternalKey": "child-tenant",
|
||||
"parentOrgUnitId": "externalKey:parent-tenant",
|
||||
},
|
||||
},
|
||||
CreatedAt: baseTime,
|
||||
UpdatedAt: baseTime,
|
||||
}
|
||||
parent := domain.WorksmobileOutbox{
|
||||
ID: "00000000-0000-0000-0000-000000000202",
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: "parent-tenant",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
DedupeKey: "orgunit:upsert:parent-tenant",
|
||||
Payload: domain.JSONMap{
|
||||
"request": map[string]any{
|
||||
"orgUnitExternalKey": "parent-tenant",
|
||||
},
|
||||
},
|
||||
CreatedAt: baseTime.Add(time.Second),
|
||||
UpdatedAt: baseTime.Add(time.Second),
|
||||
}
|
||||
require.NoError(t, testDB.Create(&child).Error)
|
||||
require.NoError(t, testDB.Create(&parent).Error)
|
||||
|
||||
rows, err := repo.ListReady(ctx, 10)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.Equal(t, "parent-tenant", rows[0].ResourceID)
|
||||
|
||||
require.NoError(t, repo.MarkProcessed(ctx, parent.ID))
|
||||
rows, err = repo.ListReady(ctx, 10)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.Equal(t, "child-tenant", rows[0].ResourceID)
|
||||
}
|
||||
@@ -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