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

@@ -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)
}
}