1
0
forked from baron/baron-sso

네이버 계정 정합성 맞춤

This commit is contained in:
2026-06-15 19:54:09 +09:00
parent 8e9d015443
commit 4d468cd39f
97 changed files with 5837 additions and 2031 deletions

View File

@@ -29,6 +29,9 @@ func Run(db *gorm.DB) error {
if err := SanitizeLegacyUserMetadata(db); err != nil {
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
}
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
return fmt.Errorf("user appointment tenant canonicalization failed: %w", err)
}
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
slog.Info("[Bootstrap] Bootstrap completed successfully.")
@@ -50,7 +53,6 @@ func migrateSchemas(db *gorm.DB) error {
&domain.TenantDomain{},
&domain.User{},
&domain.UserLoginID{},
&domain.UserProjectionState{},
&domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},

View File

@@ -15,6 +15,42 @@ where metadata ? 'hanmacFamily'
or metadata ? 'userType'
`
const canonicalizeUserAppointmentTenantsSQL = `
with normalized as (
select
u.id,
jsonb_agg(
case
when jsonb_typeof(item.value) = 'object' and t.id is not null then
item.value || jsonb_build_object(
'tenantId', t.id::text,
'tenantSlug', t.slug,
'tenantName', t.name
)
else item.value
end
order by item.ordinality
) as appointments
from users u
cross join lateral jsonb_array_elements(u.metadata -> 'additionalAppointments') with ordinality as item(value, ordinality)
left join tenants t on t.id::text = item.value ->> 'tenantId'
and t.deleted_at is null
where jsonb_typeof(u.metadata -> 'additionalAppointments') = 'array'
group by u.id
),
changed as (
select u.id, normalized.appointments
from users u
join normalized on normalized.id = u.id
where u.metadata -> 'additionalAppointments' is distinct from normalized.appointments
)
update users u
set metadata = jsonb_set(u.metadata, '{additionalAppointments}', changed.appointments, true),
updated_at = now()
from changed
where changed.id = u.id
`
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
if db == nil {
@@ -32,3 +68,21 @@ func SanitizeLegacyUserMetadata(db *gorm.DB) error {
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
return nil
}
// CanonicalizeUserAppointmentTenants rewrites appointment display fields from the tenant UUID source.
func CanonicalizeUserAppointmentTenants(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database is not configured")
}
if !db.Migrator().HasTable("users") || !db.Migrator().HasTable("tenants") {
slog.Info("[Bootstrap] User appointment tenant canonicalization skipped because required tables do not exist")
return nil
}
result := db.Exec(canonicalizeUserAppointmentTenantsSQL)
if result.Error != nil {
return fmt.Errorf("canonicalize user appointment tenants: %w", result.Error)
}
slog.Info("[Bootstrap] User appointment tenant metadata canonicalized", "rowsAffected", result.RowsAffected)
return nil
}

View File

@@ -114,6 +114,89 @@ func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
}
}
func TestCanonicalizeUserAppointmentTenantsUsesTenantUUID(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}); err != nil {
t.Fatalf("failed to migrate users and tenants tables: %v", err)
}
tenant := domain.Tenant{
ID: "30000000-0000-0000-0000-000000000101",
Type: domain.TenantTypeOrganization,
Name: "통합시스템",
Slug: "intigrated-system",
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
user := domain.User{
ID: "30000000-0000-0000-0000-000000000201",
Email: "appointment@example.com",
Name: "Appointment User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenant.ID,
"tenantSlug": "tech-planning",
"tenantName": "기술기획",
"grade": "연구원",
},
map[string]any{
"tenantId": "30000000-0000-0000-0000-000000000999",
"tenantSlug": "unknown-old",
"tenantName": "Unknown Old",
},
},
},
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
t.Fatalf("CanonicalizeUserAppointmentTenants returned error: %v", err)
}
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
t.Fatalf("CanonicalizeUserAppointmentTenants must be idempotent: %v", err)
}
var got domain.User
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to load canonicalized user: %v", err)
}
appointments, ok := got.Metadata["additionalAppointments"].([]any)
if !ok || len(appointments) != 2 {
t.Fatalf("additionalAppointments = %#v, want two appointments", got.Metadata["additionalAppointments"])
}
first, ok := appointments[0].(map[string]any)
if !ok {
t.Fatalf("first appointment = %#v, want object", appointments[0])
}
if first["tenantId"] != tenant.ID {
t.Fatalf("tenantId = %#v, want %s", first["tenantId"], tenant.ID)
}
if first["tenantSlug"] != tenant.Slug {
t.Fatalf("tenantSlug = %#v, want %s", first["tenantSlug"], tenant.Slug)
}
if first["tenantName"] != tenant.Name {
t.Fatalf("tenantName = %#v, want %s", first["tenantName"], tenant.Name)
}
if first["grade"] != "연구원" {
t.Fatalf("grade = %#v, want preserved value", first["grade"])
}
second, ok := appointments[1].(map[string]any)
if !ok {
t.Fatalf("second appointment = %#v, want object", appointments[1])
}
if second["tenantSlug"] != "unknown-old" || second["tenantName"] != "Unknown Old" {
t.Fatalf("unknown tenant appointment must be preserved: %#v", second)
}
}
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {

View File

@@ -1,29 +0,0 @@
package domain
import "time"
const (
UserProjectionNameKratos = "kratos_users"
UserProjectionStatusSyncing = "syncing"
UserProjectionStatusReady = "ready"
UserProjectionStatusFailed = "failed"
)
type UserProjectionState struct {
Name string `gorm:"primaryKey;column:name" json:"name"`
Status string `gorm:"column:status;not null" json:"status"`
LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"`
LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
type UserProjectionStatus struct {
Name string `json:"name"`
Status string `json:"status"`
Ready bool `json:"ready"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
ProjectedUsers int64 `json:"projectedUsers"`
}

View File

@@ -24,16 +24,15 @@ type identityCacheAdmin interface {
}
type AdminHandler struct {
DB *gorm.DB
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
UserProjectionRepo repository.UserProjectionRepository
IdentityCache identityCacheAdmin
IntegrityChecker repository.DataIntegrityChecker
DB *gorm.DB
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
IdentityCache identityCacheAdmin
IntegrityChecker repository.DataIntegrityChecker
}
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
@@ -289,20 +288,6 @@ func requireSuperAdminProfile(c *fiber.Ctx) bool {
return true
}
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.UserProjectionRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
}
status, err := h.UserProjectionRepo.GetStatus(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(status)
}
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
@@ -428,14 +413,14 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
}
func (h *AdminHandler) countUsers(ctx context.Context) int64 {
if h == nil || h.UserProjectionRepo == nil {
if h == nil || h.DB == nil {
return 0
}
status, err := h.UserProjectionRepo.GetStatus(ctx)
if err != nil {
var total int64
if err := h.DB.WithContext(ctx).Model(&domain.User{}).Count(&total).Error; err != nil {
return 0
}
return status.ProjectedUsers
return total
}
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {

View File

@@ -65,34 +65,6 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time
return f.count, nil
}
type fakeAdminUserProjectionRepo struct {
status domain.UserProjectionStatus
}
func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
return f.status.Ready, nil
}
func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeAdminUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
return nil
}
func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
return nil
}
func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
return f.status, nil
}
type fakeIdentityCacheAdmin struct {
status domain.IdentityCacheStatus
flush domain.IdentityCacheFlushResult
@@ -157,58 +129,6 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
}
func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true},
},
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) {
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
LastSyncedAt: &syncedAt,
ProjectedUsers: 152,
},
},
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body domain.UserProjectionStatus
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, domain.UserProjectionNameKratos, body.Name)
require.Equal(t, domain.UserProjectionStatusReady, body.Status)
require.True(t, body.Ready)
require.Equal(t, int64(152), body.ProjectedUsers)
}
func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) {
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
cache := &fakeIdentityCacheAdmin{
@@ -237,11 +157,9 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
UserProjection *domain.UserProjectionStatus `json:"userProjection,omitempty"`
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Nil(t, body.UserProjection)
require.True(t, body.IdentityCache.RedisReady)
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
@@ -305,14 +223,6 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
auditRepo := &fakeOverviewAuditRepo{count: 22}
h := &AdminHandler{
AuditRepo: auditRepo,
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
ProjectedUsers: 152,
},
},
}
app := fiber.New()
app.Get("/api/v1/admin/stats", h.GetSystemStats)
@@ -328,7 +238,7 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
require.Contains(t, body, "totalUsers")
require.Contains(t, body, "oidcClients")
require.Contains(t, body, "auditEvents24h")
require.Equal(t, float64(152), body["totalUsers"])
require.Equal(t, float64(0), body["totalUsers"])
require.Equal(t, float64(22), body["auditEvents24h"])
require.Equal(t, time.UTC, auditRepo.since.Location())
}

View File

@@ -103,7 +103,6 @@ type AuthHandler struct {
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageSink domain.RPUsageEventSink
@@ -860,7 +859,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
if err := h.UserRepo.Update(ctx, u); err != nil {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
} else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
@@ -870,7 +868,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
// [Keto] Sync user-tenant relationship via Outbox
@@ -8120,7 +8117,6 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
ctx := context.Background()
if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)

View File

@@ -4,7 +4,9 @@ import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
)
@@ -28,7 +30,13 @@ type hanmacEmailEvaluation struct {
LocalPart string
}
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]bool) hanmacEmailEvaluation {
type hanmacLocalPartOwner struct {
UserID string
Email string
Name string
}
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]hanmacLocalPartOwner) hanmacEmailEvaluation {
originalEmail := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
evaluation := hanmacEmailEvaluation{
@@ -68,9 +76,9 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
}
evaluation.LocalPart = localPart
if usedLocalParts[localPart] {
if owner, exists := usedLocalParts[localPart]; exists {
evaluation.Status = "blockingError"
evaluation.Message = "한맥가족 내에서 이미 사용 중인 이메일 ID입니다."
evaluation.Message = formatHanmacLocalPartConflictMessage(localPart, owner)
evaluation.Blocking = true
return evaluation
}
@@ -88,6 +96,14 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
}
func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string) error {
return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, "", "hanmac create email local-part conflict")
}
func (h *UserHandler) ensureHanmacEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string) error {
return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, currentUserID, "hanmac email local-part conflict")
}
func (h *UserHandler) ensureHanmacEmailAllowedWithLog(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string, logMessage string) error {
scope, err := h.resolveHanmacEmailScope(ctx)
if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) {
return nil
@@ -102,8 +118,22 @@ func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email
if err != nil {
return err
}
if usedLocalParts[localPart] {
return fmt.Errorf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
if owner, exists := usedLocalParts[localPart]; exists {
ownerUserID := strings.TrimSpace(owner.UserID)
if currentUserID != "" && ownerUserID != "" && ownerUserID == strings.TrimSpace(currentUserID) {
return nil
}
slog.Warn(
logMessage,
"requestedEmail", email,
"localPart", localPart,
"ownerUserID", owner.UserID,
"ownerEmail", owner.Email,
"ownerName", owner.Name,
"tenantID", tenantID,
"tenantSlug", tenantSlug,
)
return fmt.Errorf("%s", formatHanmacLocalPartConflictMessage(localPart, owner))
}
return nil
}
@@ -149,8 +179,8 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
return scope, nil
}
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]bool, error) {
used := make(map[string]bool)
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]hanmacLocalPartOwner, error) {
used := make(map[string]hanmacLocalPartOwner)
if h.UserRepo == nil || scope == nil {
return used, nil
}
@@ -160,7 +190,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
if err != nil {
return nil, err
}
addUserEmailLocalParts(used, users)
addUserEmailLocalPartOwners(used, users)
}
if len(scope.SlugList) > 0 {
@@ -168,7 +198,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
if err != nil {
return nil, err
}
addUserEmailLocalParts(used, users)
addUserEmailLocalPartOwners(used, users)
}
return used, nil
@@ -210,31 +240,79 @@ func isTenantDescendantOf(tenant domain.Tenant, rootID string, tenantByID map[st
return false
}
func addUserEmailLocalParts(target map[string]bool, users []domain.User) {
func addUserEmailLocalPartOwners(target map[string]hanmacLocalPartOwner, users []domain.User) {
for _, user := range users {
localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email)
if err == nil && localPart != "" {
target[localPart] = true
if err != nil || localPart == "" {
continue
}
if _, exists := target[localPart]; exists {
continue
}
target[localPart] = hanmacLocalPartOwner{
UserID: strings.TrimSpace(user.ID),
Email: strings.TrimSpace(user.Email),
Name: strings.TrimSpace(user.Name),
}
}
}
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]bool) string {
func formatHanmacLocalPartConflictMessage(localPart string, owner hanmacLocalPartOwner) string {
message := fmt.Sprintf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다. local-part=%s", strings.TrimSpace(localPart))
if owner.Email != "" {
message += ", 사용 계정=" + owner.Email
}
if owner.Name != "" {
message += ", 사용자=" + owner.Name
}
if owner.UserID != "" {
message += ", 사용자 ID=" + owner.UserID
}
return message
}
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]hanmacLocalPartOwner) string {
base = strings.ToLower(strings.TrimSpace(base))
if base == "" {
return ""
}
if !usedLocalParts[base] {
if _, exists := usedLocalParts[base]; !exists {
return base
}
for index := 1; ; index++ {
candidate := fmt.Sprintf("%s%d", base, index)
if !usedLocalParts[candidate] {
stem, nextIndex := splitTrailingNumericSuffix(base)
if stem == "" {
stem = base
}
for index := nextIndex; ; index++ {
candidate := fmt.Sprintf("%s%d", stem, index)
if _, exists := usedLocalParts[candidate]; !exists {
return candidate
}
}
}
func splitTrailingNumericSuffix(value string) (string, int) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "", 1
}
index := len(value)
for index > 0 && value[index-1] >= '0' && value[index-1] <= '9' {
index--
}
if index == len(value) {
return value, 1
}
stem := value[:index]
suffix := value[index:]
number, err := strconv.Atoi(suffix)
if err != nil {
return value, 1
}
return stem, number + 1
}
func appendUniqueString(values []string, value string) []string {
if slices.Contains(values, value) {
return values

View File

@@ -0,0 +1,66 @@
package handler
import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"strings"
)
var internalEmailDomainsDisallowedForPersonal = map[string]bool{
"brsw.kr": true,
"hanmaceng.co.kr": true,
"samaneng.com": true,
"hallasanup.com": true,
"jangheon.co.kr": true,
"jangheon.com": true,
"pre-cast.co.kr": true,
}
func internalDomainPersonalPolicyMessage(email string) string {
return fmt.Sprintf("내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: %s", strings.ToLower(strings.TrimSpace(email)))
}
func emailUsesInternalPersonalRestrictedDomain(email string) bool {
_, domainPart, err := domain.SplitEmailDomain(email)
if err != nil {
return false
}
return internalEmailDomainsDisallowedForPersonal[strings.ToLower(strings.TrimSpace(domainPart))]
}
func isPersonalTenantForInternalDomainPolicy(tenant *domain.Tenant) bool {
if tenant == nil {
return false
}
if strings.EqualFold(strings.TrimSpace(tenant.Type), domain.TenantTypePersonal) {
return true
}
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
return slug == "personal" || strings.HasPrefix(slug, "personal-")
}
func (h *UserHandler) ensureInternalDomainNotAssignedToPersonal(ctx context.Context, email string, tenantID string, tenantSlug string, resolvedTenant *domain.Tenant) error {
if !emailUsesInternalPersonalRestrictedDomain(email) {
return nil
}
tenant := resolvedTenant
if tenant == nil && h.TenantService != nil {
if id := strings.TrimSpace(tenantID); id != "" {
if found, err := h.TenantService.GetTenant(ctx, id); err == nil && found != nil {
tenant = found
}
}
if tenant == nil {
if slug := strings.TrimSpace(tenantSlug); slug != "" {
if found, err := h.TenantService.GetTenantBySlug(ctx, slug); err == nil && found != nil {
tenant = found
}
}
}
}
if isPersonalTenantForInternalDomainPolicy(tenant) {
return fmt.Errorf("%s", internalDomainPersonalPolicyMessage(email))
}
return nil
}

View File

@@ -16,10 +16,8 @@ import (
"io"
"log/slog"
"maps"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -29,25 +27,28 @@ import (
)
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
OrgChartCache orgChartCacheStore
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
Hydra *service.HydraAdminService
ConsentRepo repository.ClientConsentRepository
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
OrgChartCache orgChartCacheStore
IdentityCache domain.RedisRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
Hydra *service.HydraAdminService
ConsentRepo repository.ClientConsentRepository
}
type orgChartCacheStore interface {
Get(key string) (string, error)
Set(key string, value string, expiration time.Duration) error
DeleteByPrefix(ctx context.Context, prefix string) (int64, error)
}
const orgChartSnapshotCacheKeyPrefix = "orgchart:snapshot:v1:"
func seedTenantDeleteError(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
}
@@ -65,18 +66,17 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
UserRepo: userRepo,
UserProjectionRepo: userProjectionRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
DB: db,
Service: svc,
UserRepo: userRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
}
}
@@ -263,6 +263,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_approved")
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
}
@@ -311,12 +312,25 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
parentMap := make(map[string]string)
tenantIDs := make(map[string]bool, len(allTenants))
for _, t := range allTenants {
tenantIDs[t.ID] = true
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
hasValidBaseTenant := false
for _, id := range baseTenantIDs {
if tenantIDs[strings.TrimSpace(id)] {
hasValidBaseTenant = true
break
}
}
if len(baseTenantIDs) > 0 && !hasValidBaseTenant {
return errorJSON(c, fiber.StatusConflict, "tenant scope is not available")
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findTenantRootID(parentMap, id)] = true
@@ -372,7 +386,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -688,6 +702,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
}
result.Details = append(result.Details, detail)
}
if result.Created > 0 || result.Updated > 0 {
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_imported")
}
return c.JSON(result)
}
@@ -1247,6 +1264,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
if err != nil {
if isMissingKetoRelationError(err) {
continue
}
return false, fmt.Errorf("private tenant permission check failed: %w", err)
}
if allowed {
@@ -1257,6 +1277,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
if err != nil {
if isMissingKetoRelationError(err) {
continue
}
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
}
if allowed {
@@ -1266,6 +1289,14 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
return false, nil
}
func isMissingKetoRelationError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "relation ") && strings.Contains(message, "does not exist")
}
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
for _, tenant := range profile.ManageableTenants {
@@ -1669,7 +1700,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), []domain.Tenant{tenant})
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -1795,6 +1826,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
}
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_created")
return c.Status(fiber.StatusCreated).JSON(summary)
}
@@ -1955,6 +1987,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_updated")
return c.JSON(mapTenantSummary(tenant))
}
@@ -1980,6 +2013,10 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return seedTenantDeleteError(c)
}
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), []string{tenantID}); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil {
logTenantCleanupFailure(err, []string{tenantID})
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -1999,6 +2036,7 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_deleted")
return c.SendStatus(fiber.StatusNoContent)
}
@@ -2287,6 +2325,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
req.IDs = uniqueNonEmptyStrings(req.IDs)
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
@@ -2313,6 +2355,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
}
}
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil {
logTenantCleanupFailure(err, req.IDs)
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2321,6 +2367,7 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_bulk_deleted")
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Tenants deleted successfully",
@@ -2328,6 +2375,362 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
})
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
result = append(result, trimmed)
}
return result
}
func (h *TenantHandler) reassignUserMembershipsBeforeTenantDelete(ctx context.Context, tenantIDs []string) error {
if h == nil || h.DB == nil {
return nil
}
deletedIDs := uniqueNonEmptyStrings(tenantIDs)
if len(deletedIDs) == 0 {
return nil
}
affectedIDs, err := h.deletedTenantIDsReferencedByUsers(ctx, deletedIDs)
if err != nil {
return err
}
if len(affectedIDs) == 0 {
return nil
}
var tenants []domain.Tenant
if err := h.DB.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
return err
}
targets, err := resolveTenantDeletionPromotionTargets(tenants, deletedIDs, affectedIDs)
if err != nil {
return err
}
var affectedUsers []domain.User
if err := h.DB.WithContext(ctx).
Where("tenant_id IN ?", affectedIDs).
Find(&affectedUsers).Error; err != nil {
return err
}
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
if err := h.promoteKratosUserMembershipsForTenantDelete(ctx, affectedUsers, tenantByID, targets); err != nil {
return err
}
return h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for deletedID, targetID := range targets {
if err := tx.Model(&domain.User{}).
Where("tenant_id = ?", deletedID).
Updates(map[string]any{"tenant_id": targetID, "updated_at": time.Now()}).Error; err != nil {
return err
}
if err := tx.Model(&domain.UserLoginID{}).
Where("tenant_id = ?", deletedID).
Update("tenant_id", targetID).Error; err != nil {
return err
}
}
return nil
})
}
func (h *TenantHandler) promoteKratosUserMembershipsForTenantDelete(ctx context.Context, users []domain.User, tenantByID map[string]domain.Tenant, targets map[string]string) error {
if h == nil || h.KratosAdmin == nil {
return nil
}
for _, user := range users {
if user.TenantID == nil || strings.TrimSpace(user.ID) == "" {
continue
}
deletedID := strings.TrimSpace(*user.TenantID)
targetID := strings.TrimSpace(targets[deletedID])
if deletedID == "" || targetID == "" {
continue
}
deletedTenant, ok := tenantByID[deletedID]
if !ok {
return fmt.Errorf("deleted tenant not found while promoting user membership: %s", deletedID)
}
targetTenant, ok := tenantByID[targetID]
if !ok {
return fmt.Errorf("promotion target tenant not found while promoting user membership: %s", targetID)
}
identity, err := h.KratosAdmin.GetIdentity(ctx, user.ID)
if err != nil {
return fmt.Errorf("get kratos identity for tenant promotion user=%s: %w", user.ID, err)
}
if identity == nil {
return fmt.Errorf("kratos identity not found for tenant promotion user=%s", user.ID)
}
traits, changed := promoteIdentityTraitsFromDeletedTenant(identity.Traits, deletedTenant, targetTenant, true)
if !changed {
continue
}
updated, err := h.KratosAdmin.UpdateIdentity(ctx, user.ID, traits, identity.State)
if err != nil {
return fmt.Errorf("update kratos identity for tenant promotion user=%s: %w", user.ID, err)
}
if updated == nil {
identity.Traits = traits
h.storePromotedIdentityMirror(*identity)
continue
}
h.storePromotedIdentityMirror(*updated)
}
return nil
}
func promoteIdentityTraitsFromDeletedTenant(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant, forcePrimary bool) (map[string]any, bool) {
next := cloneIdentityTraits(traits)
changed := false
primaryChanged := forcePrimary
if traitStringEqualTenant(next["tenant_id"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["tenant_id"] = targetTenant.ID
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantId"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["primaryTenantId"] = targetTenant.ID
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantSlug"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["primaryTenantSlug"] = targetTenant.Slug
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantName"], deletedTenant.Name, "") || forcePrimary {
next["primaryTenantName"] = targetTenant.Name
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["companyCode"], deletedTenant.ID, deletedTenant.Slug) {
next["companyCode"] = targetTenant.Slug
changed = true
}
if traitStringEqualTenant(next["company_code"], deletedTenant.ID, deletedTenant.Slug) {
next["company_code"] = targetTenant.Slug
changed = true
}
appointments, appointmentsChanged := promoteIdentityAppointmentsFromDeletedTenant(next["additionalAppointments"], deletedTenant, targetTenant)
if appointmentsChanged {
next["additionalAppointments"] = appointments
changed = true
}
if primaryChanged {
next["primaryTenantId"] = targetTenant.ID
next["primaryTenantSlug"] = targetTenant.Slug
next["primaryTenantName"] = targetTenant.Name
}
return next, changed
}
func promoteIdentityAppointmentsFromDeletedTenant(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
switch appointments := raw.(type) {
case []any:
next := make([]any, 0, len(appointments))
changed := false
for _, rawAppointment := range appointments {
appointment, ok := rawAppointment.(map[string]any)
if !ok {
next = append(next, rawAppointment)
continue
}
copied := maps.Clone(appointment)
if identityAppointmentMatchesTenant(copied, deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
case []map[string]any:
next := make([]any, 0, len(appointments))
changed := false
for _, appointment := range appointments {
copied := maps.Clone(appointment)
if identityAppointmentMatchesTenant(copied, deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
default:
return nil, false
}
}
func identityAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
return traitStringEqualTenant(appointment["tenantId"], tenant.ID, tenant.Slug) ||
traitStringEqualTenant(appointment["tenantSlug"], tenant.ID, tenant.Slug) ||
traitStringEqualTenant(appointment["tenantName"], tenant.Name, "")
}
func traitStringEqualTenant(value any, id string, slug string) bool {
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" || raw == "<nil>" {
return false
}
if strings.EqualFold(raw, strings.TrimSpace(id)) {
return true
}
return strings.TrimSpace(slug) != "" && strings.EqualFold(raw, strings.TrimSpace(slug))
}
func cloneIdentityTraits(traits map[string]any) map[string]any {
if traits == nil {
return map[string]any{}
}
next := make(map[string]any, len(traits))
for key, value := range traits {
next[key] = cloneIdentityTraitValue(value)
}
return next
}
func cloneIdentityTraitValue(value any) any {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for key, nested := range typed {
next[key] = cloneIdentityTraitValue(nested)
}
return next
case []any:
next := make([]any, len(typed))
for i, nested := range typed {
next[i] = cloneIdentityTraitValue(nested)
}
return next
case []map[string]any:
next := make([]any, len(typed))
for i, nested := range typed {
next[i] = cloneIdentityTraitValue(nested)
}
return next
default:
return value
}
}
func (h *TenantHandler) storePromotedIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
raw, err := json.Marshal(identity)
if err != nil {
return
}
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
_ = h.IdentityCache.Delete("identity:mirror:state")
}
func (h *TenantHandler) deletedTenantIDsReferencedByUsers(ctx context.Context, tenantIDs []string) ([]string, error) {
referenced := make(map[string]bool)
var userTenantIDs []string
if err := h.DB.WithContext(ctx).
Model(&domain.User{}).
Where("tenant_id IN ?", tenantIDs).
Distinct("tenant_id").
Pluck("tenant_id", &userTenantIDs).Error; err != nil {
return nil, err
}
for _, tenantID := range userTenantIDs {
if tenantID != "" {
referenced[tenantID] = true
}
}
var loginTenantIDs []string
if err := h.DB.WithContext(ctx).
Model(&domain.UserLoginID{}).
Where("tenant_id IN ?", tenantIDs).
Distinct("tenant_id").
Pluck("tenant_id", &loginTenantIDs).Error; err != nil {
return nil, err
}
for _, tenantID := range loginTenantIDs {
if tenantID != "" {
referenced[tenantID] = true
}
}
result := make([]string, 0, len(referenced))
for _, tenantID := range tenantIDs {
if referenced[tenantID] {
result = append(result, tenantID)
}
}
return result, nil
}
func resolveTenantDeletionPromotionTargets(tenants []domain.Tenant, deletedTenantIDs []string, affectedTenantIDs []string) (map[string]string, error) {
deleted := make(map[string]bool, len(deletedTenantIDs))
for _, tenantID := range deletedTenantIDs {
tenantID = strings.TrimSpace(tenantID)
if tenantID != "" {
deleted[tenantID] = true
}
}
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantByID[tenant.ID] = tenant
}
}
targets := make(map[string]string, len(affectedTenantIDs))
for _, affectedID := range uniqueNonEmptyStrings(affectedTenantIDs) {
tenant, ok := tenantByID[affectedID]
if !ok {
return nil, fmt.Errorf("tenant %s not found for membership reassignment", affectedID)
}
visited := map[string]bool{affectedID: true}
for {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" || *tenant.ParentID == tenant.ID {
return nil, fmt.Errorf("tenant %s cannot be deleted while referenced by users because it has no remaining parent tenant", affectedID)
}
parentID := strings.TrimSpace(*tenant.ParentID)
if visited[parentID] {
return nil, fmt.Errorf("tenant %s cannot be reassigned because its parent chain has a cycle", affectedID)
}
visited[parentID] = true
parent, ok := tenantByID[parentID]
if !ok {
return nil, fmt.Errorf("tenant %s cannot be reassigned because parent tenant %s was not found", affectedID, parentID)
}
if !deleted[parentID] && !parent.DeletedAt.Valid {
targets[affectedID] = parent.ID
break
}
tenant = parent
}
}
return targets, nil
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {
@@ -2673,7 +3076,7 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
return build(rootID)
}
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -2681,27 +3084,78 @@ func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, te
if len(tenants) == 0 {
return counts, counts, nil
}
if h.UserProjectionRepo == nil {
return nil, nil, errors.New("user projection is not configured")
if h.UserRepo == nil {
return counts, counts, nil
}
ready, err := h.UserProjectionRepo.IsReady(ctx)
if err != nil {
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
if !ready {
return nil, nil, errors.New("user projection is not ready")
}
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, nil, err
}
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
if err != nil {
return nil, nil, err
totalCounts := make(map[string]int64, len(tenants))
allTenants := tenants
if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
}
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
continue
}
childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant)
}
for _, tenant := range tenants {
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
if len(descendantIDs) == 0 {
totalCounts[tenant.ID] = directCounts[tenant.ID]
continue
}
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
if countErr != nil {
return nil, nil, countErr
}
totalCounts[tenant.ID] = total
}
return directCounts, totalCounts, nil
}
func collectTenantSubtreeIDs(rootID string, childrenByParentID map[string][]domain.Tenant) []string {
rootID = strings.TrimSpace(rootID)
if rootID == "" {
return nil
}
ids := []string{rootID}
queue := []string{rootID}
seen := map[string]struct{}{rootID: {}}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
for _, child := range childrenByParentID[current] {
childID := strings.TrimSpace(child.ID)
if childID == "" {
continue
}
if _, ok := seen[childID]; ok {
continue
}
seen[childID] = struct{}{}
ids = append(ids, childID)
queue = append(queue, childID)
}
}
return ids
}
func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
@@ -2763,7 +3217,6 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
ttl := orgChartSnapshotCacheTTL()
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
"user_id", userID,
@@ -2778,9 +3231,8 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
TTLSeconds: int(ttl.Seconds()),
Source: "redis",
Hit: true,
}
c.Set("X-Orgfront-Cache", "HIT")
slog.Info("orgchart snapshot cache hit",
@@ -2823,14 +3275,13 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
TTLSeconds: int(ttl.Seconds()),
Source: "database",
Hit: false,
}
if cacheMode == "redis" && h.OrgChartCache != nil {
if raw, err := json.Marshal(snapshot); err == nil {
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
if err := h.OrgChartCache.Set(cacheKey, string(raw), orgChartSnapshotCacheExpiration()); err != nil {
slog.Warn("orgchart snapshot cache write failed",
"user_id", userID,
"role", role,
@@ -2858,13 +3309,68 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
return c.JSON(snapshot)
}
func (h *TenantHandler) WarmOrgChartSnapshotCache(ctx context.Context) error {
if h == nil || h.OrgChartCache == nil {
return nil
}
profile := &domain.UserProfileResponse{
ID: "orgfront-cache-warmup",
Role: domain.RoleSuperAdmin,
}
snapshot, err := h.buildOrgChartSnapshot(ctx, profile)
if err != nil {
return err
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
}
raw, err := json.Marshal(snapshot)
if err != nil {
return err
}
return h.OrgChartCache.Set(
orgChartSnapshotCacheKey(profile, ""),
string(raw),
orgChartSnapshotCacheExpiration(),
)
}
func (h *TenantHandler) refreshOrgChartSnapshotCacheAfterTenantChange(ctx context.Context, reason string) {
if h == nil || h.OrgChartCache == nil {
return
}
deleted, err := h.OrgChartCache.DeleteByPrefix(ctx, orgChartSnapshotCacheKeyPrefix)
if err != nil {
slog.Warn("Orgfront orgchart snapshot cache invalidation failed after tenant change",
"reason", reason,
"prefix", orgChartSnapshotCacheKeyPrefix,
"error", err,
)
return
}
if err := h.WarmOrgChartSnapshotCache(ctx); err != nil {
slog.Warn("Orgfront orgchart snapshot cache refresh failed after tenant change",
"reason", reason,
"error", err,
)
return
}
slog.Info("Orgfront orgchart snapshot cache refreshed after tenant change",
"reason", reason,
"invalidated_keys", deleted,
)
}
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
if err != nil {
return orgChartSnapshotResponse{}, err
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
memberCounts, totalMemberCounts, err := h.countTenantMembers(ctx, tenants)
if err != nil {
return orgChartSnapshotResponse{}, err
}
@@ -3002,7 +3508,10 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
if profile != nil {
role = domain.NormalizeRole(profile.Role)
userID = strings.TrimSpace(profile.ID)
if tenantID == "" && profile.TenantID != nil {
if role == domain.RoleSuperAdmin {
userID = "all"
tenantID = "none"
} else if tenantID == "" && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
}
@@ -3012,7 +3521,7 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
if tenantID == "" {
tenantID = "none"
}
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
return fmt.Sprintf("%s%s:%s:%s", orgChartSnapshotCacheKeyPrefix, role, userID, tenantID)
}
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
@@ -3045,17 +3554,8 @@ func findTenantRootID(parentMap map[string]string, tenantID string) string {
}
}
func orgChartSnapshotCacheTTL() time.Duration {
const defaultTTL = 5 * time.Minute
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
if raw == "" {
return defaultTTL
}
seconds, err := strconv.Atoi(raw)
if err != nil || seconds <= 0 {
return defaultTTL
}
return time.Duration(seconds) * time.Second
func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes"
"context"
@@ -15,6 +16,7 @@ import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/mock"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
@@ -56,8 +58,8 @@ func newTenantHandlerSeedDeleteDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("failed to open postgres connection: %v", err)
}
if err := db.AutoMigrate(&domain.Tenant{}); err != nil {
t.Fatalf("failed to migrate tenants: %v", err)
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}, &domain.UserLoginID{}); err != nil {
t.Fatalf("failed to migrate tenant delete models: %v", err)
}
return db
}
@@ -108,6 +110,137 @@ func TestTenantHandlerDeleteTenantRejectsSeedTenant(t *testing.T) {
}
}
func TestTenantHandlerDeleteTenantReassignsUserMembershipsToParentTenant(t *testing.T) {
db := newTenantHandlerSeedDeleteDB(t)
parent := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000001",
Name: "Parent",
Slug: "delete-policy-parent",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
child := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000002",
Name: "Collaboration",
Slug: "delete-policy-collaboration",
Type: domain.TenantTypeUserGroup,
ParentID: &parent.ID,
Status: domain.TenantStatusActive,
}
user := domain.User{
ID: "10000000-0000-0000-0000-000000000101",
Email: "delete-policy-user@example.com",
Name: "Delete Policy User",
Role: domain.RoleUser,
TenantID: &child.ID,
}
loginID := domain.UserLoginID{
ID: "10000000-0000-0000-0000-000000000201",
UserID: user.ID,
TenantID: child.ID,
FieldKey: "employee_number",
LoginID: "delete-policy-user",
}
if err := db.Create(&parent).Error; err != nil {
t.Fatalf("failed to create parent tenant: %v", err)
}
if err := db.Create(&child).Error; err != nil {
t.Fatalf("failed to create child tenant: %v", err)
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
if err := db.Create(&loginID).Error; err != nil {
t.Fatalf("failed to create login id: %v", err)
}
staleIdentity := service.KratosIdentity{
ID: user.ID,
State: "active",
Traits: map[string]any{
"email": user.Email,
"name": user.Name,
"tenant_id": child.ID,
"primaryTenantId": child.ID,
"primaryTenantSlug": child.Slug,
"primaryTenantName": child.Name,
"additionalAppointments": []any{
map[string]any{
"tenantId": child.ID,
"tenantSlug": child.Slug,
"tenantName": child.Name,
"isPrimary": true,
"grade": "G5",
},
},
},
}
updatedIdentity := staleIdentity
mockKratos := new(MockKratosAdmin)
mockKratos.On("GetIdentity", mock.Anything, user.ID).Return(&staleIdentity, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, user.ID, mock.MatchedBy(func(traits map[string]any) bool {
if traits["tenant_id"] != parent.ID || traits["primaryTenantId"] != parent.ID {
return false
}
if traits["primaryTenantSlug"] != parent.Slug || traits["primaryTenantName"] != parent.Name {
return false
}
appointments, ok := traits["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
return ok &&
appointment["tenantId"] == parent.ID &&
appointment["tenantSlug"] == parent.Slug &&
appointment["tenantName"] == parent.Name &&
appointment["grade"] == "G5" &&
appointment["isPrimary"] == true
}), "active").Run(func(args mock.Arguments) {
updatedIdentity.Traits = args.Get(2).(map[string]any)
}).Return(&updatedIdentity, nil).Once()
redis := &mockRedisRepo{data: map[string]string{}}
staleRaw, _ := json.Marshal(staleIdentity)
if err := redis.Set(identityMirrorKey(user.ID), string(staleRaw), 0); err != nil {
t.Fatalf("failed to seed identity mirror: %v", err)
}
app := fiber.New()
app.Delete("/tenants/:id", (&TenantHandler{DB: db, KratosAdmin: mockKratos, IdentityCache: redis}).DeleteTenant)
req := httptest.NewRequest(http.MethodDelete, "/tenants/"+child.ID, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
var foundUser domain.User
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to reload user: %v", err)
}
if foundUser.TenantID == nil || *foundUser.TenantID != parent.ID {
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, parent.ID)
}
var foundLogin domain.UserLoginID
if err := db.First(&foundLogin, "id = ?", loginID.ID).Error; err != nil {
t.Fatalf("failed to reload login id: %v", err)
}
if foundLogin.TenantID != parent.ID {
t.Fatalf("login tenant_id = %s, want %s", foundLogin.TenantID, parent.ID)
}
mockKratos.AssertExpectations(t)
var mirrored service.KratosIdentity
if err := json.Unmarshal([]byte(redis.data[identityMirrorKey(user.ID)]), &mirrored); err != nil {
t.Fatalf("failed to decode mirrored identity: %v", err)
}
if mirrored.Traits["tenant_id"] != parent.ID || mirrored.Traits["primaryTenantSlug"] != parent.Slug {
t.Fatalf("mirrored traits = %#v, want promoted tenant %s/%s", mirrored.Traits, parent.ID, parent.Slug)
}
}
func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
setSeedTenantCSVForDeleteGuard(t, "protected-root")
db := newTenantHandlerSeedDeleteDB(t)
@@ -157,3 +290,78 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
t.Fatalf("remaining tenant count = %d, want 2", count)
}
}
func TestTenantHandlerDeleteTenantsBulkReassignsUsersToNearestRemainingAncestor(t *testing.T) {
db := newTenantHandlerSeedDeleteDB(t)
root := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000011",
Name: "Root",
Slug: "delete-policy-root",
Type: domain.TenantTypeCompanyGroup,
Status: domain.TenantStatusActive,
}
parent := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000012",
Name: "Parent",
Slug: "delete-policy-bulk-parent",
Type: domain.TenantTypeCompany,
ParentID: &root.ID,
Status: domain.TenantStatusActive,
}
child := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000013",
Name: "Collaboration",
Slug: "delete-policy-bulk-collaboration",
Type: domain.TenantTypeUserGroup,
ParentID: &parent.ID,
Status: domain.TenantStatusActive,
}
user := domain.User{
ID: "10000000-0000-0000-0000-000000000111",
Email: "bulk-delete-policy-user@example.com",
Name: "Bulk Delete Policy User",
Role: domain.RoleUser,
TenantID: &child.ID,
}
if err := db.Create(&root).Error; err != nil {
t.Fatalf("failed to create root tenant: %v", err)
}
if err := db.Create(&parent).Error; err != nil {
t.Fatalf("failed to create parent tenant: %v", err)
}
if err := db.Create(&child).Error; err != nil {
t.Fatalf("failed to create child tenant: %v", err)
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
mockSvc := new(MockTenantService)
mockSvc.On("DeleteTenantsBulk", mock.Anything, []string{parent.ID, child.ID}).Return(nil).Once()
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/tenants/bulk", (&TenantHandler{DB: db, Service: mockSvc}).DeleteTenantsBulk)
body, _ := json.Marshal(map[string][]string{"ids": {parent.ID, child.ID}})
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
var foundUser domain.User
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to reload user: %v", err)
}
if foundUser.TenantID == nil || *foundUser.TenantID != root.ID {
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, root.ID)
}
mockSvc.AssertExpectations(t)
}

View File

@@ -113,7 +113,16 @@ func (m *MockUserRepoForHandler) DB() *gorm.DB {
}
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error {
for _, call := range m.ExpectedCalls {
if call.Method == "Update" {
args := m.Called(ctx, user)
return args.Error(0)
}
}
return nil
}
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
m.deletedIDs = append(m.deletedIDs, id)
return nil
@@ -155,7 +164,20 @@ func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs
}
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
for _, call := range m.ExpectedCalls {
if call.Method == "CountByTenantIDs" {
args := m.Called(ctx, tenantIDs)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
}
counts := make(map[string]int64, len(tenantIDs))
for _, tenantID := range tenantIDs {
counts[tenantID] = 0
}
return counts, nil
}
func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
@@ -187,10 +209,6 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi
return "", nil
}
type MockUserProjectionRepoForHandler struct {
mock.Mock
}
type mockOrgChartCache struct {
mock.Mock
values map[string]string
@@ -210,40 +228,9 @@ func (m *mockOrgChartCache) Set(key string, value string, expiration time.Durati
return args.Error(0)
}
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
args := m.Called(ctx)
return args.Bool(0), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
args := m.Called(ctx)
return args.Get(0).(domain.UserProjectionStatus), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
args := m.Called(ctx, tenants)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
args := m.Called(ctx, tenants)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
args := m.Called(ctx, users)
return args.Error(0)
}
func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error {
args := m.Called(ctx, syncErr)
return args.Error(0)
func (m *mockOrgChartCache) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
args := m.Called(prefix)
return args.Get(0).(int64), args.Error(1)
}
func toJSONString(t *testing.T, value any) string {
@@ -281,14 +268,14 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -303,11 +290,11 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(7), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -320,7 +307,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
require.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
@@ -353,7 +340,6 @@ func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
@@ -370,15 +356,15 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
cache.On("Get", mock.Anything).Return("", redis.Nil).Once()
cache.On("Set", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "orgchart:snapshot:")
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
}), mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 1}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
@@ -401,14 +387,103 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-1",
Role: domain.RoleSuperAdmin,
}, "")
second := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-2",
Role: domain.RoleSuperAdmin,
}, "")
require.Equal(t, first, second)
require.Equal(t, "orgchart:snapshot:v1:super_admin:all:none", first)
}
func TestResolveTenantDeletionPromotionTargetsUsesNearestRemainingAncestor(t *testing.T) {
rootID := "root"
parentID := "parent"
childID := "child"
tenants := []domain.Tenant{
{ID: rootID, Slug: "root"},
{ID: parentID, Slug: "parent", ParentID: &rootID},
{ID: childID, Slug: "child", ParentID: &parentID},
}
targets, err := resolveTenantDeletionPromotionTargets(tenants, []string{parentID, childID}, []string{childID})
require.NoError(t, err)
require.Equal(t, map[string]string{
childID: rootID,
}, targets)
}
func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
require.NotEmpty(t, raw)
var cached orgChartSnapshotResponse
require.NoError(t, json.Unmarshal([]byte(raw), &cached))
require.Len(t, cached.Tenants, 1)
require.Equal(t, "database", cached.Cache.Source)
require.False(t, cached.Cache.Hit)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesAllSnapshotKeys(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(3), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
@@ -424,7 +499,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
{ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
@@ -438,14 +513,11 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
@@ -463,18 +535,17 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
require.Len(t, body.Users, 1)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -492,11 +563,13 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
Return([]domain.User{}, int64(3), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
Return([]domain.User{}, int64(2), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -510,49 +583,17 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
assert.Equal(t, int64(3), res.Items[0].TotalMemberCount)
assert.Equal(t, int64(2), res.Items[1].MemberCount)
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
mockProjection.AssertNotCalled(t, "CountTenantMembersRecursive", mock.Anything, mock.Anything)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -569,11 +610,10 @@ func TestTenantHandler_ListTenants(t *testing.T) {
// Mocking for the new allTenants check in ListTenants
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -599,11 +639,11 @@ func TestTenantHandler_ListTenants(t *testing.T) {
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -621,9 +661,10 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
}
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
@@ -662,11 +703,11 @@ func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
parent := func(id string) *string { return &id }
@@ -688,14 +729,12 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "public-team"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"family", "company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"public-team"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
@@ -712,11 +751,11 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
parent := func(id string) *string { return &id }
@@ -740,14 +779,10 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "private-team", "private-child"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", mock.Anything, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
@@ -761,6 +796,41 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
require.Contains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_ListTenantsRejectsStaleProfileTenantScope(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
}
staleTenantID := "deleted-tenant"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &staleTenantID,
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body["error"], "tenant scope is not available")
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
@@ -785,6 +855,41 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
mockKeto.AssertExpectations(t)
}
func TestTenantHandler_FilterPrivateTenantsTreatsMissingKetoRelationsAsDenied(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "company", Type: domain.TenantTypeCompany, Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
}
mockKeto := new(devMockKetoService)
h := &TenantHandler{Keto: mockKeto}
relationErr := errors.New(`keto returned status 400: {"reason":"relation \"view_private\" does not exist"}`)
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", relation).Return(false, relationErr).Once()
}
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "company", "view_private_descendants").Return(false, relationErr).Once()
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1",
Role: "tenant_admin",
TenantID: parent("company"),
})
require.NoError(t, err)
require.ElementsMatch(t, []string{"hanmac", "public-team"}, tenantSlugs(filtered))
mockKeto.AssertExpectations(t)
}
func tenantSlugs(tenants []domain.Tenant) []string {
slugs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
slugs = append(slugs, tenant.Slug)
}
return slugs
}
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
if len(got) != len(want) {
return false
@@ -1127,47 +1232,14 @@ func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) {
func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUserRepo,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUserRepo,
}
app.Use(func(c *fiber.Ctx) error {
@@ -1183,16 +1255,11 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
Return(map[string]int64{"saman": 152}, nil).Maybe()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(152), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -1203,8 +1270,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
mockProjection.AssertExpectations(t)
assert.Equal(t, int64(152), res.Items[0].MemberCount)
mockUserRepo.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
@@ -1718,6 +1785,46 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenantID := "tenant-1"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: tenantID, Type: domain.TenantTypeOrganization, Name: "조직", Slug: "team", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUsers,
OrgChartCache: cache,
}
app.Post("/tenants/:id/approve", h.ApproveTenant)
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/approve", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
args := m.Called(ctx, tenantIDs)
return args.Error(0)

View File

@@ -34,17 +34,16 @@ type OryProviderAPI interface {
}
type UserHandler struct {
KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
IdentityCache domain.RedisRepository
Worksmobile service.WorksmobileSyncer
KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
IdentityCache domain.RedisRepository
Worksmobile service.WorksmobileSyncer
}
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
@@ -111,6 +110,16 @@ func userAppointmentSliceFromRaw(raw any) []any {
appointments = append(appointments, value)
}
return appointments
case []map[string]string:
appointments := make([]any, 0, len(values))
for _, value := range values {
appointment := make(map[string]any, len(value))
for key, item := range value {
appointment[key] = item
}
appointments = append(appointments, appointment)
}
return appointments
default:
return nil
}
@@ -323,7 +332,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
delete(metadata, "primaryTenantIsOwner")
cleared = true
}
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
if isBlockedRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
clearMetadataPrimary()
}
@@ -336,7 +345,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
if tenantSlug == "" {
tenantSlug = normalizeMetadataString(appointment["slug"])
}
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
if !isBlockedRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
return
}
appointment["isPrimary"] = false
@@ -359,7 +368,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
return cleared, nil
}
func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
func isBlockedRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
var tenant *domain.Tenant
var err error
if strings.TrimSpace(tenantID) != "" {
@@ -371,7 +380,7 @@ func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.
return false
}
visibility := tenantVisibility(tenant.Config)
return visibility == "internal" || visibility == "private"
return visibility == "private"
}
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
@@ -544,9 +553,34 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
}
func identityTenantAccessKeys(traits map[string]any) []string {
keys := make([]string, 0, 2)
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
keys = append(keys, tenantID)
keys := make([]string, 0, 4)
seen := make(map[string]bool)
appendKey := func(value string) {
key := strings.ToLower(strings.TrimSpace(value))
if key == "" || seen[key] {
return
}
seen[key] = true
keys = append(keys, key)
}
appendKey(extractTraitString(traits, "tenant_id"))
appendKey(extractTraitString(traits, "tenantSlug"))
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
if len(appointments) == 0 {
if metadata, ok := traits["metadata"].(map[string]any); ok {
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
}
}
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok {
continue
}
appendKey(normalizeMetadataString(appointment["tenantId"]))
appendKey(normalizeMetadataString(appointment["tenantSlug"]))
appendKey(normalizeMetadataString(appointment["slug"]))
}
return keys
}
@@ -673,6 +707,7 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string
}
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
searchLower = strings.TrimSpace(searchLower)
if searchLower == "" {
return true
}
@@ -685,6 +720,9 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
return true
}
if identityEmailLocalPartMatchesSearch(identity, searchLower) {
return true
}
rawTraits, err := json.Marshal(identity.Traits)
if err != nil {
return false
@@ -692,6 +730,21 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
return strings.Contains(strings.ToLower(string(rawTraits)), searchLower)
}
func identityEmailLocalPartMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
if !strings.Contains(searchLower, "@") {
return false
}
searchLocalPart, err := domain.ExtractNormalizedEmailLocalPart(searchLower)
if err != nil || searchLocalPart == "" {
return false
}
identityLocalPart, err := domain.ExtractNormalizedEmailLocalPart(extractTraitString(identity.Traits, "email"))
if err != nil || identityLocalPart == "" {
return false
}
return searchLocalPart == identityLocalPart
}
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// [New] Get requester profile from middleware
var requesterRole string
@@ -813,11 +866,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
searchLower := strings.ToLower(search)
for _, identity := range identities {
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
// Tenant Admin & Member filtering
if requesterRole != domain.RoleSuperAdmin {
hasAccess := manageableSlugs[tID]
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
if !hasAccess {
continue
}
@@ -825,7 +878,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// Dedicated tenantSlug filter
if tenantSlug != "" {
matches := tID == targetTenantID
targetKeys := map[string]bool{
targetTenantID: true,
strings.ToLower(tenantSlug): true,
}
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
if !matches {
continue
}
@@ -1195,6 +1252,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// [Resolve TenantID and Custom Login IDs before Kratos creation]
var tenantID string
var resolvedTenant *domain.Tenant
primaryAppointments := req.AdditionalAppointments
if representativeCleared {
primaryAppointments = nil
@@ -1205,18 +1263,26 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
tenantID = tenant.ID
req.CompanyCode = tenant.Slug
resolvedTenant = tenant
}
}
}
if req.CompanyCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
tenantID = tenant.ID
resolvedTenant = tenant
}
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), email, tenantID, req.CompanyCode, resolvedTenant); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
if tenantID == "" {
if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
}
if emailUsesInternalPersonalRestrictedDomain(email) {
return errorJSON(c, fiber.StatusBadRequest, internalDomainPersonalPolicyMessage(email))
}
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
@@ -1304,21 +1370,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// Sync to local DB (Synchronous for immediate consistency)
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err)
}
}
// Update User Login IDs in local DB
for i := range loginIDRecords {
loginIDRecords[i].UserID = localUser.ID
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
@@ -1405,7 +1463,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
results := make([]bulkUserResult, 0, len(req.Users))
var hanmacScope *hanmacEmailScope
var hanmacLocalParts map[string]bool
var hanmacLocalParts map[string]hanmacLocalPartOwner
hanmacScopeLoaded := false
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
@@ -1414,6 +1472,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
ID string
Slug string
Name string
Type string
ParentID *string
Schema []any
Groups []domain.UserGroup
@@ -1428,6 +1487,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
ID: tenant.ID,
Slug: tenant.Slug,
Name: tenant.Name,
Type: tenant.Type,
ParentID: tenant.ParentID,
}
if s, ok := tenant.Config["userSchema"].([]any); ok {
@@ -1558,6 +1618,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
tenantSlug = tItem.Slug
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
} else if tenantSlug != "" {
tItem, err = resolveTenantBySlug(tenantSlug)
if err != nil {
@@ -1565,6 +1629,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
tenantSlug = tItem.Slug
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
} else {
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
@@ -1574,6 +1642,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
if tenantSlug == "" {
if emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
tItem, err = createPersonalTenantItem(email)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"})
@@ -1582,6 +1654,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
tenantSlug = tItem.Slug
}
}
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
// Role-based access check
if requester != nil && requester.Role != domain.RoleSuperAdmin {
@@ -1678,7 +1754,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
userEmail = emailEvaluation.Email
if emailEvaluation.LocalPart != "" {
hanmacLocalParts[emailEvaluation.LocalPart] = true
hanmacLocalParts[emailEvaluation.LocalPart] = hanmacLocalPartOwner{
Email: userEmail,
Name: item.Name,
}
}
} else {
if _, _, err := domain.SplitEmailDomain(email); err != nil {
@@ -1878,14 +1957,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err)
}
}
// Update User Login IDs in local DB
for i := range loginIDRecords {
loginIDRecords[i].UserID = localUser.ID
@@ -1903,7 +1975,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
results = append(results, bulkUserResult{
Email: userEmail,
OriginalEmail: emailEvaluation.OriginalEmail,
@@ -2165,6 +2236,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Pre-fetch tenant cache if tenantSlug is being changed.
type tenantCacheItem struct {
ID string
Slug string
Type string
Schema []any
}
tenantCache := make(map[string]tenantCacheItem)
@@ -2223,6 +2296,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if req.CompanyCode != nil {
delete(traits, "companyCode")
delete(traits, "companyCodes")
var targetTenant *domain.Tenant
if req.IsAddTenant {
if h.TenantService == nil {
@@ -2234,6 +2308,10 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
continue
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), tenant.ID, tenant.Slug, tenant); err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
if appointments, ok := metadata["additionalAppointments"]; ok {
traits["additionalAppointments"] = appointments
@@ -2249,12 +2327,22 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
traits["tenant_id"] = tItem.ID
targetTenant = &domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}
} else if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
if err == nil && tenant != nil {
tItem.ID = tenant.ID
tItem.Slug = tenant.Slug
tItem.Type = tenant.Type
tenantCache[*req.CompanyCode] = tItem
traits["tenant_id"] = tenant.ID
targetTenant = tenant
}
}
if !req.IsAddTenant {
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), *req.CompanyCode, targetTenant); err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
}
}
@@ -2306,8 +2394,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
localUser.JobTitle = *req.JobTitle
}
// Resolve TenantID if changing tenantSlug.
if req.CompanyCode != nil && h.TenantService != nil {
// Resolve TenantID only for representative tenant changes.
if req.CompanyCode != nil && !req.IsAddTenant && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
localUser.TenantID = &tenant.ID
}
@@ -2315,7 +2403,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
_ = h.UserRepo.Update(c.Context(), localUser)
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
if err := h.Worksmobile.EnqueueUserUpdateIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err)
}
}
@@ -2468,6 +2556,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Status *string `json:"status"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
IsPrimaryTenant bool `json:"isPrimaryTenant"`
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
Department *string `json:"department"`
@@ -2498,6 +2587,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
req.PrimaryTenantID = ""
req.PrimaryTenantName = ""
req.PrimaryTenantIsOwner = nil
req.IsPrimaryTenant = false
req.CompanyCode = nil
}
}
@@ -2592,6 +2682,17 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
}
targetTenantID := extractTraitString(traits, "tenant_id")
if targetTenantID == "" {
targetTenantID = oldTenantID
}
targetTenantSlug := ""
if req.CompanyCode != nil && !req.IsRemoveTenant {
targetTenantSlug = strings.TrimSpace(*req.CompanyCode)
}
if err := h.ensureHanmacEmailAllowed(c.Context(), nextEmail, targetTenantSlug, targetTenantID, userID); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if h.UserRepo != nil {
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
@@ -2619,6 +2720,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode)
var resolvedTenant *domain.Tenant
representativeTenantRequested := req.IsPrimaryTenant || strings.TrimSpace(req.PrimaryTenantID) != ""
if req.IsRemoveTenant {
if h.TenantService != nil && code != "" {
@@ -2639,10 +2742,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
}
} else if !req.IsAddTenant {
} else if !req.IsAddTenant && representativeTenantRequested {
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
traits["tenant_id"] = tenant.ID
resolvedTenant = tenant
} else {
traits["tenant_id"] = ""
}
@@ -2652,6 +2756,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err != nil || tenant == nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), tenant.ID, tenant.Slug, tenant); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
if h.KetoOutboxRepo != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
@@ -2663,6 +2770,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
})
}
}
if !req.IsRemoveTenant && !req.IsAddTenant && representativeTenantRequested {
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), code, resolvedTenant); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
}
delete(traits, "companyCode")
delete(traits, "companyCodes")
@@ -2719,6 +2831,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["secondary_emails"] = subEmails
traits["worksmobileAliasEmails"] = subEmails
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), "", nil); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
// [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values
@@ -2773,10 +2888,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
if err := h.Worksmobile.EnqueueUserUpdateIfInScope(ctx, *updatedLocalUser); err != nil {
slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err)
}
}
@@ -2784,7 +2898,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// Update User Login IDs in local DB
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
// [Keto Sync] asynchronously as it's less critical for immediate UI count
@@ -2963,7 +3076,6 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
if h.UserRepo != nil {
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
} else {
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
@@ -171,6 +172,7 @@ func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context,
type fakeUserHandlerWorksmobileSyncer struct {
upserts []domain.User
updates []domain.User
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
@@ -186,6 +188,11 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx contex
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
f.updates = append(f.updates, user)
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
return nil
}
@@ -206,6 +213,18 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
assert.Contains(t, metadata, "userType")
}
func TestIdentityMatchesSearchFindsSameEmailLocalPartAcrossHanmacFamilyDomains(t *testing.T) {
identity := service.KratosIdentity{
ID: "user-han",
Traits: map[string]any{
"email": "han@samaneng.com",
"name": "안헌",
},
}
require.True(t, identityMatchesSearch(identity, "han@hanmaceng.co.kr"))
}
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
internalTenantID := "internal-tenant"
@@ -249,6 +268,44 @@ func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
mockTenant.AssertExpectations(t)
}
func TestSanitizeUserRepresentativeTenantsAllowsPublicTeamOrgPrimary(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
teamTenantID := "team-tenant"
metadata := map[string]any{
"primaryTenantId": teamTenantID,
"primaryTenantName": "IS3",
"primaryTenantSlug": "is-3",
"additionalAppointments": []any{
map[string]any{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
},
}
appointments := []map[string]any{
{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
}
mockTenant.On("GetTenant", mock.Anything, teamTenantID).Return(&domain.Tenant{
ID: teamTenantID,
Slug: "is-3",
Config: domain.JSONMap{
"visibility": "public",
"orgUnitType": "팀",
},
}, nil)
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
require.NoError(t, err)
assert.False(t, cleared)
assert.Equal(t, teamTenantID, metadata["primaryTenantId"])
assert.Equal(t, "IS3", metadata["primaryTenantName"])
assert.Equal(t, "is-3", metadata["primaryTenantSlug"])
assert.Equal(t, true, appointments[0]["isPrimary"])
metadataAppointments := metadata["additionalAppointments"].([]any)
firstAppointment := metadataAppointments[0].(map[string]any)
assert.Equal(t, true, firstAppointment["isPrimary"])
mockTenant.AssertExpectations(t)
}
type MockTenantServiceForUser struct {
mock.Mock
service.TenantService
@@ -292,11 +349,16 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
}
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
args := m.Called(ctx, limit, offset, parentID, search)
if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2)
for _, call := range m.ExpectedCalls {
if call.Method == "ListTenants" {
args := m.Called(ctx, limit, offset, parentID, search)
if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2)
}
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
}
}
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
return nil, 0, nil
}
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
@@ -659,6 +721,59 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
})
}
func TestUserHandler_BulkCreateUsersDoesNotAutoProvisionWorksmobileUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
worksmobile := &fakeUserHandlerWorksmobileSyncer{}
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
UserRepo: mockRepo,
Worksmobile: worksmobile,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "user1@test.com",
"name": "User One",
"tenantSlug": "test-tenant",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
assert.Empty(t, worksmobile.upserts)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsersRejectsRequestedUserID(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1135,6 +1250,84 @@ func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *te
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
createdAt := time.Date(2026, 6, 15, 4, 55, 0, 0, time.UTC)
primaryTenantID := "primary-tenant-id"
targetTenantID := "target-tenant-id"
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "target-team").Return(&domain.Tenant{
ID: targetTenantID,
Slug: "target-team",
Name: "Target Team",
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
ID: primaryTenantID,
Slug: "primary-team",
Name: "Primary Team",
}, nil).Maybe()
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{
ID: "additional-member",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "additional@example.com",
"name": "Additional Member",
"tenant_id": primaryTenantID,
"additionalAppointments": []any{
map[string]any{
"tenantId": targetTenantID,
"tenantSlug": "target-team",
"tenantName": "Target Team",
},
},
},
},
{
ID: "outside-member",
State: "active",
CreatedAt: createdAt.Add(-time.Minute),
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "outside@example.com",
"name": "Outside Member",
"tenant_id": primaryTenantID,
},
},
}, nil).Once()
req := httptest.NewRequest("GET", "/users?tenantSlug=target-team&limit=20&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res userListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Equal(t, int64(1), res.Total)
require.Len(t, res.Items, 1)
require.Equal(t, "additional-member", res.Items[0].ID)
require.Equal(t, "additional@example.com", res.Items[0].Email)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
@@ -1540,6 +1733,91 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
})
}
func TestNextAvailableHanmacLocalPartIncrementsTrailingNumericSuffix(t *testing.T) {
used := map[string]hanmacLocalPartOwner{
"jhchoi11": {Email: "jhchoi11@hanmaceng.co.kr"},
"jhchoi12": {Email: "jhchoi12@hallasanup.com"},
"yskim11": {Email: "yskim11@hanmaceng.co.kr"},
"yskim12": {Email: "yskim12@hanmaceng.co.kr"},
"yskim13": {Email: "yskim13@hanmaceng.co.kr"},
}
assert.Equal(t, "jhchoi13", nextAvailableHanmacLocalPart("jhchoi11", used))
assert.Equal(t, "yskim14", nextAvailableHanmacLocalPart("yskim11", used))
assert.Equal(t, "mjkim", nextAvailableHanmacLocalPart("mjkim", used))
}
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"users":[{"email":"internal@jangheon.co.kr","name":"Internal User","tenantSlug":"personal-team"}]}`
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload map[string][]map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload["results"], 1)
require.Equal(t, false, payload["results"][0]["success"])
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockTenant.On("GetTenantByDomain", mock.Anything, "pre-cast.co.kr").Return(nil, nil)
body := `{"users":[{"email":"internal@pre-cast.co.kr","name":"Internal User"}]}`
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload map[string][]map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload["results"], 1)
require.Equal(t, false, payload["results"][0]["success"])
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1570,7 +1848,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
}, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
{ID: "owner-user", Email: "han@hanmaceng.co.kr", Name: "안헌", CompanyCode: "hanmac", TenantID: &companyID},
}, nil).Maybe()
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Maybe()
@@ -1583,12 +1861,23 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var logBuffer bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, nil)))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
resp, _ := app.Test(req)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
assert.Contains(t, result["error"].(string), "han")
assert.Contains(t, result["error"].(string), "han@hanmaceng.co.kr")
assert.Contains(t, result["error"].(string), "안헌")
assert.Contains(t, logBuffer.String(), "hanmac create email local-part conflict")
assert.Contains(t, logBuffer.String(), "owner-user")
assert.Contains(t, logBuffer.String(), "han@hanmaceng.co.kr")
mockOry.AssertNotCalled(t, "CreateUser")
}
@@ -1635,9 +1924,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.True(t, results[0].(map[string]any)["success"].(bool))
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
assert.Len(t, worksmobile.updates, 1)
assert.Equal(t, "u-1", worksmobile.updates[0].ID)
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.updates[0].Status)
})
t.Run("Success - Super admin assigns legacy roles as user", func(t *testing.T) {
@@ -1682,10 +1971,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
KetoOutboxRepo: mockOutbox,
UserRepo: mockRepo,
}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
@@ -1735,6 +2026,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
},
}, nil).Once()
mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(user *domain.User) bool {
return user != nil &&
user.ID == "u-1" &&
user.TenantID != nil &&
*user.TenantID == "primary-tenant-id"
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "team-a-id" &&
@@ -1753,6 +2050,7 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
@@ -2258,6 +2556,73 @@ func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUserRejectsHanmacDuplicateLocalPart(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
UserRepo: mockRepo,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
rootID := "hanmac-family-id"
targetTenantID := "brsw-id"
ownerTenantID := "hanmac-id"
tenants := []domain.Tenant{
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
{ID: targetTenantID, Slug: "brsw", Name: "바론", ParentID: &rootID},
{ID: ownerTenantID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
}
userID := "target-user-id"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "jmhwang11@brsw.kr",
"name": "황재민",
"role": domain.RoleUser,
"tenant_id": targetTenantID,
},
State: "active",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
State: "active",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, targetTenantID).Return(&tenants[1], nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
return slices.Contains(ids, targetTenantID) && slices.Contains(ids, ownerTenantID)
})).Return([]domain.User{
{ID: "owner-user-id", Email: "jmhwang2@hanmaceng.co.kr", Name: "황지만", TenantID: &ownerTenantID, CompanyCode: "hanmac"},
}, nil).Maybe()
mockRepo.On("FindByCompanyCodes", mock.Anything, mock.MatchedBy(func(slugs []string) bool {
return slices.Contains(slugs, "brsw") && slices.Contains(slugs, "hanmac")
})).Return([]domain.User{}, nil).Maybe()
body, _ := json.Marshal(map[string]interface{}{"email": "jmhwang2@brsw.kr"})
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode)
var result map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
message, _ := result["error"].(string)
assert.Contains(t, message, "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
assert.Contains(t, message, "jmhwang2")
assert.Contains(t, message, "jmhwang2@hanmaceng.co.kr")
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -2769,9 +3134,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
resp, _ := app.Test(req)
assert.Equal(t, 201, resp.StatusCode)
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "some-id", worksmobile.upserts[0].ID)
assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID)
assert.Empty(t, worksmobile.upserts)
mockOry.AssertExpectations(t)
}
@@ -2850,6 +3213,66 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
mockKratos.AssertExpectations(t)
}
func TestUserHandler_CreateUserRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
body := `{"email":"internal@hanmaceng.co.kr","password":"Password1!","name":"Internal User"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
}
func TestUserHandler_CreateUserRejectsInternalDomainExplicitPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
personalTenantID := "personal-tenant-id"
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: personalTenantID,
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"email":"internal@samaneng.com","password":"Password1!","name":"Internal User","tenantSlug":"personal-team"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -2925,9 +3348,9 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
ID: "old-tenant-id",
Slug: "old-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
@@ -2936,7 +3359,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Maybe()
@@ -2952,6 +3375,100 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUserRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/:id", h.UpdateUser)
identity := &service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@brsw.kr",
"name": "Internal User",
"tenant_id": "company-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"tenantSlug":"personal-team"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUserRejectsPersonalTenantInternalDomainEmail(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/:id", h.UpdateUser)
identity := &service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "external@example.com",
"name": "External User",
"tenant_id": "personal-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
mockTenant.On("GetTenant", mock.Anything, "personal-tenant-id").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"email":"user@hallasanup.com"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -3272,6 +3789,163 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
}
func TestUserHandler_UpdateUserTenantSlugWithoutPrimaryFlagKeepsRepresentativeTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
ID: "old-tenant-id",
Slug: "old-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
return extractTraitString(traits, "tenant_id") == "old-tenant-id"
}), mock.Anything).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"tenantSlug":"new-tenant"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUserPrimaryTenantFlagChangesRepresentativeTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
return extractTraitString(traits, "tenant_id") == "new-tenant-id"
}), mock.Anything).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"tenantSlug":"new-tenant","isPrimaryTenant":true}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/bulk", h.BulkUpdateUsers)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@brsw.kr",
"name": "Internal User",
"tenant_id": "company-tenant-id",
"role": domain.RoleUser,
},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"userIds":["user-id"],"tenantSlug":"personal-team"}`
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload map[string][]map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload["results"], 1)
require.Equal(t, false, payload["results"][0]["success"])
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{

View File

@@ -1,16 +0,0 @@
package handler
import (
"baron-sso-backend/internal/repository"
"context"
"log/slog"
)
func markUserProjectionFailed(ctx context.Context, repo repository.UserProjectionRepository, syncErr error) {
if repo == nil || syncErr == nil {
return
}
if err := repo.MarkFailed(ctx, syncErr); err != nil {
slog.Error("Failed to mark user projection as failed", "syncError", syncErr, "error", err)
}
}

View File

@@ -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{}, &domain.WorksmobileOutbox{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

View File

@@ -26,26 +26,76 @@ WHERE u.deleted_at IS NULL
}
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
result := db.WithContext(ctx).Exec(`
userResult := db.WithContext(ctx).Exec(`
WITH orphan_users AS (
SELECT u.id
SELECT u.id AS user_id,
replacement.id AS replacement_tenant_id
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
JOIN tenants AS deleted_tenant
ON deleted_tenant.id = u.tenant_id
AND deleted_tenant.deleted_at IS NOT NULL
JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
FROM tenants AS parent
WHERE parent.id = deleted_tenant.parent_id
UNION ALL
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
FROM tenants AS parent
JOIN ancestors ON parent.id = ancestors.parent_id
WHERE ancestors.parent_id IS NOT NULL
AND ancestors.parent_id <> ancestors.id
)
)
SELECT id
FROM ancestors
WHERE deleted_at IS NULL
ORDER BY depth
LIMIT 1
) AS replacement ON true
WHERE u.deleted_at IS NULL
AND u.tenant_id IS NOT NULL
)
UPDATE users AS u
SET tenant_id = NULL,
SET tenant_id = ou.replacement_tenant_id,
updated_at = NOW()
FROM orphan_users AS ou
WHERE u.id = ou.id
WHERE u.id = ou.user_id
`)
return result.RowsAffected, result.Error
if userResult.Error != nil {
return userResult.RowsAffected, userResult.Error
}
loginResult := db.WithContext(ctx).Exec(`
WITH orphan_login_ids AS (
SELECT uli.id AS login_id,
replacement.id AS replacement_tenant_id
FROM user_login_ids AS uli
JOIN tenants AS deleted_tenant
ON deleted_tenant.id = uli.tenant_id
AND deleted_tenant.deleted_at IS NOT NULL
JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
FROM tenants AS parent
WHERE parent.id = deleted_tenant.parent_id
UNION ALL
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
FROM tenants AS parent
JOIN ancestors ON parent.id = ancestors.parent_id
WHERE ancestors.parent_id IS NOT NULL
AND ancestors.parent_id <> ancestors.id
)
SELECT id
FROM ancestors
WHERE deleted_at IS NULL
ORDER BY depth
LIMIT 1
) AS replacement ON true
)
UPDATE user_login_ids AS uli
SET tenant_id = oli.replacement_tenant_id
FROM orphan_login_ids AS oli
WHERE uli.id = oli.login_id
`)
return userResult.RowsAffected + loginResult.RowsAffected, loginResult.Error
}

View File

@@ -20,7 +20,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
require.NoError(t, testDB.Unscoped().Where("slug IN ?", []string{"orphan-active", "orphan-deleted"}).Delete(&domain.Tenant{}).Error)
activeTenant := &domain.Tenant{Name: "Active Tenant", Slug: "orphan-active", Type: domain.TenantTypeCompany}
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeCompany}
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeUserGroup, ParentID: &activeTenant.ID}
require.NoError(t, tenantRepo.Create(ctx, activeTenant))
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
@@ -39,6 +39,13 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
}
require.NoError(t, repo.Create(ctx, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser))
loginID := &domain.UserLoginID{
UserID: orphanUser.ID,
TenantID: deletedTenant.ID,
FieldKey: "employee_number",
LoginID: "orphan-membership-login",
}
require.NoError(t, testDB.Create(loginID).Error)
count, err := CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
@@ -46,7 +53,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(1), affected)
assert.Equal(t, int64(2), affected)
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
require.NoError(t, err)
@@ -56,7 +63,12 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
require.NoError(t, err)
assert.Nil(t, foundOrphan.TenantID)
require.NotNil(t, foundOrphan.TenantID)
assert.Equal(t, activeTenant.ID, *foundOrphan.TenantID)
var foundLogin domain.UserLoginID
require.NoError(t, testDB.First(&foundLogin, "id = ?", loginID.ID).Error)
assert.Equal(t, activeTenant.ID, foundLogin.TenantID)
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)

View File

@@ -1,227 +0,0 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserProjectionRepository interface {
IsReady(ctx context.Context) (bool, error)
GetStatus(ctx context.Context) (domain.UserProjectionStatus, error)
CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
ReplaceAllFromKratos(ctx context.Context, users []domain.User) error
MarkFailed(ctx context.Context, syncErr error) error
}
type userProjectionRepository struct {
db *gorm.DB
}
func NewUserProjectionRepository(db *gorm.DB) UserProjectionRepository {
return &userProjectionRepository{db: db}
}
func (r *userProjectionRepository) IsReady(ctx context.Context) (bool, error) {
status, err := r.GetStatus(ctx)
if err != nil {
return false, err
}
return status.Ready, nil
}
func (r *userProjectionRepository) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
var projectedUsers int64
if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&projectedUsers).Error; err != nil {
return domain.UserProjectionStatus{}, err
}
var state domain.UserProjectionState
err := r.db.WithContext(ctx).First(&state, "name = ?", domain.UserProjectionNameKratos).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusFailed,
Ready: false,
ProjectedUsers: projectedUsers,
}, nil
}
if err != nil {
return domain.UserProjectionStatus{}, err
}
return domain.UserProjectionStatus{
Name: state.Name,
Status: state.Status,
Ready: state.Status == domain.UserProjectionStatusReady && state.LastSyncedAt != nil,
LastSyncedAt: state.LastSyncedAt,
LastError: state.LastError,
UpdatedAt: &state.UpdatedAt,
ProjectedUsers: projectedUsers,
}, nil
}
func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
}
if len(tenants) == 0 {
return counts, nil
}
valuePlaceholders := make([]string, 0, len(tenants))
args := make([]any, 0, len(tenants)*2)
for _, tenant := range tenants {
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
}
query := fmt.Sprintf(`
WITH requested(tenant_id, slug) AS (
VALUES %s
)
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
FROM requested
LEFT JOIN users ON users.deleted_at IS NULL AND (
users.tenant_id::text = requested.tenant_id
)
GROUP BY requested.tenant_id
`, strings.Join(valuePlaceholders, ","))
type result struct {
TenantID string
Count int64
}
var rows []result
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
counts[row.TenantID] = row.Count
}
return counts, nil
}
func (r *userProjectionRepository) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
}
if len(tenants) == 0 {
return counts, nil
}
valuePlaceholders := make([]string, 0, len(tenants))
args := make([]any, 0, len(tenants))
for _, tenant := range tenants {
valuePlaceholders = append(valuePlaceholders, "(?)")
args = append(args, strings.TrimSpace(tenant.ID))
}
query := fmt.Sprintf(`
WITH RECURSIVE requested(tenant_id) AS (
VALUES %s
),
descendants(root_tenant_id, tenant_id) AS (
SELECT requested.tenant_id, requested.tenant_id
FROM requested
UNION ALL
SELECT descendants.root_tenant_id, child.id::text
FROM descendants
JOIN tenants child
ON child.parent_id::text = descendants.tenant_id
AND child.deleted_at IS NULL
)
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
FROM requested
LEFT JOIN descendants
ON descendants.root_tenant_id = requested.tenant_id
LEFT JOIN users
ON users.deleted_at IS NULL
AND users.tenant_id::text = descendants.tenant_id
GROUP BY requested.tenant_id
`, strings.Join(valuePlaceholders, ","))
type result struct {
TenantID string
Count int64
}
var rows []result
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
counts[row.TenantID] = row.Count
}
return counts, nil
}
func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
now := time.Now()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i := range users {
users[i].DeletedAt = gorm.DeletedAt{}
if users[i].CreatedAt.IsZero() {
users[i].CreatedAt = now
}
if users[i].UpdatedAt.IsZero() {
users[i].UpdatedAt = now
}
}
if len(users) > 0 {
// [FIX] Handle email conflicts before bulk upsert
for _, u := range users {
if u.Email != "" {
// Hard-delete any record with same email but different ID to clear unique constraint
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
}
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).Create(&users).Error; err != nil {
return err
}
}
return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "")
})
}
func (r *userProjectionRepository) MarkFailed(ctx context.Context, syncErr error) error {
message := ""
if syncErr != nil {
message = syncErr.Error()
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return upsertUserProjectionState(tx, domain.UserProjectionStatusFailed, nil, message)
})
}
func upsertUserProjectionState(tx *gorm.DB, status string, syncedAt *time.Time, lastError string) error {
state := domain.UserProjectionState{
Name: domain.UserProjectionNameKratos,
Status: status,
LastSyncedAt: syncedAt,
LastError: lastError,
UpdatedAt: time.Now(),
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{
"status",
"last_synced_at",
"last_error",
"updated_at",
}),
}).Create(&state).Error
}

View File

@@ -1,168 +0,0 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyWithoutDeletingUsersMissingFromPartialList(t *testing.T) {
ctx := context.Background()
repo := NewUserProjectionRepository(testDB)
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
tenantID := "10000000-0000-0000-0000-000000000001"
tenantSlug := "projection-saman"
require.NoError(t, testDB.Create(&domain.Tenant{
ID: tenantID,
Name: "Projection Saman",
Slug: tenantSlug,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}).Error)
existing := &domain.User{
ID: "00000000-0000-0000-0000-000000000099",
Email: "existing@example.com",
Name: "Existing",
CompanyCode: tenantSlug,
TenantID: &tenantID,
}
require.NoError(t, NewUserRepository(testDB).Create(ctx, existing))
users := []domain.User{
{
ID: "00000000-0000-0000-0000-000000000101",
Email: "one@example.com",
Name: "One",
CompanyCode: tenantSlug,
TenantID: &tenantID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "00000000-0000-0000-0000-000000000102",
Email: "two@example.com",
Name: "Two",
TenantID: &tenantID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
require.NoError(t, repo.ReplaceAllFromKratos(ctx, users))
ready, err := repo.IsReady(ctx)
require.NoError(t, err)
assert.True(t, ready)
counts, err := repo.CountTenantMembers(ctx, []domain.Tenant{
{ID: tenantID, Slug: tenantSlug},
})
require.NoError(t, err)
assert.Equal(t, int64(3), counts[tenantID])
var activeCount int64
require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error)
assert.Equal(t, int64(3), activeCount)
var existingCount int64
require.NoError(t, testDB.Model(&domain.User{}).Where("id = ?", existing.ID).Count(&existingCount).Error)
assert.Equal(t, int64(1), existingCount)
var existingRow domain.User
require.NoError(t, testDB.Unscoped().First(&existingRow, "id = ?", existing.ID).Error)
assert.False(t, existingRow.DeletedAt.Valid)
}
func TestUserProjectionRepository_CountTenantMembersRecursiveIncludesDescendantsAndExcludesSoftDeletedUsers(t *testing.T) {
ctx := context.Background()
repo := NewUserProjectionRepository(testDB)
parentID := "20000000-0000-0000-0000-000000000001"
childID := "20000000-0000-0000-0000-000000000002"
grandchildID := "20000000-0000-0000-0000-000000000003"
siblingID := "20000000-0000-0000-0000-000000000004"
tenantIDs := []string{parentID, childID, grandchildID, siblingID}
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
require.NoError(t, testDB.Unscoped().Where("id IN ?", tenantIDs).Delete(&domain.Tenant{}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: parentID,
Name: "Recursive Parent",
Slug: "recursive-parent",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: childID,
Name: "Recursive Child",
Slug: "recursive-child",
Type: domain.TenantTypeOrganization,
Status: domain.TenantStatusActive,
ParentID: &parentID,
}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: grandchildID,
Name: "Recursive Grandchild",
Slug: "recursive-grandchild",
Type: domain.TenantTypeUserGroup,
Status: domain.TenantStatusActive,
ParentID: &childID,
}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: siblingID,
Name: "Recursive Sibling",
Slug: "recursive-sibling",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}).Error)
users := []domain.User{
{ID: "21000000-0000-0000-0000-000000000001", Email: "parent@example.com", Name: "Parent", TenantID: &parentID},
{ID: "21000000-0000-0000-0000-000000000002", Email: "child@example.com", Name: "Child", TenantID: &childID},
{ID: "21000000-0000-0000-0000-000000000003", Email: "grandchild@example.com", Name: "Grandchild", TenantID: &grandchildID},
{ID: "21000000-0000-0000-0000-000000000004", Email: "deleted-grandchild@example.com", Name: "Deleted Grandchild", TenantID: &grandchildID},
{ID: "21000000-0000-0000-0000-000000000005", Email: "sibling@example.com", Name: "Sibling", TenantID: &siblingID},
}
for i := range users {
require.NoError(t, testDB.Create(&users[i]).Error)
}
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", users[3].ID).Error)
directCounts, err := repo.CountTenantMembers(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
require.NoError(t, err)
assert.Equal(t, int64(1), directCounts[parentID])
assert.Equal(t, int64(1), directCounts[childID])
assert.Equal(t, int64(1), directCounts[grandchildID])
assert.Equal(t, int64(1), directCounts[siblingID])
recursiveCounts, err := repo.CountTenantMembersRecursive(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
require.NoError(t, err)
assert.Equal(t, int64(3), recursiveCounts[parentID])
assert.Equal(t, int64(2), recursiveCounts[childID])
assert.Equal(t, int64(1), recursiveCounts[grandchildID])
assert.Equal(t, int64(1), recursiveCounts[siblingID])
}
func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) {
ctx := context.Background()
repo := NewUserProjectionRepository(testDB)
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
require.NoError(t, repo.MarkFailed(ctx, errors.New("kratos down")))
ready, err := repo.IsReady(ctx)
require.NoError(t, err)
assert.False(t, ready)
}

View File

@@ -272,7 +272,12 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
}
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&domain.User{}, "id = ?", id).Error
})
}
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestUserRepository(t *testing.T) {
@@ -95,8 +96,14 @@ func TestUserRepository(t *testing.T) {
})
t.Run("Delete User", func(t *testing.T) {
require.NoError(t, testDB.AutoMigrate(&domain.UserLoginID{}))
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users WHERE email = ?", "delete@example.com").Error)
user := &domain.User{Email: "delete@example.com", Name: "To Delete"}
_ = repo.Create(ctx, user)
require.NoError(t, repo.Create(ctx, user))
require.NoError(t, repo.UpdateUserLoginIDs(ctx, user.ID, []domain.UserLoginID{
{UserID: user.ID, TenantID: uuid.NewString(), FieldKey: "employee_id", LoginID: "DELETE001"},
}))
err := repo.Delete(ctx, user.ID)
assert.NoError(t, err)
@@ -104,6 +111,14 @@ func TestUserRepository(t *testing.T) {
found, err := repo.FindByEmail(ctx, "delete@example.com")
assert.Error(t, err) // Should not be found
assert.Nil(t, found)
var hardDeleted domain.User
err = testDB.Unscoped().Where("id = ?", user.ID).First(&hardDeleted).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
var loginIDCount int64
require.NoError(t, testDB.Unscoped().Model(&domain.UserLoginID{}).Where("user_id = ?", user.ID).Count(&loginIDCount).Error)
require.Zero(t, loginIDCount)
})
t.Run("CountByCompanyCodes", func(t *testing.T) {

View File

@@ -102,6 +102,44 @@ func (s *RedisService) Delete(key string) error {
return s.Client.Del(ctx, key).Err()
}
func (s *RedisService) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
if s == nil || s.Client == nil {
return 0, os.ErrInvalid
}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return 0, os.ErrInvalid
}
var deleted int64
var cursor uint64
pattern := prefix + "*"
for {
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
if err != nil {
return deleted, err
}
for len(keys) > 0 {
chunkSize := len(keys)
if chunkSize > 500 {
chunkSize = 500
}
chunk := keys[:chunkSize]
count, err := s.Client.Del(ctx, chunk...).Result()
if err != nil {
return deleted, err
}
deleted += count
keys = keys[chunkSize:]
}
cursor = next
if cursor == 0 {
break
}
}
return deleted, nil
}
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
if s == nil || s.Client == nil {
return domain.IdentityCacheStatus{

View File

@@ -134,6 +134,27 @@ func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *
}, stub.deleted)
}
func TestRedisServiceDeleteByPrefixScansAndDeletesMatchingKeys(t *testing.T) {
stub := &redisCommandStub{
scans: map[string][]string{
"orgchart:snapshot:v1:*": {
"orgchart:snapshot:v1:super_admin:all:none",
"orgchart:snapshot:v1:user:user-1:tenant-1",
},
},
}
service := newStubbedRedisService(stub)
deleted, err := service.DeleteByPrefix(context.Background(), "orgchart:snapshot:v1:")
require.NoError(t, err)
require.Equal(t, int64(2), deleted)
require.ElementsMatch(t, []string{
"orgchart:snapshot:v1:super_admin:all:none",
"orgchart:snapshot:v1:user:user-1:tenant-1",
}, stub.deleted)
}
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())

View File

@@ -243,7 +243,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
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 {
if err := s.worksmobile.EnqueueUserUpdateIfInScope(ctx, *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
}
}

View File

@@ -156,6 +156,11 @@ func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpdateIfInScope(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
}

View File

@@ -1,153 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"fmt"
"strings"
"time"
)
type UserProjectionSyncService struct {
kratos KratosAdminService
repo repository.UserProjectionRepository
}
type UserProjectionReconciler interface {
Reconcile(ctx context.Context) (int, error)
}
func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService {
return &UserProjectionSyncService{
kratos: kratos,
repo: repo,
}
}
func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) {
if s == nil || s.kratos == nil || s.repo == nil {
return 0, fmt.Errorf("user projection sync dependencies are not configured")
}
identities, err := s.kratos.ListIdentities(ctx)
if err != nil {
_ = s.repo.MarkFailed(ctx, err)
return 0, err
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
users = append(users, MapKratosIdentityToLocalUser(identity))
}
if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil {
_ = s.repo.MarkFailed(ctx, err)
return 0, err
}
return len(users), nil
}
func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
traits := identity.Traits
now := time.Now()
createdAt := identity.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
updatedAt := identity.UpdatedAt
if updatedAt.IsZero() {
updatedAt = now
}
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
if !ok {
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
if !ok {
role = domain.RoleUser
}
}
grade := kratosProjectionTraitString(traits, "grade")
if _, ok := domain.NormalizeRoleAlias(grade); ok {
grade = ""
}
user := domain.User{
ID: identity.ID,
Email: kratosProjectionTraitString(traits, "email"),
Name: kratosProjectionTraitString(traits, "name"),
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
Role: role,
Status: normalizeProjectionStatus(identity.State),
Department: kratosProjectionTraitString(traits, "department"),
Grade: grade,
Position: kratosProjectionTraitString(traits, "position"),
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Metadata: make(domain.JSONMap),
}
if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" {
user.TenantID = &tenantID
}
if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" {
user.RelyingPartyID = &relyingPartyID
}
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "role": true,
"companyCode": true, "company_code": true, "companyCodes": true,
"tenant_id": true, "department": true,
"position": true, "jobTitle": true, "affiliationType": true,
"relying_party_id": true, "custom_login_ids": true, "id": true,
}
for key, value := range traits {
if !coreTraits[key] {
user.Metadata[key] = value
}
}
return user
}
func kratosProjectionTraitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
value, ok := traits[key]
if !ok || value == nil {
return ""
}
if str, ok := value.(string); ok {
return str
}
return fmt.Sprint(value)
}
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
if traits == nil {
return nil
}
switch value := traits[key].(type) {
case []string:
return value
case []any:
items := make([]string, 0, len(value))
for _, item := range value {
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
items = append(items, str)
}
}
return items
default:
return nil
}
}
func normalizeProjectionStatus(state string) string {
normalized := domain.NormalizeUserStatus(state)
if normalized == "" {
return domain.UserStatusActive
}
return normalized
}

View File

@@ -1,142 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeUserProjectionRepo struct {
replacedUsers []domain.User
failedErr error
replaceErr error
}
func (f *fakeUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
return false, nil
}
func (f *fakeUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
return domain.UserProjectionStatus{}, nil
}
func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
f.replacedUsers = append([]domain.User(nil), users...)
return f.replaceErr
}
func (f *fakeUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
f.failedErr = syncErr
return nil
}
func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
tenantID := "00000000-0000-0000-0000-000000000001"
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{
ID: "00000000-0000-0000-0000-000000000101",
Traits: map[string]any{
"email": "one@example.com",
"name": "One",
"phone_number": "+821012345678",
"companyCode": "saman",
"companyCodes": []any{"saman", "group-a"},
"tenant_id": tenantID,
"department": "DX",
"customAttr": "kept",
},
State: "active",
},
}, nil).Once()
count, err := svc.Reconcile(ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
require.Len(t, repo.replacedUsers, 1)
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
assert.Equal(t, "One", repo.replacedUsers[0].Name)
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
require.NotNil(t, repo.replacedUsers[0].TenantID)
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])
assert.NoError(t, repo.failedErr)
kratos.AssertExpectations(t)
}
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{
ID: "00000000-0000-0000-0000-000000000102",
Traits: map[string]any{
"email": "two@example.com",
"name": "Two",
"phone_number": "+82 +821091917771",
},
State: "active",
},
}, nil).Once()
count, err := svc.Reconcile(ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
require.Len(t, repo.replacedUsers, 1)
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
kratos.AssertExpectations(t)
}
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
expectedErr := errors.New("kratos down")
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{}, expectedErr).Once()
count, err := svc.Reconcile(ctx)
assert.Equal(t, 0, count)
assert.ErrorIs(t, err, expectedErr)
assert.ErrorIs(t, repo.failedErr, expectedErr)
assert.Empty(t, repo.replacedUsers)
kratos.AssertExpectations(t)
}
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
user := MapKratosIdentityToLocalUser(KratosIdentity{
ID: "00000000-0000-0000-0000-000000000201",
State: domain.UserStatusArchived,
Traits: map[string]any{
"email": "archived@example.com",
"name": "Archived User",
},
})
assert.Equal(t, domain.UserStatusArchived, user.Status)
}

View File

@@ -14,6 +14,7 @@ import (
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
@@ -33,6 +34,7 @@ type WorksmobileDirectoryClient interface {
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error
AddUserAliasEmail(ctx context.Context, userID string, email string) error
ResetUserPassword(ctx context.Context, userID string, password string) error
DeleteUser(ctx context.Context, userID string) error
@@ -324,17 +326,14 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
payload = normalizeWorksmobileUserCreatePayload(payload)
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
}
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
err := c.CreateUser(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
identifier := strings.TrimSpace(payload.Email)
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
}
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
if patchErr := c.updateUserByPatchOnly(ctx, payload); patchErr != nil {
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
}
return nil
@@ -342,6 +341,163 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
return err
}
func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
return c.updateUserByPatchOnly(ctx, payload)
}
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
identifier := strings.TrimSpace(payload.Email)
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
}
patchPayload := NewWorksmobileUserPatchPayload(payload)
if patchErr := c.PatchUser(ctx, identifier, patchPayload); patchErr != nil {
externalKey := strings.TrimSpace(payload.UserExternalKey)
if patchAPIError, ok := patchErr.(WorksmobileHTTPError); ok && patchAPIError.StatusCode == http.StatusNotFound && externalKey != "" && externalKey != identifier {
if externalKeyPatchErr := c.PatchUser(ctx, externalKey, patchPayload); externalKeyPatchErr == nil {
return nil
} else {
if externalKeyPatchAPIError, ok := externalKeyPatchErr.(WorksmobileHTTPError); ok && externalKeyPatchAPIError.StatusCode == http.StatusNotFound {
if lookupPatchErr := c.patchUserByExternalKeyLookup(ctx, externalKey, payload.DomainID, patchPayload); lookupPatchErr == nil {
return nil
} else {
return fmt.Errorf("patch failed: %w; external key patch failed: %v; external key lookup patch failed: %v", patchErr, externalKeyPatchErr, lookupPatchErr)
}
}
return fmt.Errorf("patch failed: %w; external key patch failed: %v", patchErr, externalKeyPatchErr)
}
}
return patchErr
}
return nil
}
func (c *WorksmobileHTTPClient) patchUserByExternalKeyLookup(ctx context.Context, externalKey string, requestedDomainID int64, payload WorksmobileUserPatchPayload) error {
externalKey = strings.TrimSpace(externalKey)
if externalKey == "" {
return fmt.Errorf("worksmobile user external key is required")
}
matches, err := c.findUsersByExternalKey(ctx, externalKey, requestedDomainID)
if err != nil {
return err
}
if len(matches) == 0 {
return fmt.Errorf("worksmobile user external key match not found after create conflict: %s", externalKey)
}
if len(matches) > 1 {
domainIDs := worksmobileRemoteUserDomainIDs(matches)
userIDs := worksmobileRemoteUserIDs(matches)
slog.Error(
"Worksmobile external key matched multiple users during upsert conflict recovery",
"externalKey", externalKey,
"requestedDomainID", requestedDomainID,
"domainIDs", domainIDs,
"userIDs", userIDs,
"matchCount", len(matches),
)
return fmt.Errorf("multiple worksmobile users matched external key: externalKey=%s requestedDomainID=%d domainIDs=%v userIDs=%v", externalKey, requestedDomainID, domainIDs, userIDs)
}
remote := matches[0]
identifiers := compactUniqueStrings(remote.ID, remote.Email, remote.UserName)
if len(identifiers) == 0 {
return fmt.Errorf("worksmobile user external key match has no patch identifier: %s", externalKey)
}
var lastErr error
for _, identifier := range identifiers {
if err := c.PatchUser(ctx, identifier, payload); err != nil {
lastErr = err
continue
}
return nil
}
return lastErr
}
func (c *WorksmobileHTTPClient) findUsersByExternalKey(ctx context.Context, externalKey string, requestedDomainID int64) ([]WorksmobileRemoteUser, error) {
externalKey = strings.TrimSpace(externalKey)
domainIDs := worksmobileExternalKeyLookupDomainIDs(requestedDomainID, c.DomainIDs)
if c.directoryAuthConfigured() && len(domainIDs) > 0 {
matches := make([]WorksmobileRemoteUser, 0, 1)
for _, domainID := range domainIDs {
users, err := c.listDirectoryUsers(ctx, []int64{domainID})
if err != nil {
return nil, err
}
for _, user := range users {
if user.ExternalID == externalKey {
matches = append(matches, user)
}
}
}
return matches, nil
}
users, err := c.ListUsers(ctx)
if err != nil {
return nil, err
}
matches := make([]WorksmobileRemoteUser, 0, 1)
for _, user := range users {
if user.ExternalID == externalKey {
matches = append(matches, user)
}
}
return matches, nil
}
func worksmobileExternalKeyLookupDomainIDs(requestedDomainID int64, configuredDomainIDs []int64) []int64 {
domainIDs := make([]int64, 0, len(configuredDomainIDs)+1)
if requestedDomainID > 0 {
domainIDs = append(domainIDs, requestedDomainID)
}
domainIDs = append(domainIDs, configuredDomainIDs...)
return uniqueWorksmobileDomainIDs(domainIDs)
}
func worksmobileRemoteUserDomainIDs(users []WorksmobileRemoteUser) []int64 {
domainIDs := make([]int64, 0, len(users))
for _, user := range users {
domainIDs = append(domainIDs, user.DomainID)
}
return uniqueWorksmobileDomainIDs(domainIDs)
}
func worksmobileRemoteUserIDs(users []WorksmobileRemoteUser) []string {
ids := make([]string, 0, len(users))
for _, user := range users {
if id := strings.TrimSpace(user.ID); id != "" {
ids = append(ids, id)
}
}
return compactUniqueStrings(ids...)
}
func compactUniqueStrings(values ...string) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeWorksmobileUserCreatePayload(payload WorksmobileUserPayload) WorksmobileUserPayload {
payload.Email = strings.TrimSpace(payload.Email)
payload.PrivateEmail = strings.TrimSpace(payload.PrivateEmail)
payload.CellPhone = normalizeWorksmobileOutboundCellPhone(payload.CellPhone)
if strings.EqualFold(strings.TrimSpace(payload.PasswordConfig.PasswordCreationType), "ADMIN") &&
strings.TrimSpace(payload.PasswordConfig.Password) != "" &&
payload.PasswordConfig.ChangePasswordAtNextLogin == nil {
changePasswordAtNextLogin := true
payload.PasswordConfig.ChangePasswordAtNextLogin = &changePasswordAtNextLogin
}
return payload
}
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
userID = strings.TrimSpace(userID)
email = strings.TrimSpace(email)
@@ -995,7 +1151,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
}
func normalizeWorksmobileOutboundCellPhone(value string) string {
return domain.NormalizePhoneNumber(value)
normalized := domain.NormalizePhoneNumber(value)
if !strings.HasPrefix(normalized, "+82") {
if strings.HasPrefix(normalized, "0") {
return "+82 " + normalized
}
return normalized
}
national := strings.TrimPrefix(normalized, "+82")
if national == "" {
return normalized
}
national = strings.TrimLeft(national, "0")
if national == "" {
return "+82 0"
}
return "+82 0" + national
}
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {

View File

@@ -36,6 +36,7 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
CellPhone: "+821041585840",
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
Locale: "ko_KR",
PasswordConfig: WorksmobilePasswordConfig{
@@ -57,11 +58,13 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
require.Equal(t, "tester@samaneng.com", payload["email"])
require.Equal(t, "user-1", payload["userExternalKey"])
require.Equal(t, "+82 01041585840", payload["cellPhone"])
require.NotContains(t, payload, "privateEmail")
require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"])
passwordConfig := payload["passwordConfig"].(map[string]any)
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
require.Len(t, passwordConfig["password"], 16)
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
}
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
@@ -92,7 +95,7 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
})
require.Equal(t, "+821062836786", payload.CellPhone)
require.Equal(t, "+82 01062836786", payload.CellPhone)
}
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
@@ -103,7 +106,7 @@ func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *test
})
require.Len(t, payload.PhoneNumbers, 1)
require.Equal(t, "+821062836786", payload.PhoneNumbers[0].Value)
require.Equal(t, "+82 01062836786", payload.PhoneNumbers[0].Value)
}
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
@@ -155,6 +158,196 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
require.Equal(t, "user-1", patchPayload["userExternalKey"])
}
func TestWorksmobileHTTPClientUpdateUserOnlyPatchesWithoutCreateOrPassword(t *testing.T) {
transport := &captureRoundTripper{
statusCode: http.StatusOK,
body: `{}`,
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
DomainID: 300285955,
Email: "moved@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Moved User"},
PrivateEmail: "private@example.com",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: "Aa1!Aa1!Aa1!Aa1!",
},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300285955,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:new-tenant", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 1)
require.Equal(t, http.MethodPatch, transport.requests[0].Method)
require.Equal(t, "/v1.0/users/moved@samaneng.com", transport.requests[0].URL.Path)
var patchPayload map[string]any
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
require.NotContains(t, patchPayload, "passwordConfig")
require.NotContains(t, patchPayload, "privateEmail")
require.Equal(t, "moved@samaneng.com", patchPayload["email"])
require.Equal(t, "user-1", patchPayload["userExternalKey"])
}
func TestWorksmobileHTTPClientUpsertUserFallsBackToExternalKeyPatchWhenEmailPatchNotFound(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300286336,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
require.Equal(t, "/v1.0/users/new-email@example.com", transport.requests[1].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
require.Equal(t, "/v1.0/users/user-1", transport.requests[2].URL.Path)
}
func TestWorksmobileHTTPClientUpsertUserFallsBackToRemoteIDPatchWhenExternalKeyPatchNotFound(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300286336},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300286336,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 5)
require.Equal(t, http.MethodGet, transport.requests[3].Method)
require.Equal(t, "/v1.0/users", transport.requests[3].URL.Path)
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
require.Equal(t, http.MethodPatch, transport.requests[4].Method)
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[4].URL.Path)
}
func TestWorksmobileHTTPClientExternalKeyLookupStartsWithPayloadDomain(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
{statusCode: http.StatusOK, body: `{"users":[],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300285955, 300286336},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 6)
require.Equal(t, http.MethodGet, transport.requests[3].Method)
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
require.Equal(t, http.MethodGet, transport.requests[4].Method)
require.Equal(t, "300285955", transport.requests[4].URL.Query().Get("domainId"))
require.Equal(t, http.MethodPatch, transport.requests[5].Method)
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[5].URL.Path)
}
func TestWorksmobileHTTPClientExternalKeyLookupRejectsDuplicateMatches(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email-1@example.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-2","userExternalKey":"user-1","email":"old-email-2@example.com"}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300286336, 300285955},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
})
require.Error(t, err)
require.Contains(t, err.Error(), "multiple worksmobile users matched external key")
require.Len(t, transport.requests, 5)
}
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
transport := &captureRoundTripper{
statusCode: http.StatusCreated,
@@ -637,6 +830,45 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
}
func TestWorksmobileRelayWorkerProcessesAutomaticUserUpdateOnlyWithoutCreate(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "moved@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Moved User"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300285955,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:new-tenant", Primary: true},
},
},
},
}),
},
},
}
repo.ready[0].Payload["provisioningMode"] = "update_only"
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Empty(t, client.createdUsers)
require.Len(t, client.updatedUsers, 1)
require.Equal(t, "moved@samaneng.com", client.updatedUsers[0].Email)
}
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
@@ -1482,6 +1714,7 @@ type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
deletedOrgUnits []string
createdUsers []WorksmobileUserPayload
updatedUsers []WorksmobileUserPayload
deletedUsers []string
activeUsers []string
suspendedUsers []string
@@ -1610,6 +1843,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
return nil
}
func (f *fakeWorksmobileDirectoryClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
f.updatedUsers = append(f.updatedUsers, payload)
return nil
}
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
return nil

View File

@@ -202,6 +202,14 @@ func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.
}
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, true)
}
func BuildWorksmobileUserPayloadForScopedDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, false)
}
func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap, includeFallbackTenant bool) (WorksmobileUserPayload, error) {
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
return WorksmobileUserPayload{}, err
}
@@ -211,7 +219,9 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
if tenantByID == nil {
tenantByID = map[string]domain.Tenant{}
}
tenantByID[tenant.ID] = tenant
if includeFallbackTenant {
tenantByID[tenant.ID] = tenant
}
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
@@ -253,7 +263,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
if len(appointments) == 0 {
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
} else if !worksmobileAppointmentsContainTenant(appointments, tenant.ID) && !worksmobileAppointmentsHavePrimary(appointments) {
} else if !worksmobileAppointmentsContainSyncableOrgUnit(appointments, tenantByID) && !worksmobileAppointmentsContainTenant(appointments, tenant.ID) {
appointments = append([]worksmobileAppointment{{
TenantID: tenant.ID,
IsPrimary: true,
@@ -284,6 +294,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if !ok {
continue
}
if worksmobileTenantExcludedFromSync(appointmentTenant, tenantByID) {
seen[appointment.TenantID] = true
continue
}
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
seen[appointment.TenantID] = true
continue
@@ -303,8 +317,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if err != nil {
return nil, "", err
}
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
@@ -361,6 +374,23 @@ func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment,
return false
}
func worksmobileAppointmentsContainSyncableOrgUnit(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
for _, appointment := range appointments {
tenant, ok := tenantByID[strings.TrimSpace(appointment.TenantID)]
if !ok {
continue
}
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
continue
}
if isWorksmobileDomainRootTenant(tenant) {
continue
}
return true
}
return false
}
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
for _, appointment := range appointments {
if appointment.IsPrimary {
@@ -376,6 +406,9 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
if !ok {
continue
}
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
continue
}
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
return true
@@ -384,6 +417,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
return false
}
func worksmobileTenantExcludedFromSync(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
visited := map[string]bool{}
current := tenant
for {
if WorksmobileExcluded(current.Config) {
return true
}
parentID := worksmobileTenantParentID(current)
if parentID == "" || visited[parentID] {
return false
}
visited[parentID] = true
parent, ok := tenantByID[parentID]
if !ok {
return false
}
current = parent
}
}
func worksmobileShouldSkipEmailDomainRootAppointment(appointment worksmobileAppointment, tenant domain.Tenant, appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
return false

View File

@@ -315,19 +315,164 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
)
require.NoError(t, err)
require.Equal(t, "Engineering", payload.Task)
require.Equal(t, "PM", payload.Task)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
require.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
}
func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
hanmacRootID := "11111111-1111-1111-1111-111111111111"
samanRootID := "22222222-2222-2222-2222-222222222222"
firstTenantID := "33333333-3333-3333-3333-333333333333"
secondTenantID := "44444444-4444-4444-4444-444444444444"
user := domain.User{
ID: "55555555-5555-5555-5555-555555555555",
Email: "first-order@samaneng.com",
Name: "First Order User",
TenantID: &secondTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": firstTenantID,
"isPrimary": false,
},
map[string]any{
"tenantId": secondTenantID,
"isPrimary": true,
},
},
},
}
hanmacRoot := domain.Tenant{
ID: hanmacRootID,
Slug: "hanmac",
Name: "한맥기술",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
}
samanRoot := domain.Tenant{
ID: samanRootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
firstTenant := domain.Tenant{
ID: firstTenantID,
Slug: "first-team",
Name: "First Team",
Type: domain.TenantTypeOrganization,
ParentID: &hanmacRootID,
}
secondTenant := domain.Tenant{
ID: secondTenantID,
Slug: "second-team",
Name: "Second Team",
Type: domain.TenantTypeOrganization,
ParentID: &samanRootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
secondTenant,
map[string]domain.Tenant{
hanmacRootID: hanmacRoot,
samanRootID: samanRoot,
firstTenantID: firstTenant,
secondTenantID: secondTenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
}
func TestBuildWorksmobileUserPayloadSkipsExcludedAppointmentWhenChoosingWorksmobilePrimary(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
excludedRootID := "11111111-1111-1111-1111-111111111111"
includedRootID := "22222222-2222-2222-2222-222222222222"
excludedTenantID := "33333333-3333-3333-3333-333333333333"
includedTenantID := "44444444-4444-4444-4444-444444444444"
user := domain.User{
ID: "55555555-5555-5555-5555-555555555555",
Email: "skip-excluded@samaneng.com",
Name: "Skip Excluded User",
TenantID: &includedTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": excludedTenantID},
map[string]any{"tenantId": includedTenantID},
},
},
}
excludedRoot := domain.Tenant{
ID: excludedRootID,
Slug: "hanmac",
Name: "한맥기술",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
Config: domain.JSONMap{"worksmobileExcluded": true},
}
includedRoot := domain.Tenant{
ID: includedRootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
excludedTenant := domain.Tenant{
ID: excludedTenantID,
Slug: "excluded-team",
Name: "Excluded Team",
Type: domain.TenantTypeOrganization,
ParentID: &excludedRootID,
}
includedTenant := domain.Tenant{
ID: includedTenantID,
Slug: "included-team",
Name: "Included Team",
Type: domain.TenantTypeOrganization,
ParentID: &includedRootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
includedTenant,
map[string]domain.Tenant{
excludedRootID: excludedRoot,
includedRootID: includedRoot,
excludedTenantID: excludedTenant,
includedTenantID: includedTenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.Equal(t, "externalKey:"+includedTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
}
func TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {

View File

@@ -141,7 +141,11 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
}
aliasEmails := append([]string(nil), payload.AliasEmails...)
payload.AliasEmails = nil
if err := w.client.UpsertUser(ctx, payload); err != nil {
if stringValue(job.Payload[worksmobileProvisioningModeKey]) == worksmobileProvisioningUpdateOnly {
if err := w.client.UpdateUserOnly(ctx, payload); err != nil {
return fmt.Errorf("worksmobile user update failed: %w", err)
}
} else if err := w.client.UpsertUser(ctx, payload); err != nil {
return fmt.Errorf("worksmobile user upsert failed: %w", err)
}
for _, aliasEmail := range aliasEmails {

View File

@@ -16,14 +16,18 @@ import (
)
const (
HanmacFamilyTenantSlug = "hanmac-family"
worksmobileExcludedConfigKey = "worksmobileExcluded"
HanmacFamilyTenantSlug = "hanmac-family"
worksmobileExcludedConfigKey = "worksmobileExcluded"
worksmobileIdentityMirrorVersion = "kratos-full-pagination-v1"
worksmobileProvisioningModeKey = "provisioningMode"
worksmobileProvisioningUpdateOnly = "update_only"
)
type WorksmobileSyncer interface {
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error
EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
}
@@ -103,55 +107,62 @@ type WorksmobileComparison struct {
}
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
WorksmobileName string `json:"worksmobileName,omitempty"`
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
Status string `json:"status"`
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
WorksmobileName string `json:"worksmobileName,omitempty"`
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
UpdateReasons []string `json:"updateReasons,omitempty"`
Status string `json:"status"`
}
type worksmobileSyncService struct {
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror
}
type WorksmobileIdentityMirror interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error)
}
func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService {
@@ -163,6 +174,13 @@ func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.
}
}
func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMirror) {
if s == nil {
return
}
s.identityMirror = source
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
@@ -344,7 +362,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
users, err := s.comparisonUsers(ctx, tenantIDs)
if err != nil {
return WorksmobileComparison{}, err
}
@@ -360,11 +378,96 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
return WorksmobileComparison{
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
Users: compareWorksmobileUsersWithRemoteGroups(users, remoteUsers, includeMatched, tenantByID, remoteGroups, worksmobileUserJobSummaries(recentJobs)),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
if err == nil &&
status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == worksmobileIdentityMirrorVersion {
identities, err := s.identityMirror.ListIdentityMirrors(ctx)
if err != nil {
return nil, err
}
return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
}
}
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
}
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
allowed[strings.TrimSpace(tenantID)] = true
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
tenantID := traitString(identity.Traits, "tenant_id")
if tenantID == "" || !allowed[tenantID] {
continue
}
user := worksmobileUserFromIdentity(identity)
users = append(users, user)
}
return users
}
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
metadata := domain.JSONMap{}
for key, value := range identity.Traits {
metadata[key] = value
}
tenantID := traitString(identity.Traits, "tenant_id")
status := domain.UserStatusActive
if identity.State == "inactive" {
status = domain.UserStatusArchived
}
if traitStatus := traitString(identity.Traits, "status"); traitStatus != "" {
status = domain.NormalizeUserStatus(traitStatus)
}
user := domain.User{
ID: strings.TrimSpace(identity.ID),
Email: traitString(identity.Traits, "email"),
Name: traitString(identity.Traits, "name"),
Phone: traitString(identity.Traits, "phone_number"),
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
AffiliationType: traitString(identity.Traits, "affiliationType"),
Department: traitString(identity.Traits, "department"),
Grade: traitString(identity.Traits, "grade"),
Position: traitString(identity.Traits, "position"),
JobTitle: traitString(identity.Traits, "jobTitle"),
Metadata: metadata,
Status: status,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
if tenantID != "" {
user.TenantID = &tenantID
}
return user
}
func traitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
value, ok := traits[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -545,35 +648,32 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
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")
}
_, tenantInScope := tenantByID[tenant.ID]
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")
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
*user,
*tenant,
tenantByID,
root.Config,
)
if err != nil {
err := errors.New("target user status is excluded from Worksmobile sync")
if recordErr := s.recordRejectedUserSync(ctx, root.ID, *user, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
initialPassword = strings.TrimSpace(initialPassword)
if initialPassword != "" {
payload.PasswordConfig = WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: initialPassword,
}
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
if !tenantInScope {
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
}
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
if err != nil {
return nil, err
}
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
return nil, err
}
action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
@@ -594,10 +694,48 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return item, nil
}
func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, rootID string, user domain.User, tenant domain.Tenant, reason error) error {
payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR",
Task: strings.TrimSpace(user.JobTitle),
}
outboxPayload := worksmobileUserOutboxPayload(rootID, payload, user.Status)
outboxPayload["displayName"] = strings.TrimSpace(user.Name)
outboxPayload["primaryLeafOrgName"] = strings.TrimSpace(tenant.Name)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: WorksmobileUserStatusAction(user.Status),
DedupeKey: worksmobileUserSyncDedupeKey("rejected", user.ID),
Payload: outboxPayload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
func worksmobileUserSyncDedupeKey(action, userID string) string {
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
}
func worksmobileAdminInitialPasswordConfig(password string) WorksmobilePasswordConfig {
password = strings.TrimSpace(password)
if password == "" {
password = GenerateWorksmobileInitialPassword()
}
changePasswordAtNextLogin := true
return WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: password,
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
}
}
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -629,10 +767,11 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
if err != nil {
return nil, err
}
@@ -848,6 +987,14 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
}
func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
return s.enqueueUserUpsertIfInScope(ctx, user, false)
}
func (s *worksmobileSyncService) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
return s.enqueueUserUpsertIfInScope(ctx, user, true)
}
func (s *worksmobileSyncService) enqueueUserUpsertIfInScope(ctx context.Context, user domain.User, updateOnly bool) error {
if user.TenantID == nil || *user.TenantID == "" {
return nil
}
@@ -887,12 +1034,19 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
return err
}
action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert && !updateOnly {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig("")
}
outboxPayload := worksmobileUserOutboxPayload(root.ID, payload, user.Status)
if action == domain.WorksmobileActionUpsert && updateOnly {
outboxPayload[worksmobileProvisioningModeKey] = worksmobileProvisioningUpdateOnly
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
Payload: outboxPayload,
})
}
@@ -1429,10 +1583,15 @@ func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]wor
}
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
return compareWorksmobileUsersWithRemoteGroups(localUsers, remoteUsers, includeMatched, localTenants, nil, jobSummaries...)
}
func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, remoteGroups []WorksmobileRemoteGroup, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
jobSummaryByUserID = jobSummaries[0]
}
remoteOrgUnitByExternalID := worksmobileRemoteOrgUnitByExternalID(remoteGroups)
remoteByExternalID := map[string]WorksmobileRemoteUser{}
remoteByEmail := map[string]WorksmobileRemoteUser{}
for _, remote := range remoteUsers {
@@ -1462,7 +1621,11 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
if !matched {
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote, localTenants)
updateReasons := []string(nil)
if matched {
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
}
needsUpdate := len(updateReasons) > 0
if matched && !includeMatched && !needsUpdate {
matchedRemoteIDs[remote.ID] = true
continue
@@ -1491,6 +1654,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
item.Status = "matched"
if needsUpdate {
item.Status = "needs_update"
item.UpdateReasons = updateReasons
}
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
@@ -1571,6 +1735,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
return result
}
func worksmobileRemoteOrgUnitByExternalID(remoteGroups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup {
result := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, remote := range remoteGroups {
externalID := strings.TrimSpace(remote.ExternalID)
if externalID == "" {
continue
}
result[externalID] = remote
}
return result
}
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
return normalizeWorksmobileAccountStatus(
remote.AccountStatus,
@@ -1582,29 +1758,40 @@ func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
)
}
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
return len(worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)) > 0
}
func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []string {
reasons := []string{}
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
return true
reasons = append(reasons, "external_key")
}
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
return true
reasons = append(reasons, "name")
}
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
return true
reasons = append(reasons, "email")
}
if worksmobileUserPhoneNeedsUpdate(user, remote) {
return true
reasons = append(reasons, "phone")
}
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
return true
reasons = append(reasons, "employee_number")
}
return false
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "organization")
}
if worksmobileUserManagerNeedsUpdate(user, remote) {
reasons = append(reasons, "manager")
}
return reasons
}
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
if localPhone == "" && remotePhone == "" {
if localPhone == "" {
return false
}
if localPhone != remotePhone {
@@ -1636,11 +1823,11 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
return localEmployeeNumber != remoteEmployeeNumber
}
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
if len(remote.Organizations) == 0 || user.TenantID == nil || localTenants == nil {
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
if localTenants == nil {
return false
}
tenantID := strings.TrimSpace(*user.TenantID)
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
if tenantID == "" {
return false
}
@@ -1648,11 +1835,34 @@ func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote Worksmobile
if !ok {
return false
}
expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil {
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil || len(expectedOrganizations) == 0 {
return worksmobileUserPrimaryOrganizationNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID)
}
remoteOrganizations := remote.Organizations
if len(remoteOrganizations) == 0 {
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
return !worksmobileUserOrganizationsEqual(expectedOrganizations, remoteOrganizations)
}
func worksmobileUserPrimaryOrganizationNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
tenantID := worksmobileUserComparisonPrimaryTenantID(user)
if tenantID == "" {
return false
}
return !worksmobileUserOrganizationsEqual(expected.Organizations, remote.Organizations)
tenant, ok := localTenants[tenantID]
if !ok || isWorksmobileDomainRootTenant(tenant) {
return false
}
expectedPrimary := "externalKey:" + tenantID
if remoteOrgUnit, ok := remoteOrgUnitByExternalID[tenantID]; ok && strings.TrimSpace(remoteOrgUnit.ID) != "" {
expectedPrimary = strings.TrimSpace(remoteOrgUnit.ID)
}
remotePrimaryOrgUnits := worksmobileRemotePrimaryOrgUnitIDs(remote)
return !worksmobileOrgUnitIDContains(remotePrimaryOrgUnits, expectedPrimary)
}
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
@@ -1664,9 +1874,131 @@ func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) doma
return nil
}
func worksmobileUserComparisonPrimaryTenantID(user domain.User) string {
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
if appointment.IsPrimary && strings.TrimSpace(appointment.TenantID) != "" {
return strings.TrimSpace(appointment.TenantID)
}
}
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func worksmobileUserComparisonTenantID(user domain.User, localTenants map[string]domain.Tenant) string {
if user.TenantID != nil {
tenantID := strings.TrimSpace(*user.TenantID)
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
tenantID := strings.TrimSpace(appointment.TenantID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
return ""
}
func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
if strings.TrimSpace(remote.PrimaryOrgUnitID) == "" {
return nil
}
return []WorksmobileUserOrganization{
{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
Primary: true,
PositionID: strings.TrimSpace(remote.PrimaryOrgUnitPositionID),
IsManager: remote.PrimaryOrgUnitIsManager,
},
},
},
}
}
func worksmobileRemoteUserOrganizationsForCompare(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
if len(remote.Organizations) == 0 {
return nil
}
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
result := make([]WorksmobileUserOrganization, len(remote.Organizations))
for i, organization := range remote.Organizations {
result[i] = organization
if result[i].DomainID == 0 {
result[i].DomainID = remote.DomainID
}
result[i].OrgUnits = make([]WorksmobileUserOrgUnit, len(organization.OrgUnits))
copy(result[i].OrgUnits, organization.OrgUnits)
for j, orgUnit := range result[i].OrgUnits {
result[i].OrgUnits[j].OrgUnitID = worksmobileCanonicalRemoteOrgUnitID(orgUnit.OrgUnitID, remoteOrgUnitByExternalID)
}
if primaryOrgUnitID == "" {
continue
}
for j, orgUnit := range result[i].OrgUnits {
if strings.TrimSpace(orgUnit.OrgUnitID) != worksmobileCanonicalRemoteOrgUnitID(primaryOrgUnitID, remoteOrgUnitByExternalID) {
continue
}
result[i].Primary = true
result[i].OrgUnits[j].Primary = true
}
}
return result
}
func worksmobileCanonicalRemoteOrgUnitID(orgUnitID string, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" || strings.HasPrefix(orgUnitID, "externalKey:") {
return orgUnitID
}
for externalID, remoteOrgUnit := range remoteOrgUnitByExternalID {
if strings.TrimSpace(remoteOrgUnit.ID) == orgUnitID && strings.TrimSpace(externalID) != "" {
return "externalKey:" + strings.TrimSpace(externalID)
}
}
return orgUnitID
}
func worksmobileRemotePrimaryOrgUnitIDs(remote WorksmobileRemoteUser) []string {
result := make([]string, 0, 1)
for _, organization := range remote.Organizations {
if !organization.Primary {
continue
}
for _, orgUnit := range organization.OrgUnits {
if orgUnit.Primary {
result = append(result, orgUnit.OrgUnitID)
}
}
}
if len(result) == 0 && strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
result = append(result, remote.PrimaryOrgUnitID)
}
return result
}
func worksmobileOrgUnitIDContains(values []string, expected string) bool {
expected = strings.TrimSpace(expected)
for _, value := range values {
if strings.TrimSpace(value) == expected {
return true
}
}
return false
}
type worksmobileComparableOrgUnit struct {
organizationPrimary bool
organizationEmail string
unitPrimary bool
positionID string
comparePosition bool
@@ -1688,9 +2020,6 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
return false
}
if strings.ToLower(expectedUnit.organizationEmail) != strings.ToLower(remoteUnit.organizationEmail) {
return false
}
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
return false
}
@@ -1704,6 +2033,23 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
return true
}
func worksmobilePrimaryOrgUnitCompareKey(organizations []WorksmobileUserOrganization) string {
for _, organization := range organizations {
if !organization.Primary {
continue
}
for _, orgUnit := range organization.OrgUnits {
if !orgUnit.Primary {
continue
}
if key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID); key != "" {
return key
}
}
}
return ""
}
func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
result := map[string]worksmobileComparableOrgUnit{}
for _, organization := range organizations {
@@ -1714,7 +2060,6 @@ func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUser
}
result[key] = worksmobileComparableOrgUnit{
organizationPrimary: organization.Primary,
organizationEmail: strings.TrimSpace(organization.Email),
unitPrimary: orgUnit.Primary,
positionID: strings.TrimSpace(orgUnit.PositionID),
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
@@ -1736,7 +2081,6 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
}
result[key] = worksmobileComparableOrgUnit{
organizationPrimary: organization.Primary,
organizationEmail: strings.TrimSpace(organization.Email),
unitPrimary: orgUnit.Primary,
positionID: strings.TrimSpace(orgUnit.PositionID),
manager: orgUnit.IsManager,
@@ -1766,22 +2110,43 @@ func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemot
if len(localManagers) == 0 {
return false
}
remoteManagers := remote.OrgUnitManagers
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
}
for remoteOrgUnitID, remoteManager := range remoteManagers {
if remoteManager == nil {
continue
remoteManagers := worksmobileRemoteOrgUnitManagerMap(remote)
for localOrgUnitID, localManager := range localManagers {
remoteManager := false
if value, ok := remoteManagers[localOrgUnitID]; ok && value != nil {
remoteManager = *value
}
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
if ok && localManager != *remoteManager {
if localManager != remoteManager {
return true
}
}
return false
}
func worksmobileRemoteOrgUnitManagerMap(remote WorksmobileRemoteUser) map[string]*bool {
result := map[string]*bool{}
for orgUnitID, isManager := range remote.OrgUnitManagers {
normalized := worksmobileOrgUnitLocalExternalKey(orgUnitID)
if normalized == "" {
continue
}
result[normalized] = isManager
}
for _, organization := range remote.Organizations {
for _, orgUnit := range organization.OrgUnits {
normalized := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if normalized == "" {
continue
}
result[normalized] = orgUnit.IsManager
}
}
if strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
result[worksmobileOrgUnitLocalExternalKey(remote.PrimaryOrgUnitID)] = remote.PrimaryOrgUnitIsManager
}
return result
}
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
managers := map[string]bool{}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {

View File

@@ -160,9 +160,11 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) {
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
@@ -198,10 +200,14 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T)
require.NoError(t, err)
require.NotNil(t, item)
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.NotEmpty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Empty(t, request.PasswordConfig.Password)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -286,6 +292,48 @@ func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *te
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
}
func TestWorksmobileSyncServiceUserUpdateIsUpdateOnlyWithoutInitialPassword(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "Hanmac Family",
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
Status: domain.UserStatusActive,
TenantID: &tenantID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
outboxRepo,
nil,
)
require.NoError(t, service.EnqueueUserUpdateIfInScope(context.Background(), target))
require.Len(t, outboxRepo.created, 1)
require.Equal(t, "update_only", outboxRepo.created[0].Payload["provisioningMode"])
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.True(t, request.PasswordConfig.IsZero())
}
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
@@ -333,6 +381,49 @@ func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testi
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
}
func TestWorksmobileSyncServicePasswordResetAllowsExcludedPrimaryTenant(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
excludedOrgID := "excluded-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "Hanmac Family",
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "Excluded Team",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
Status: domain.UserStatusActive,
TenantID: &excludedOrgID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, excludedOrgID: excludedOrg}, list: []domain.Tenant{root, excludedOrg}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
outboxRepo,
nil,
)
item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1")
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action)
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
}
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
@@ -1650,6 +1741,69 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
}
func TestWorksmobileSyncServiceGetComparisonUsesIdentityMirrorWhenReady(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Name: "계열사",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
userRepo := &fakeWorksmobileUserRepo{byTenant: []domain.User{{
ID: "local-only-user",
Email: "local-only@example.com",
Name: "Local Only",
TenantID: &companyID,
Status: domain.UserStatusActive,
}}}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
userRepo,
&fakeWorksmobileOutboxRepo{},
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
ID: "works-user",
ExternalID: "mirror-user",
Email: "mirror@example.com",
DisplayName: "Mirror User",
PrimaryOrgUnitID: "externalKey:" + companyID,
}}},
)
service.SetIdentityMirror(&fakeWorksmobileIdentityMirror{
status: domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: "kratos-full-pagination-v1",
ObservedCount: 1,
},
identities: []KratosIdentity{{
ID: "mirror-user",
State: "active",
Traits: map[string]any{
"email": "mirror@example.com",
"name": "Mirror User",
"tenant_id": companyID,
"role": domain.RoleUser,
"affiliationType": "internal",
},
}},
})
comparison, err := service.GetComparison(context.Background(), rootID, true)
require.NoError(t, err)
require.Empty(t, userRepo.requestedTenantIDs)
require.Len(t, comparison.Users, 1)
require.Equal(t, "matched", comparison.Users[0].Status)
require.Equal(t, "mirror-user", comparison.Users[0].BaronID)
require.Equal(t, "mirror-user", comparison.Users[0].ExternalKey)
}
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
@@ -1898,6 +2052,7 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
}
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
@@ -1944,12 +2099,26 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
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)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceUser, outboxRepo.created[0].ResourceType)
require.Equal(t, user.ID, outboxRepo.created[0].ResourceID)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Empty(t, outboxRepo.created[0].Status)
require.Empty(t, outboxRepo.created[0].LastError)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, user.Email, outboxRepo.created[0].Payload["loginEmail"])
require.Equal(t, user.Name, outboxRepo.created[0].Payload["displayName"])
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Empty(t, request.Organizations)
}
func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-manager",
@@ -1977,20 +2146,37 @@ func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
DisplayName: user.Name,
PrimaryOrgUnitID: "externalKey:" + tenantID,
PrimaryOrgUnitIsManager: &remoteManager,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
IsManager: &remoteManager,
},
},
},
},
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &rootID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
primaryTenantID := "tenant-company"
secondaryTenantID := "tenant-gpdtdc-leaf"
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-saman"
primaryTenantID := "tenant-primary"
secondaryTenantID := "tenant-secondary"
user := domain.User{
ID: "user-secondary-manager",
Email: "secondary-manager@samaneng.com",
@@ -2026,19 +2212,39 @@ func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
"externalKey:" + primaryTenantID: &remotePrimaryManager,
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + primaryTenantID,
Primary: true,
IsManager: &remotePrimaryManager,
},
{
OrgUnitID: "externalKey:" + secondaryTenantID,
Primary: false,
IsManager: &remoteSecondaryManager,
},
},
},
},
}},
true,
map[string]domain.Tenant{
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
primaryTenantID: {ID: primaryTenantID, Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
secondaryTenantID: {ID: secondaryTenantID, Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T) {
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
@@ -2097,10 +2303,336 @@ func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-email",
Email: "org-email@samaneng.com",
Name: "Org Email User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-email",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-domain",
Email: "org-domain@samaneng.com",
Name: "Org Domain User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-domain",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
Organizations: []WorksmobileUserOrganization{
{
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersUsesRemotePrimaryOrgUnitWhenOrganizationPrimaryFlagsAreMissing(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-primary",
Email: "org-primary@samaneng.com",
Name: "Org Primary User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-primary",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
PrimaryOrgUnitID: "works-leaf",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "works-leaf",
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
[]WorksmobileRemoteGroup{{
ID: "works-leaf",
ExternalID: tenantID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-external-key",
Email: "org-external-key@samaneng.com",
Name: "Org External Key User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-external-key",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
PrimaryOrgUnitID: "works-leaf",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
[]WorksmobileRemoteGroup{{
ID: "works-leaf",
ExternalID: tenantID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
gpdtdcID := "tenant-gpdtdc"
peopleGrowthID := "tenant-people-growth"
user := domain.User{
ID: "user-people-growth",
Email: "people-growth@baroncs.co.kr",
Name: "People Growth User",
TenantID: &peopleGrowthID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-people-growth",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1003,
PrimaryOrgUnitID: "externalKey:another-team",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1003,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:another-team",
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Equal(t, peopleGrowthID, items[0].BaronPrimaryOrgID)
require.Equal(t, "externalKey:another-team", items[0].WorksmobilePrimaryOrgID)
}
func TestCompareWorksmobileUsersMatchesPrimaryOrganizationByWorksmobileResourceID(t *testing.T) {
peopleGrowthID := "tenant-people-growth"
user := domain.User{
ID: "user-people-growth",
Email: "people-growth@baroncs.co.kr",
Name: "People Growth User",
TenantID: &peopleGrowthID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-people-growth",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
PrimaryOrgUnitID: "works-current-people-growth",
}},
true,
map[string]domain.Tenant{
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
},
[]WorksmobileRemoteGroup{{
ID: "works-current-people-growth",
ExternalID: peopleGrowthID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksStalePrimaryOrganizationResourceIDNeedsUpdate(t *testing.T) {
peopleGrowthID := "tenant-people-growth"
user := domain.User{
ID: "user-people-growth",
Email: "people-growth@baroncs.co.kr",
Name: "People Growth User",
TenantID: &peopleGrowthID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-people-growth",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
PrimaryOrgUnitID: "works-deleted-people-growth",
}},
true,
map[string]domain.Tenant{
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
},
[]WorksmobileRemoteGroup{{
ID: "works-current-people-growth",
ExternalID: peopleGrowthID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
tenantID := "tenant-saman"
user := domain.User{
@@ -2167,6 +2699,63 @@ func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *te
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresRemotePhoneWhenBaronPhoneIsEmpty(t *testing.T) {
tenantID := "tenant-halla"
user := domain.User{
ID: "edb8e4f6-3dfd-44d4-a8aa-87332f8b2b38",
Email: "cyhan4@hallasanup.com",
Name: "네이버웍스관리자",
TenantID: &tenantID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "fe9449d1-1671-44e4-1848-033779dddbaf",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
CellPhone: "+82 01041585840",
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "한라산업개발", Type: domain.TenantTypeCompany},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersTreatsSpacedKoreanCountryCodePhoneAsMatched(t *testing.T) {
tenantID := "tenant-saman"
user := domain.User{
ID: "user-phone-spaced",
Email: "phone-spaced@samaneng.com",
Name: "Phone Spaced User",
Phone: "+821041585840",
TenantID: &tenantID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-phone-spaced",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
CellPhone: "+82 1041585840",
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant
@@ -2229,6 +2818,19 @@ type fakeWorksmobileUserRepo struct {
requestedTenantIDs []string
}
type fakeWorksmobileIdentityMirror struct {
status domain.IdentityCacheStatus
identities []KratosIdentity
}
func (f *fakeWorksmobileIdentityMirror) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
return f.status, nil
}
func (f *fakeWorksmobileIdentityMirror) ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error) {
return f.identities, nil
}
func (f *fakeWorksmobileUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {