1
0
forked from baron/baron-sso

chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions

View File

@@ -8038,11 +8038,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
if department := extractTraitString(traits, "department"); department != "" {
localUser.Department = department
}
if grade := extractTraitString(traits, "grade"); grade != "" {
if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
localUser.Grade = grade
}
}
if position := extractTraitString(traits, "position"); position != "" {
localUser.Position = position
}

View File

@@ -9,6 +9,7 @@ import (
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/base64"
"encoding/csv"
"encoding/json"
"errors"
@@ -66,6 +67,16 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func tenantCreatorIDForMembership(profile *domain.UserProfileResponse) string {
if profile == nil {
return ""
}
if domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return ""
}
return strings.TrimSpace(profile.ID)
}
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,
@@ -125,6 +136,20 @@ type tenantListResponse struct {
NextCursor string `json:"nextCursor,omitempty"`
}
type tenantSortDirection string
const (
tenantSortAsc tenantSortDirection = "asc"
tenantSortDesc tenantSortDirection = "desc"
)
type tenantQueryCursor struct {
Sort string `json:"sort"`
Direction string `json:"direction"`
Value string `json:"value"`
ID string `json:"id"`
}
type orgChartSnapshotCacheInfo struct {
Source string `json:"source"`
Hit bool `json:"hit"`
@@ -132,9 +157,10 @@ type orgChartSnapshotCacheInfo struct {
}
type orgChartSnapshotResponse struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
GeneratedAt string `json:"generatedAt"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
}
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
@@ -147,6 +173,189 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) (
})
}
func normalizeTenantSortDirection(raw string) tenantSortDirection {
if strings.EqualFold(strings.TrimSpace(raw), string(tenantSortAsc)) {
return tenantSortAsc
}
return tenantSortDesc
}
func normalizeTenantSortKey(raw string) string {
switch strings.TrimSpace(raw) {
case "id", "name", "slug", "type", "status", "createdAt", "updatedAt":
return strings.TrimSpace(raw)
default:
return "createdAt"
}
}
func encodeTenantQueryCursor(sortKey string, direction tenantSortDirection, value string, id string) string {
if strings.TrimSpace(value) == "" || strings.TrimSpace(id) == "" {
return ""
}
payload, err := json.Marshal(tenantQueryCursor{
Sort: sortKey,
Direction: string(direction),
Value: value,
ID: id,
})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func decodeTenantQueryCursor(raw string, sortKey string, direction tenantSortDirection) (*tenantQueryCursor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return nil, err
}
var cursor tenantQueryCursor
if err := json.Unmarshal(decoded, &cursor); err != nil {
return nil, err
}
if strings.TrimSpace(cursor.ID) == "" || strings.TrimSpace(cursor.Value) == "" {
return nil, errors.New("invalid tenant cursor")
}
if cursor.Sort != sortKey || cursor.Direction != string(direction) {
return nil, errors.New("tenant cursor does not match sort")
}
return &cursor, nil
}
func tenantCursorValue(tenant domain.Tenant, sortKey string) string {
switch sortKey {
case "id":
return tenant.ID
case "name":
return strings.ToLower(tenant.Name)
case "slug":
return strings.ToLower(tenant.Slug)
case "type":
return strings.ToLower(tenant.Type)
case "status":
return strings.ToLower(tenant.Status)
case "updatedAt":
return tenant.UpdatedAt.UTC().Format(time.RFC3339Nano)
default:
return tenant.CreatedAt.UTC().Format(time.RFC3339Nano)
}
}
func filterTenantsByListSearch(tenants []domain.Tenant, search string) []domain.Tenant {
search = strings.ToLower(strings.TrimSpace(search))
if search == "" {
return tenants
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if strings.Contains(strings.ToLower(tenant.ID), search) ||
strings.Contains(strings.ToLower(tenant.Name), search) ||
strings.Contains(strings.ToLower(tenant.Slug), search) ||
strings.Contains(strings.ToLower(tenant.Type), search) ||
strings.Contains(strings.ToLower(tenant.Description), search) {
filtered = append(filtered, tenant)
}
}
return filtered
}
func sortTenantsForList(tenants []domain.Tenant, sortKey string, direction tenantSortDirection) {
sort.SliceStable(tenants, func(i, j int) bool {
left := tenantCursorValue(tenants[i], sortKey)
right := tenantCursorValue(tenants[j], sortKey)
if left == right {
if direction == tenantSortAsc {
return tenants[i].ID < tenants[j].ID
}
return tenants[i].ID > tenants[j].ID
}
if direction == tenantSortAsc {
return left < right
}
return left > right
})
}
func pageSortedTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, string, error) {
ordered := append([]domain.Tenant(nil), tenants...)
sortTenantsForList(ordered, sortKey, direction)
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, "", err
}
if cursor != nil {
filtered := ordered[:0]
for _, tenant := range ordered {
value := tenantCursorValue(tenant, sortKey)
after := value > cursor.Value || (value == cursor.Value && tenant.ID > cursor.ID)
if direction == tenantSortDesc {
after = value < cursor.Value || (value == cursor.Value && tenant.ID < cursor.ID)
}
if after {
filtered = append(filtered, tenant)
}
}
ordered = filtered
}
if len(ordered) <= limit {
return ordered, "", nil
}
page := ordered[:limit]
last := page[len(page)-1]
return page, encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID), nil
}
func tenantSortExpression(sortKey string) string {
switch sortKey {
case "id":
return "id::text"
case "name":
return "LOWER(name)"
case "slug":
return "LOWER(slug)"
case "type":
return "LOWER(type)"
case "status":
return "LOWER(status)"
case "updatedAt":
return "updated_at"
default:
return "created_at"
}
}
func tenantOrderClause(sortExpression string, direction tenantSortDirection) string {
if direction == tenantSortAsc {
return sortExpression + " asc, id asc"
}
return sortExpression + " desc, id desc"
}
func applyTenantQueryCursor(db *gorm.DB, sortExpression string, cursor *tenantQueryCursor, direction tenantSortDirection) *gorm.DB {
if cursor == nil {
return db
}
operator := "<"
idOperator := "<"
if direction == tenantSortAsc {
operator = ">"
idOperator = ">"
}
return db.Where(
fmt.Sprintf("%s %s ? OR (%s = ? AND id::text %s ?)", sortExpression, operator, sortExpression, idOperator),
cursor.Value,
cursor.Value,
cursor.ID,
)
}
type tenantImportDetail struct {
Row int `json:"row"`
Slug string `json:"slug"`
@@ -289,6 +498,9 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
cursorRaw := strings.TrimSpace(c.Query("cursor"))
search := strings.TrimSpace(c.Query("search"))
sortKey := normalizeTenantSortKey(c.Query("sort"))
sortDirection := normalizeTenantSortDirection(c.Query("direction"))
if limit <= 0 {
limit = 50
@@ -298,6 +510,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
var tenants []domain.Tenant
var countSourceTenants []domain.Tenant
var total int64
var err error
nextCursor := ""
@@ -365,44 +578,55 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
tenants = filterTenantsByListSearch(tenants, search)
countSourceTenants = append([]domain.Tenant(nil), tenants...)
total = int64(len(tenants))
if cursorRaw != "" {
tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
tenants, nextCursor, err = pageSortedTenantsByCursor(tenants, limit, cursorRaw, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if offset < len(tenants) {
sortTenantsForList(tenants, sortKey, sortDirection)
end := min(offset+limit, len(tenants))
tenants = tenants[offset:end]
if total > int64(end) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
} else {
tenants = []domain.Tenant{}
}
} else {
// Super Admin case
if cursorRaw != "" && h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
if h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, offset, parentId, cursorRaw, search, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
if cursorRaw != "" {
offset = 0
}
} else {
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if total > int64(offset+len(tenants)) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
var memberCounts map[string]int64
var totalMemberCounts map[string]int64
if countSourceTenants != nil {
memberCounts, totalMemberCounts, err = h.countTenantMembersFromTenantSet(c.Context(), tenants, countSourceTenants)
} else {
memberCounts, totalMemberCounts, err = h.countTenantMembers(c.Context(), tenants)
}
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -425,8 +649,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
})
}
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
cursor, err := pagination.Decode(cursorRaw)
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, offset int, parentID string, cursorRaw string, search string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, int64, string, error) {
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, 0, "", err
}
@@ -440,8 +664,9 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
searchClause := "LOWER(id::text) LIKE ? OR LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(type) LIKE ? OR LOWER(description) LIKE ?"
countQuery = countQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
}
var total int64
@@ -449,11 +674,16 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
return nil, 0, "", err
}
pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
sortExpression := tenantSortExpression(sortKey)
if cursor != nil {
pageQuery = applyTenantQueryCursor(pageQuery, sortExpression, cursor, direction)
} else if offset > 0 {
pageQuery = pageQuery.Offset(offset)
}
var tenants []domain.Tenant
if err := pageQuery.
Order("created_at desc, id desc").
Order(tenantOrderClause(sortExpression, direction)).
Limit(limit + 1).
Preload("Domains").
Find(&tenants).Error; err != nil {
@@ -464,7 +694,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if len(tenants) > limit {
tenants = tenants[:limit]
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID)
}
return tenants, total, nextCursor, nil
}
@@ -609,8 +839,8 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
records = orderTenantCSVRecordsByParentSlug(records)
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = tenantCreatorIDForMembership(profile)
}
tenantIDBySlug := make(map[string]string)
@@ -1861,10 +2091,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid
}
// Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = profile.ID
creatorID = tenantCreatorIDForMembership(profile)
}
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
@@ -2887,9 +3116,6 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true")
membersByTenantID := make(map[string][]orgContextMember)
if includeUsers {
if h.UserRepo == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
}
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2919,32 +3145,22 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
}
func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) {
usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, err
}
usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs)
if err != nil {
return nil, err
}
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
membersByTenantID := make(map[string][]orgContextMember)
users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...)
for _, user := range users {
if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
for _, identity := range identities {
if seen[identity.ID] || !domain.IsOrgVisibleUserStatus(normalizeStatus(identity.State)) {
continue
}
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
assignments := mapOrgContextIdentityAssignments(identity, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
seen[user.ID] = true
seen[identity.ID] = true
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
@@ -2952,6 +3168,55 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
return membersByTenantID, nil
}
func (h *TenantHandler) listOrgContextIdentities(ctx context.Context, tenantIDs, tenantSlugs []string) ([]service.KratosIdentity, error) {
allowedTenantKeys := make(map[string]bool, len(tenantIDs)+len(tenantSlugs))
for _, value := range append(tenantIDs, tenantSlugs...) {
key := strings.ToLower(strings.TrimSpace(value))
if key != "" {
allowedTenantKeys[key] = true
}
}
query := service.IdentityMirrorPageQuery{
Limit: 10000,
AllowedTenantKeys: allowedTenantKeys,
}
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.orgContextIdentityMirrorReady(ctx) {
result, err := lister.ListIdentityMirrorPage(ctx, query)
if err != nil {
return nil, err
}
return result.Items, nil
}
}
if h == nil || h.KratosAdmin == nil {
return nil, errors.New("identity mirror is unavailable")
}
identities, err := h.KratosAdmin.ListIdentities(ctx)
if err != nil {
return nil, err
}
result, err := pageIdentityMirrorSlice(identities, query)
if err != nil {
return nil, err
}
return result.Items, nil
}
func (h *TenantHandler) orgContextIdentityMirrorReady(ctx context.Context) bool {
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
if !ok {
return false
}
status, err := reader.GetIdentityCacheStatus(ctx)
if err != nil {
return false
}
return status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == identityMirrorVersion
}
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
normalized := strings.ToLower(strings.TrimSpace(slug))
for _, tenant := range tenants {
@@ -3086,8 +3351,55 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
return assignments
}
func mapOrgContextIdentityAssignments(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
assignments := make([]orgContextMemberAssignment, 0, 2)
seenTenants := map[string]bool{}
traits := identity.Traits
appointments := tenantClaimAppointmentsFromTraits(traits)
addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) {
if !ok || seenTenants[tenant.ID] {
return
}
seenTenants[tenant.ID] = true
if appointment == nil {
appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{
ID: tenant.ID,
Slug: tenant.Slug,
})
}
assignments = append(assignments, orgContextMemberAssignment{
TenantID: tenant.ID,
Member: mapOrgContextIdentityMember(identity, appointment, includeUserIDs),
})
}
for _, appointment := range appointments {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment)
}
}
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", appointment)
}
}
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", nil)
}
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", nil)
}
return assignments
}
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
grade := user.Grade
grade := ""
position := user.Position
jobTitle := user.JobTitle
department := user.Department
@@ -3139,6 +3451,60 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
}
}
func mapOrgContextIdentityMember(identity service.KratosIdentity, appointment map[string]any, includeUserIDs bool) orgContextMember {
traits := identity.Traits
grade := ""
position := extractTraitString(traits, "position")
jobTitle := extractTraitString(traits, "jobTitle")
department := extractTraitString(traits, "department")
if value := tenantClaimString(appointment, "grade"); value != "" {
grade = value
}
if value := tenantClaimString(appointment, "position"); value != "" {
position = value
}
if value := tenantClaimString(appointment, "jobTitle"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "job_title"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "department"); value != "" {
department = value
}
isOwner := false
if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok {
isOwner = value
}
isManager := false
if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok {
isManager = value
}
isPrimary := false
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
isPrimary = value
}
id := ""
phone := ""
if includeUserIDs {
id = identity.ID
phone = extractTraitString(traits, "phone_number")
}
return orgContextMember{
ID: id,
Email: extractTraitString(traits, "email"),
Name: extractTraitString(traits, "name"),
Phone: phone,
Department: department,
Grade: grade,
Position: position,
JobTitle: jobTitle,
IsOwner: isOwner,
IsManager: isManager,
IsPrimary: isPrimary,
}
}
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode {
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range tenants {
@@ -3170,6 +3536,24 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
}
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
allTenants := tenants
if h.DB != nil {
var edges []domain.Tenant
if err := h.DB.WithContext(ctx).
Model(&domain.Tenant{}).
Select("id", "parent_id").
Find(&edges).Error; err == nil && len(edges) > 0 {
allTenants = edges
}
} else if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
}
return h.countTenantMembersFromTenantSet(ctx, tenants, allTenants)
}
func (h *TenantHandler) countTenantMembersFromTenantSet(ctx context.Context, tenants []domain.Tenant, allTenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -3181,25 +3565,22 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
return counts, counts, nil
}
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
totalCounts := make(map[string]int64, len(tenants))
allTenantIDs := make([]string, 0, len(allTenants))
for _, tenant := range allTenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantIDs = append(tenantIDs, tenant.ID)
allTenantIDs = append(allTenantIDs, tenant.ID)
}
}
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, allTenantIDs)
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
}
for _, tenant := range tenants {
counts[tenant.ID] = directCounts[tenant.ID]
}
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
@@ -3209,13 +3590,9 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
}
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
var total int64
for _, descendantID := range descendantIDs {
total += directCounts[descendantID]
}
totalCounts[tenant.ID] = total
}
@@ -3309,6 +3686,7 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
refreshRequested := parseBoolQuery(c.Query("refresh"))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
@@ -3317,12 +3695,16 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
"refresh", refreshRequested,
)
if cacheMode == "redis" && h.OrgChartCache != nil {
if cacheMode == "redis" && h.OrgChartCache != nil && !refreshRequested {
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
if strings.TrimSpace(cached.GeneratedAt) == "" {
cached.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
}
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
@@ -3384,7 +3766,11 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
)
}
}
c.Set("X-Orgfront-Cache", "MISS")
if refreshRequested {
c.Set("X-Orgfront-Cache", "REFRESH")
} else {
c.Set("X-Orgfront-Cache", "MISS")
}
} else {
c.Set("X-Orgfront-Cache", "BYPASS")
}
@@ -3482,8 +3868,9 @@ func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *doma
}
return orgChartSnapshotResponse{
Tenants: tenantSummaries,
Users: users,
Tenants: tenantSummaries,
Users: users,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
@@ -3543,57 +3930,133 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
}
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
if h.UserRepo == nil {
return nil, errors.New("user repository is not configured")
}
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
tenantIDs := []string{}
tenantSlugs := []string{}
if role != domain.RoleSuperAdmin {
tenantIDs = make([]string, 0, len(tenants))
tenantSlugs = make([]string, 0, len(tenants))
for _, tenant := range tenants {
tenantIDs = append(tenantIDs, tenant.ID)
tenantSlugs = append(tenantSlugs, tenant.Slug)
}
}
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
summaries := make([]userSummary, 0, len(users))
for _, user := range users {
summary := userSummary{
ID: user.ID,
Email: user.Email,
LoginID: user.Email,
Name: user.Name,
Phone: user.Phone,
Role: domain.NormalizeRole(user.Role),
Status: normalizeStatus(user.Status),
TenantSlug: userTenantSlug(user),
CompanyCode: userTenantSlug(user),
Metadata: user.Metadata,
Tenant: user.Tenant,
Department: user.Department,
Grade: user.Grade,
Position: user.Position,
JobTitle: user.JobTitle,
CreatedAt: formatTime(user.CreatedAt),
UpdatedAt: formatTime(user.UpdatedAt),
}
if h.Service != nil {
if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
summary.JoinedTenants = joined
}
}
summaries = append(summaries, summary)
tenantByID := make(map[string]domain.Tenant, len(tenants))
tenantBySlug := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
tenantBySlug[strings.ToLower(tenant.Slug)] = tenant
}
summaries := make([]userSummary, 0, len(identities))
for _, identity := range identities {
summaries = append(summaries, mapOrgChartIdentitySummary(identity, tenantByID, tenantBySlug))
}
return summaries, nil
}
func mapOrgChartIdentitySummary(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]domain.Tenant) userSummary {
traits := identity.Traits
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := extractTraitString(traits, "tenantSlug")
var tenantSummary *domain.Tenant
if tenantID != "" {
if tenant, ok := tenantByID[tenantID]; ok {
tenantCopy := tenant
tenantSummary = &tenantCopy
if tenantSlug == "" {
tenantSlug = tenant.Slug
}
}
}
if tenantSummary == nil && tenantSlug != "" {
if tenant, ok := tenantBySlug[strings.ToLower(tenantSlug)]; ok {
tenantCopy := tenant
tenantSummary = &tenantCopy
if tenantID == "" {
tenantID = tenant.ID
}
}
}
metadata := make(domain.JSONMap)
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "tenantSlug": true,
"custom_login_ids": true, "id": true,
}
for key, value := range traits {
if !coreTraits[key] {
metadata[key] = value
}
}
return userSummary{
ID: identity.ID,
Email: extractTraitString(traits, "email"),
LoginID: resolvePasswordLoginID(traits),
Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"),
Role: roleFromTraits(traits),
Status: normalizeStatus(identity.State),
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Metadata: metadata,
Tenant: tenantSummary,
JoinedTenants: orgChartIdentityJoinedTenants(traits, tenantByID, tenantBySlug),
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt),
}
}
func orgChartIdentityJoinedTenants(traits map[string]any, tenantByID, tenantBySlug map[string]domain.Tenant) []domain.Tenant {
seen := make(map[string]bool)
joined := make([]domain.Tenant, 0, 2)
addTenant := func(tenant domain.Tenant, ok bool) {
if !ok || strings.TrimSpace(tenant.ID) == "" || seen[tenant.ID] {
return
}
seen[tenant.ID] = true
joined = append(joined, tenant)
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
}
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "")
}
for _, appointment := range tenantClaimAppointmentsFromTraits(traits) {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
}
}
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "")
}
}
}
return joined
}
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
role := "anonymous"
userID := "anonymous"
@@ -3651,6 +4114,15 @@ func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
func parseBoolQuery(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {

View File

@@ -106,6 +106,7 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN
type MockUserRepoForHandler struct {
mock.Mock
deletedIDs []string
listCalls int
}
func (m *MockUserRepoForHandler) DB() *gorm.DB {
@@ -145,6 +146,7 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
m.listCalls += 1
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
@@ -240,6 +242,53 @@ func toJSONString(t *testing.T, value any) string {
return string(raw)
}
func newReadyIdentityMirror(t *testing.T, now time.Time, identities ...service.KratosIdentity) *identityMirrorRedisStub {
t.Helper()
data := make(map[string]string, len(identities)+1)
for _, identity := range identities {
raw, err := json.Marshal(identity)
require.NoError(t, err)
data[identityMirrorKey(identity.ID)] = string(raw)
}
rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
RedisReady: true,
Status: "ready",
ObservedCount: int64(len(identities)),
MirrorVersion: identityMirrorVersion,
LastRefreshedAt: &now,
})
require.NoError(t, err)
data["identity:mirror:state"] = string(rawStatus)
return &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: data}}
}
func orgContextIdentityFromUserFixture(user domain.User) service.KratosIdentity {
traits := map[string]any{
"email": user.Email,
"name": user.Name,
"phone_number": user.Phone,
"department": user.Department,
"position": user.Position,
"jobTitle": user.JobTitle,
}
if user.TenantID != nil {
traits["tenant_id"] = *user.TenantID
}
if strings.TrimSpace(user.CompanyCode) != "" {
traits["tenantSlug"] = strings.TrimSpace(user.CompanyCode)
}
for key, value := range user.Metadata {
traits[key] = value
}
return service.KratosIdentity{
ID: user.ID,
State: user.Status,
Traits: traits,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -268,6 +317,43 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
func TestTenantHandler_CreateTenantDoesNotAssignSuperAdminAsCreatorMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/tenants", h.CreateTenant)
input := map[string]any{
"name": "System Created Tenant",
"slug": "system-created-tenant",
}
body, _ := json.Marshal(input)
mockSvc.On(
"RegisterTenant",
mock.Anything,
"System Created Tenant",
"system-created-tenant",
domain.TenantTypeCompany,
"",
[]string(nil),
(*string)(nil),
"",
).Return(&domain.Tenant{ID: "system-created-id", Name: "System Created Tenant", Slug: "system-created-tenant"}, nil).Once()
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -293,8 +379,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
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()
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)
@@ -306,7 +390,60 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.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)
assert.Equal(t, int64(2), res.Items[0].TotalMemberCount)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsPassesSearchToBackendQuery(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
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: "tenant-1", Name: "Saman", Slug: "saman"}}
mockSvc.On("ListTenants", mock.Anything, 25, 0, "", "saman").Return(tenants, int64(1), nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"tenant-1"}).
Return(map[string]int64{"tenant-1": 1}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=25&search=saman", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_CountTenantMembersDoesNotListUsersPerTenant(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
parentID := "parent-tenant"
childID := "child-tenant"
tenants := []domain.Tenant{
{ID: parentID, Name: "Parent", Slug: "parent"},
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
memberCounts, totalMemberCounts, err := h.countTenantMembers(context.Background(), tenants)
require.NoError(t, err)
require.Equal(t, int64(1), memberCounts[parentID])
require.Equal(t, int64(3), totalMemberCounts[parentID])
require.Equal(t, int64(2), memberCounts[childID])
require.Equal(t, int64(2), totalMemberCounts[childID])
require.Zero(t, mockUsers.listCalls)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
@@ -358,13 +495,9 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
return strings.HasPrefix(key, "orgchart:snapshot:")
}), 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()
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, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
@@ -378,18 +511,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "MISS", resp.Header.Get("X-Orgfront-Cache"))
var body struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
GeneratedAt string `json:"generatedAt"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Tenants, 2)
require.Len(t, body.Users, 1)
require.NotEmpty(t, body.GeneratedAt)
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotRefreshBypassesRedisHitAndUpdatesCache(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 17, 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},
}
users := []domain.User{
{ID: "fresh-user", Email: "fresh@example.com", Name: "Fresh User", Role: domain.RoleUser, Status: "active", TenantID: &familyID, Tenant: &tenants[0], CreatedAt: now, UpdatedAt: now},
}
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.MatchedBy(func(raw string) bool {
return strings.Contains(raw, "fresh-user")
}), 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: 1}, nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis&refresh=true", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "REFRESH", resp.Header.Get("X-Orgfront-Cache"))
var body struct {
Users []userSummary `json:"users"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Users, 1)
require.Equal(t, "fresh-user", body.Users[0].ID)
require.Equal(t, "database", body.Cache.Source)
require.False(t, body.Cache.Hit)
cache.AssertNotCalled(t, "Get", mock.Anything)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-1",
@@ -435,10 +618,8 @@ func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t
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 := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
@@ -469,10 +650,8 @@ func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesA
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 := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
@@ -515,11 +694,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
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()
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
resp, err := app.Test(req, 1000)
@@ -538,6 +713,66 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 17, 13, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
familyID := "hanmac-family-id"
companyID := "hanmac-company-id"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
identity := service.KratosIdentity{
ID: "identity-orgchart-user",
State: domain.UserStatusActive,
Traits: map[string]any{
"email": "orgchart-mirror@example.com",
"name": "OrgChart Mirror",
"phone_number": "010-2222-3333",
"tenant_id": companyID,
"tenantSlug": "hanmac",
"position": "팀장",
"jobTitle": "Mirror Source",
},
CreatedAt: now,
UpdatedAt: now,
}
identityCache := newReadyIdentityMirror(t, now, identity)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, companyID}).Return(map[string]int64{familyID: 0, companyID: 1}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Users []userSummary `json:"users"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Users, 1)
require.Equal(t, "identity-orgchart-user", body.Users[0].ID)
require.Equal(t, "orgchart-mirror@example.com", body.Users[0].Email)
require.Equal(t, "hanmac", body.Users[0].TenantSlug)
require.Equal(t, "Mirror Source", body.Users[0].JobTitle)
require.Equal(t, 1, identityCache.pageCalls)
require.Equal(t, 0, identityCache.fullCalls)
mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -566,10 +801,6 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
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()
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)
@@ -612,8 +843,6 @@ func TestTenantHandler_ListTenants(t *testing.T) {
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
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)
@@ -663,8 +892,6 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), 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)
@@ -1058,6 +1285,14 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
UpdatedAt: now,
},
}
identityFixtures := make([]service.KratosIdentity, 0, len(usersByTenantID)+len(usersBySlug)+len(usersByList))
for _, user := range usersByTenantID {
identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
}
for _, user := range usersByList {
identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
}
h.IdentityCache = newReadyIdentityMirror(t, now, identityFixtures...)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
@@ -1088,7 +1323,15 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 3)
firstUser := platformMembers[0].(map[string]any)
var firstUser map[string]any
for _, item := range platformMembers {
member := item.(map[string]any)
if member["email"] == "lead@example.com" {
firstUser = member
break
}
}
require.NotNil(t, firstUser)
require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone")
require.NotContains(t, firstUser, "tenantIds")
@@ -1132,6 +1375,83 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
}
func TestTenantHandler_GetOrgContextJSONUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
familyID := "group-hanmac-family"
companyID := "company-hanmac"
deptID := "dept-platform"
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: deptID, Type: domain.TenantTypeUserGroup, ParentID: parent(companyID), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
identity := service.KratosIdentity{
ID: "identity-platform-lead",
State: domain.UserStatusActive,
Traits: map[string]any{
"email": "mirror-lead@example.com",
"name": "Mirror Lead",
"phone_number": "010-0000-0000",
"tenant_id": companyID,
"additionalAppointments": []any{
map[string]any{
"tenantId": deptID,
"tenantSlug": "platform",
"isPrimary": true,
"isOwner": true,
"position": "실장",
"jobTitle": "SSOT Lead",
},
},
},
CreatedAt: now,
UpdatedAt: now,
}
rawIdentity, err := json.Marshal(identity)
require.NoError(t, err)
rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
RedisReady: true,
Status: "ready",
ObservedCount: 1,
MirrorVersion: identityMirrorVersion,
LastRefreshedAt: &now,
})
require.NoError(t, err)
identityCache := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(identity.ID): string(rawIdentity),
"identity:mirror:state": string(rawStatus),
}}}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("apiKeyName", "orgfront-ssot-client")
return c.Next()
})
app.Get("/org-context", h.GetOrgContext)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Contains(t, toJSONString(t, got), "mirror-lead@example.com")
require.Contains(t, toJSONString(t, got), "SSOT Lead")
require.Equal(t, 1, identityCache.pageCalls)
require.Equal(t, 0, identityCache.fullCalls)
mockUsers.AssertNotCalled(t, "FindByTenantIDs", mock.Anything, mock.Anything)
mockUsers.AssertNotCalled(t, "FindByCompanyCodes", mock.Anything, mock.Anything)
mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -1152,6 +1472,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
users := []domain.User{
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
}
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
@@ -1182,6 +1503,63 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
require.NotContains(t, tree, "directUserIds")
}
func TestTenantHandler_GetOrgContextJSONDoesNotFallbackToUserGradeForTenantMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("apiKeyName", "orgfront-ssot-client")
return c.Next()
})
app.Get("/org-context", h.GetOrgContext)
now := time.Date(2026, 6, 17, 9, 0, 0, 0, time.UTC)
tenantID := "company-hanmac"
tenants := []domain.Tenant{
{ID: tenantID, Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
users := []domain.User{
{
ID: "user-grade-only",
Email: "grade-only@example.com",
Name: "직급 단독",
Status: domain.UserStatusActive,
TenantID: &tenantID,
Grade: "책임",
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenantID,
"tenantSlug": "hanmac",
},
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{tenantID}).Return(users, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
tenantsPayload := got["tenants"].([]any)
members := tenantsPayload[0].(map[string]any)["members"].([]any)
require.Len(t, members, 1)
member := members[0].(map[string]any)
require.NotContains(t, member, "grade")
}
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -1206,7 +1584,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUsers=false", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -1258,8 +1636,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
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).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)
@@ -1271,6 +1647,7 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(152), res.Items[0].MemberCount)
assert.Equal(t, int64(152), res.Items[0].TotalMemberCount)
mockUserRepo.AssertExpectations(t)
}
@@ -1622,6 +1999,46 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ImportTenantsCSVDoesNotAssignSuperAdminAsCompanyMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/tenants/import", h.ImportTenantsCSV)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Company,COMPANY,,imported-company,,\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On(
"RegisterTenant",
mock.Anything,
"Imported Company",
"imported-company",
domain.TenantTypeCompany,
"",
[]string{},
(*string)(nil),
"",
).Return(&domain.Tenant{ID: "imported-company-id", Name: "Imported Company", Slug: "imported-company"}, nil).Once()
req := httptest.NewRequest("POST", "/tenants/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockSvc.AssertExpectations(t)
}
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}
@@ -1801,9 +2218,6 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
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()
@@ -1811,6 +2225,7 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
Service: mockSvc,
UserRepo: mockUsers,
OrgChartCache: cache,
IdentityCache: newReadyIdentityMirror(t, now),
}
app.Post("/tenants/:id/approve", h.ApproveTenant)

View File

@@ -95,11 +95,41 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
if key == "hanmacFamily" || key == "userType" {
continue
}
if key == "additionalAppointments" {
sanitized[key] = normalizeUserAppointmentGrades(value)
continue
}
sanitized[key] = value
}
return sanitized
}
func normalizeUserAppointmentGrades(raw any) []any {
appointments := userAppointmentSliceFromRaw(raw)
for i, item := range appointments {
appointment, ok := item.(map[string]any)
if !ok {
continue
}
if grade, ok := appointment["grade"].(string); ok {
appointment["grade"] = normalizeInternalGradeName(grade)
}
appointments[i] = appointment
}
return appointments
}
func normalizeInternalGradeName(grade string) string {
switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
case "상무":
return "상무이사"
case "전무":
return "전무이사"
default:
return strings.TrimSpace(grade)
}
}
func userAppointmentSliceFromRaw(raw any) []any {
switch values := raw.(type) {
case []any:
@@ -142,6 +172,144 @@ func userAppointmentTenantKey(raw any) string {
return ""
}
func userAppointmentMatchesTenant(appointment map[string]any, tenantID string, tenantSlug string) bool {
targetID := strings.ToLower(strings.TrimSpace(tenantID))
targetSlug := strings.ToLower(strings.TrimSpace(tenantSlug))
appointmentID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
appointmentSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
if appointmentSlug == "" {
appointmentSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
}
return (targetID != "" && appointmentID == targetID) ||
(targetSlug != "" && appointmentSlug == targetSlug)
}
func tenantBoundGradeFromTraits(traits map[string]any) string {
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := 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 || !userAppointmentMatchesTenant(appointment, tenantID, tenantSlug) {
continue
}
if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
return grade
}
}
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok {
continue
}
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
continue
}
if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
return grade
}
}
return ""
}
func tenantBoundGradeFromUser(user domain.User) string {
if user.Metadata == nil {
return ""
}
traits := map[string]any{
"tenant_id": userTenantIDValue(user),
}
for key, value := range user.Metadata {
traits[key] = value
}
return tenantBoundGradeFromTraits(traits)
}
func userTenantIDValue(user domain.User) string {
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func applyTenantBoundGrade(metadata map[string]any, tenant *domain.Tenant, grade string) map[string]any {
if metadata == nil {
metadata = map[string]any{}
}
if tenant == nil || strings.TrimSpace(tenant.ID) == "" {
return metadata
}
normalizedGrade := normalizeInternalGradeName(grade)
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
matched := false
for i, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok || !userAppointmentMatchesTenant(appointment, tenant.ID, tenant.Slug) {
continue
}
appointment["tenantId"] = tenant.ID
appointment["tenantSlug"] = tenant.Slug
appointment["tenantName"] = tenant.Name
if normalizedGrade == "" {
delete(appointment, "grade")
} else {
appointment["grade"] = normalizedGrade
}
appointments[i] = appointment
matched = true
}
if !matched && normalizedGrade != "" {
appointments = append(appointments, map[string]any{
"tenantId": tenant.ID,
"tenantSlug": tenant.Slug,
"tenantName": tenant.Name,
"grade": normalizedGrade,
})
}
if len(appointments) > 0 {
metadata["additionalAppointments"] = appointments
}
return metadata
}
func applyTenantBoundGradeToTraits(ctx context.Context, tenantService service.TenantService, traits map[string]any, grade string) {
delete(traits, "grade")
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := extractTraitString(traits, "tenantSlug")
var tenant *domain.Tenant
if tenantService != nil {
if tenantID != "" {
if found, err := tenantService.GetTenant(ctx, tenantID); err == nil {
tenant = found
}
}
if tenant == nil && tenantSlug != "" {
if found, err := tenantService.GetTenantBySlug(ctx, tenantSlug); err == nil {
tenant = found
}
}
}
if tenant == nil {
tenant = &domain.Tenant{ID: tenantID, Slug: tenantSlug}
}
if strings.TrimSpace(tenant.ID) == "" && strings.TrimSpace(tenant.Slug) == "" {
return
}
metadata := map[string]any{}
if appointments, ok := traits["additionalAppointments"]; ok {
metadata["additionalAppointments"] = appointments
}
metadata = applyTenantBoundGrade(metadata, tenant, grade)
if appointments, ok := metadata["additionalAppointments"]; ok {
traits["additionalAppointments"] = appointments
}
}
func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
if tenant == nil {
return metadata
@@ -507,14 +675,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
}
func gradeFromTraits(traits map[string]any) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
return ""
}
if _, ok := domain.NormalizeRoleAlias(value); ok {
return ""
}
return value
return tenantBoundGradeFromTraits(traits)
}
func rejectLegacyCompanyCode(value string) error {
@@ -631,6 +792,14 @@ type identityMirrorLister interface {
ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error)
}
type identityMirrorPageLister interface {
ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error)
}
type identityMirrorStore interface {
StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error
}
type identityMirrorStatusReader interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
}
@@ -856,68 +1025,29 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
})
}
identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context())
allowedTenantKeys := map[string]bool(nil)
if requesterRole != domain.RoleSuperAdmin {
allowedTenantKeys = manageableSlugs
}
page, err := h.listIdentityMirrorPageOrKratos(c.Context(), service.IdentityMirrorPageQuery{
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
Search: search,
TenantSlug: tenantSlug,
TenantID: targetTenantID,
AllowedTenantKeys: allowedTenantKeys,
})
if err != nil {
slog.Warn("Identity mirror unavailable for user list", "error", err)
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
}
filtered := make([]service.KratosIdentity, 0, len(identities))
searchLower := strings.ToLower(search)
for _, identity := range identities {
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
// Tenant Admin & Member filtering
if requesterRole != domain.RoleSuperAdmin {
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
if !hasAccess {
continue
}
}
// Dedicated tenantSlug filter
if tenantSlug != "" {
targetKeys := map[string]bool{
targetTenantID: true,
strings.ToLower(tenantSlug): true,
}
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
if !matches {
continue
}
}
if !identityMatchesSearch(identity, searchLower) {
continue
}
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
nextCursor := ""
var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(filtered) {
offset = len(filtered)
}
end := min(offset+limit, len(filtered))
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
items := make([]userSummary, 0, len(pageIdentities))
for _, identity := range pageIdentities {
items := make([]userSummary, 0, len(page.Items))
for _, identity := range page.Items {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
@@ -926,9 +1056,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
Items: items,
Limit: limit,
Offset: offset,
Total: total,
Total: page.Total,
Cursor: cursorRaw,
NextCursor: nextCursor,
NextCursor: page.NextCursor,
})
}
@@ -1024,6 +1154,84 @@ func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]s
return h.rebuildIdentityMirror(ctx)
}
func (h *UserHandler) listIdentityMirrorPageOrKratos(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
return lister.ListIdentityMirrorPage(ctx, query)
}
}
identities, err := h.rebuildIdentityMirror(ctx)
if err != nil {
return service.IdentityMirrorPageResult{}, err
}
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
return lister.ListIdentityMirrorPage(ctx, query)
}
}
return pageIdentityMirrorSlice(identities, query)
}
func pageIdentityMirrorSlice(identities []service.KratosIdentity, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
if query.Limit <= 0 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
searchLower := strings.ToLower(strings.TrimSpace(query.Search))
targetKeys := make(map[string]bool)
for _, value := range []string{query.TenantID, query.TenantSlug} {
key := strings.ToLower(strings.TrimSpace(value))
if key != "" {
targetKeys[key] = true
}
}
filtered := make([]service.KratosIdentity, 0, len(identities))
for _, identity := range identities {
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
if len(query.AllowedTenantKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, query.AllowedTenantKeys) {
continue
}
if len(targetKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, targetKeys) {
continue
}
if !identityMatchesSearch(identity, searchLower) {
continue
}
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
nextCursor := ""
var pageIdentities []service.KratosIdentity
if strings.TrimSpace(query.Cursor) != "" {
var err error
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, query.Limit, query.Cursor, kratosIdentityCursorKey)
if err != nil {
return service.IdentityMirrorPageResult{}, err
}
} else {
if query.Offset > len(filtered) {
query.Offset = len(filtered)
}
end := min(query.Offset+query.Limit, len(filtered))
pageIdentities = filtered[query.Offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
return service.IdentityMirrorPageResult{
Items: pageIdentities,
Total: total,
Cursor: query.Cursor,
NextCursor: nextCursor,
}, nil
}
func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) {
identities, err := h.rebuildIdentityMirror(ctx)
if err != nil {
@@ -1066,6 +1274,24 @@ func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int
status.ObservedCount == int64(identityCount)
}
func (h *UserHandler) identityMirrorStatusReady(ctx context.Context) bool {
if h == nil || h.IdentityCache == nil {
return false
}
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
if !ok {
return false
}
status, err := reader.GetIdentityCacheStatus(ctx)
if err != nil {
return false
}
return status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == identityMirrorVersion &&
status.ObservedCount > 0
}
func (h *UserHandler) flushIdentityMirror(ctx context.Context) {
if h == nil || h.IdentityCache == nil {
return
@@ -1108,6 +1334,10 @@ func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
if store, ok := h.IdentityCache.(identityMirrorStore); ok {
_ = store.StoreIdentityMirror(context.Background(), identity)
return
}
raw, err := json.Marshal(identity)
if err != nil {
return
@@ -1235,7 +1465,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
attributes := map[string]any{
"department": req.Department,
"grade": strings.TrimSpace(req.Grade),
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
@@ -1289,6 +1518,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
tenantID = tenant.ID
req.CompanyCode = tenant.Slug
resolvedTenant = tenant
}
attributes["role"] = role
@@ -1308,6 +1538,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
if strings.TrimSpace(req.Grade) != "" {
req.Metadata = applyTenantBoundGrade(req.Metadata, resolvedTenant, req.Grade)
}
// Merge custom metadata into attributes
for k, v := range req.Metadata {
// Don't overwrite core fields
@@ -1789,6 +2023,13 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
if strings.TrimSpace(item.Grade) != "" {
item.Metadata = applyTenantBoundGrade(item.Metadata, &domain.Tenant{
ID: tItem.ID,
Slug: tItem.Slug,
Name: tItem.Name,
}, item.Grade)
}
normalizeBulkUserAliasMetadata(item.Metadata)
item.Metadata = sanitizeUserMetadata(item.Metadata)
@@ -1800,7 +2041,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]any{
"department": dept,
"grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
@@ -2350,7 +2590,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
traits["department"] = *req.Department
}
if req.Grade != nil {
traits["grade"] = *req.Grade
applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
}
if req.Position != nil {
traits["position"] = *req.Position
@@ -2783,7 +3023,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["department"] = strings.TrimSpace(*req.Department)
}
if req.Grade != nil {
traits["grade"] = strings.TrimSpace(*req.Grade)
applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
}
if req.Position != nil {
traits["position"] = strings.TrimSpace(*req.Position)
@@ -3344,7 +3584,7 @@ func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User)
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Department: user.Department,
Grade: user.Grade,
Grade: tenantBoundGradeFromUser(user),
Position: user.Position,
JobTitle: user.JobTitle,
Metadata: user.Metadata,

View File

@@ -993,6 +993,22 @@ func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T)
mockOry.AssertExpectations(t)
}
func TestApplyTenantBoundGradeNormalizesDirectorLevelNames(t *testing.T) {
tenant := &domain.Tenant{ID: "tenant-1", Slug: "tenant", Name: "Tenant"}
metadata := applyTenantBoundGrade(nil, tenant, "상무")
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
require.Len(t, appointments, 1)
appointment := appointments[0].(map[string]any)
require.Equal(t, "상무이사", appointment["grade"])
metadata = applyTenantBoundGrade(metadata, tenant, "전무")
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
require.Len(t, appointments, 1)
appointment = appointments[0].(map[string]any)
require.Equal(t, "전무이사", appointment["grade"])
}
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1098,9 +1114,17 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
type identityMirrorRedisStub struct {
mockRedisRepo
pageCalls int
fullCalls int
failFull bool
lastQuery service.IdentityMirrorPageQuery
}
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
s.fullCalls++
if s.failFull {
return nil, errors.New("full identity mirror materialization is forbidden")
}
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
@@ -1118,6 +1142,35 @@ func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]se
return identities, nil
}
func (s *identityMirrorRedisStub) ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
s.pageCalls++
s.lastQuery = query
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
continue
}
var identity service.KratosIdentity
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
continue
}
if strings.TrimSpace(identity.ID) == "" {
continue
}
identities = append(identities, identity)
}
return pageIdentityMirrorSlice(identities, query)
}
func (s *identityMirrorRedisStub) StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error {
raw, err := json.Marshal(identity)
if err != nil {
return err
}
s.data[identityMirrorKey(identity.ID)] = string(raw)
return nil
}
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
raw := s.data["identity:mirror:state"]
if strings.TrimSpace(raw) == "" {
@@ -1174,7 +1227,7 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: mockRepo,
IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
IdentityCache: &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
"identity:mirror:state": string(rawState),
}}},
@@ -1200,14 +1253,71 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
require.Len(t, res.Items, 1)
require.Equal(t, "mirror-user-1", res.Items[0].ID)
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
cache := h.IdentityCache.(*identityMirrorRedisStub)
require.Equal(t, 1, cache.pageCalls)
require.Equal(t, 0, cache.fullCalls)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
}
func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
app := fiber.New()
createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
identities := []service.KratosIdentity{
{ID: "user-new", State: "active", CreatedAt: createdAt.Add(2 * time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "new@example.com", "name": "New User"}},
{ID: "user-needle", State: "active", CreatedAt: createdAt.Add(time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "needle@example.com", "name": "Needle User"}},
{ID: "user-old", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "old@example.com", "name": "Old User"}},
}
data := map[string]string{}
for _, identity := range identities {
raw, err := json.Marshal(identity)
require.NoError(t, err)
data[identityMirrorKey(identity.ID)] = string(raw)
}
state := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: identityMirrorVersion,
ObservedCount: int64(len(identities)),
}
rawState, err := json.Marshal(state)
require.NoError(t, err)
data["identity:mirror:state"] = string(rawState)
cache := &identityMirrorRedisStub{
mockRedisRepo: mockRedisRepo{data: data},
failFull: true,
}
h := &UserHandler{
KratosAdmin: new(MockKratosAdmin),
IdentityCache: cache,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/users", h.ListUsers)
req := httptest.NewRequest("GET", "/users?limit=1&search=needle", 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.Len(t, res.Items, 1)
require.Equal(t, "user-needle", res.Items[0].ID)
require.Equal(t, 1, cache.pageCalls)
require.Equal(t, 0, cache.fullCalls)
require.Equal(t, 1, cache.lastQuery.Limit)
require.Equal(t, "needle", cache.lastQuery.Search)
}
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{}}}
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
h := &UserHandler{
@@ -1330,7 +1440,7 @@ func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
}}}
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
@@ -1382,7 +1492,7 @@ func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing
}
rawLegacyState, err := json.Marshal(legacyState)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
"identity:mirror:state": string(rawLegacyState),
}}}
@@ -1436,7 +1546,7 @@ func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
}
rawPartialIdentity, err := json.Marshal(partialIdentity)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
}}}
kratosIdentities := []service.KratosIdentity{
@@ -3946,18 +4056,24 @@ func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t
mockTenant.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
func TestUserHandler_MapToLocalUserPreservesTenantBoundGradeForCompatibility(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{
ID: "user-grade-id",
State: "active",
Traits: map[string]any{
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"companyCode": "hanmac",
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"tenant_id": "tenant-1",
"additionalAppointments": []any{
map[string]any{
"tenantId": "tenant-1",
"grade": "수석",
},
},
},
}
@@ -3967,6 +4083,7 @@ func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
assert.Equal(t, "수석", localUser.Grade)
assert.Equal(t, "팀장", localUser.Position)
assert.NotContains(t, localUser.Metadata, "grade")
assert.Contains(t, localUser.Metadata, "additionalAppointments")
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {