package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "context" "encoding/json" "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-redis/redis/v8" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "gorm.io/gorm" ) // MockTenantService is a mock for service.TenantService type MockTenantService struct { mock.Mock } func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { args := m.Called(ctx, domainName) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) ApproveTenant(ctx context.Context, tenantID string) error { args := m.Called(ctx, tenantID) return args.Error(0) } func (m *MockTenantService) RequestRegistration(ctx context.Context, name, slug, description, domainName, adminEmail string) (*domain.Tenant, error) { args := m.Called(ctx, name, slug, description, domainName, adminEmail) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { args := m.Called(ctx, slug) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) { args := m.Called(ctx, limit, offset, parentID, search) return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) } func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]domain.Tenant), args.Error(1) } func (m *MockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { args := m.Called(ctx, domainName) return args.Bool(0), args.Error(1) } func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { args := m.Called(ctx, domainName) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } type MockUserRepoForHandler struct { mock.Mock deletedIDs []string listCalls int } func (m *MockUserRepoForHandler) DB() *gorm.DB { return nil } func (m *MockUserRepoForHandler) Create(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 } func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) { return nil, nil } func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } 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) return args.Get(0).([]domain.User), args.Get(1).(int64), args.String(2), args.Error(3) } } return nil, 0, "", nil } func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { return 0, nil } func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) { args := m.Called(ctx, tenantIDs) return args.Get(0).([]domain.User), args.Error(1) } func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { 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) { args := m.Called(ctx, codes) return args.Get(0).([]domain.User), args.Error(1) } func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { args := m.Called(ctx, codes) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(map[string]int64), args.Error(1) } func (m *MockUserRepoForHandler) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { return nil } func (m *MockUserRepoForHandler) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { return nil, nil } func (m *MockUserRepoForHandler) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { return false, nil } func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { return "", nil } type mockOrgChartCache struct { mock.Mock values map[string]string } func (m *mockOrgChartCache) Get(key string) (string, error) { args := m.Called(key) return args.String(0), args.Error(1) } func (m *mockOrgChartCache) Set(key string, value string, expiration time.Duration) error { if m.values == nil { m.values = make(map[string]string) } m.values[key] = value args := m.Called(key, value, expiration) 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 { t.Helper() raw, err := json.Marshal(value) require.NoError(t, err) 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) // CreateTenant checks h.DB != nil h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}} app.Post("/tenants", h.CreateTenant) input := map[string]any{ "name": "Test Tenant", "slug": "test-tenant", "domains": []string{"test.com"}, } body, _ := json.Marshal(input) mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string(nil), (*string)(nil), ""). Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) 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) var got map[string]any json.NewDecoder(resp.Body).Decode(&got) 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) 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: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").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{"00000000-0000-0000-0000-000000000001"}). Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse json.NewDecoder(resp.Body).Decode(&res) require.Len(t, res.Items, 1) assert.Equal(t, int64(2), res.Items[0].MemberCount) 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) } func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) { app := fiber.New() cache := &mockOrgChartCache{} cached := `{"tenants":[{"id":"family","type":"COMPANY_GROUP","name":"한맥가족","slug":"hanmac-family","description":"","status":"active","memberCount":0,"totalMemberCount":2,"createdAt":"2026-06-09T00:00:00Z","updatedAt":"2026-06-09T00:00:00Z"}],"users":[],"cache":{"source":"redis","hit":true}}` cache.On("Get", mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "orgchart:snapshot:") })).Return(cached, nil).Once() h := &TenantHandler{OrgChartCache: cache} 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", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, "HIT", resp.Header.Get("X-Orgfront-Cache")) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Equal(t, "redis", body["cache"].(map[string]any)["source"]) cache.AssertExpectations(t) } func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUsers := new(MockUserRepoForHandler) cache := &mockOrgChartCache{} now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC) familyID := "family" samanID := "saman" tenants := []domain.Tenant{ {ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: samanID, Type: domain.TenantTypeCompany, Name: "삼안", Slug: "saman", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } users := []domain.User{ {ID: "user-1", Email: "user@example.com", Name: "User One", Role: domain.RoleUser, Status: "active", TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now}, } 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, time.Duration(0)).Return(nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice() mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 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", nil) resp, err := app.Test(req) require.NoError(t, err) 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"` 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", 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() 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"] 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() h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)} 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) mockUsers := new(MockUserRepoForHandler) now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } familyID := "hanmac-family-id" samanID := "saman-id" teamID := "saman-platform-id" tenants := []domain.Tenant{ {ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: samanID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "삼안", Slug: "saman", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: teamID, Type: domain.TenantTypeUserGroup, ParentID: parent(samanID), Name: "플랫폼팀", Slug: "saman-platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } users := []domain.User{ {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} app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleUser, TenantID: &samanID, JoinedTenants: []domain.Tenant{ tenants[1], }, }) 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, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, 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) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var body struct { Tenants []tenantSummary `json:"tenants"` Users []userSummary `json:"users"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Len(t, body.Tenants, 3) require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform")) require.Len(t, body.Users, 1) mockSvc.AssertExpectations(t) 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) 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) parentID := "00000000-0000-0000-0000-000000000001" childID := "00000000-0000-0000-0000-000000000002" tenants := []domain.Tenant{ {ID: parentID, Name: "Parent", Slug: "parent"}, {ID: childID, Name: "Child", Slug: "child", ParentID: &parentID}, } mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once() 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() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Len(t, res.Items, 2) assert.Equal(t, int64(1), res.Items[0].MemberCount) 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) mockUsers.AssertExpectations(t) } func TestTenantHandler_ListTenants(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: "t1", Name: "Tenant A", Slug: "slug-a"}, {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, } // 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() mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}). Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, int64(2), res.Total) assert.Len(t, res.Items, 2) // Check if counts are mapped correctly for _, item := range res.Items { if item.Slug == "slug-a" { assert.Equal(t, int64(5), item.MemberCount) } else if item.Slug == "slug-b" { assert.Equal(t, int64(10), item.MemberCount) } } } func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(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) createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt}, {ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)}, } 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() req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil) resp, _ := app.Test(req) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Len(t, res.Items, 2) require.NotEmpty(t, res.NextCursor) } func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) { createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt}, {ID: "00000000-0000-0000-0000-000000000003", Name: "Tenant C", Slug: "slug-c", CreatedAt: createdAt}, {ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt}, } page, nextCursor, err := pageTenantsByCursor(tenants, 2, "") require.NoError(t, err) require.NotEmpty(t, nextCursor) require.Equal(t, []string{ "00000000-0000-0000-0000-000000000003", "00000000-0000-0000-0000-000000000002", }, []string{page[0].ID, page[1].ID}) nextPage, _, err := pageTenantsByCursor(tenants, 2, nextCursor) require.NoError(t, err) require.Equal(t, []string{"00000000-0000-0000-0000-000000000001"}, []string{nextPage[0].ID}) } func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUsers := new(MockUserRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserRepo: mockUsers, } 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"}, {ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: "tenant_admin", TenantID: parent("company"), }) return c.Next() }) 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).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) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Equal(t, int64(3), res.Total) require.NotContains(t, toJSONString(t, res), "private-team") require.NotContains(t, toJSONString(t, res), "private-child") } func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUsers := new(MockUserRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserRepo: mockUsers, } 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"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: "tenant_admin", TenantID: parent("company"), ManageableTenants: []domain.Tenant{ {ID: "private-team", Slug: "private-team"}, }, }) return c.Next() }) 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() 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) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Equal(t, int64(4), res.Total) require.Contains(t, toJSONString(t, res), "private-team") 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{ {ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"}, {ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } mockKeto := new(devMockKetoService) h := &TenantHandler{Keto: mockKeto} mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", "view_private").Return(true, nil).Once() filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{ ID: "user-1", Role: "tenant_admin", TenantID: parent("company"), }) require.NoError(t, err) require.True(t, tenantSlugsMatch(filtered, "hanmac-family", "hanmac", "private-team", "private-child")) 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 } counts := make(map[string]int, len(want)) for _, slug := range want { counts[slug]++ } for _, tenant := range got { counts[tenant.Slug]-- if counts[tenant.Slug] < 0 { return false } } return true } func tenantSummarySlugsMatch(got []tenantSummary, want ...string) bool { if len(got) != len(want) { return false } counts := make(map[string]int, len(want)) for _, slug := range want { counts[slug]++ } for _, tenant := range got { counts[tenant.Slug]-- } for _, count := range counts { if count != 0 { return false } } return true } func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(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, 5, 13, 12, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "root-other", Type: domain.TenantTypeCompanyGroup, Name: "다른그룹", Slug: "other-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, Config: domain.JSONMap{"orgUnitType": "실"}, CreatedAt: now, UpdatedAt: now}, {ID: "team-sso", Type: domain.TenantTypeUserGroup, ParentID: parent("dept-platform"), Name: "SSO팀", Slug: "sso", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "비공개", Slug: "private-team", Status: domain.TenantStatusActive, Config: domain.JSONMap{"visibility": "private"}, CreatedAt: now, UpdatedAt: now}, } usersByTenantID := []domain.User{ { ID: "user-platform-lead", Email: "lead@example.com", Name: "플랫폼 리드", Phone: "010-1111-2222", Status: domain.UserStatusActive, TenantID: parent("dept-platform"), CompanyCode: "platform", Grade: "책임", Position: "실장", JobTitle: "Backend Engineer", Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantId": "dept-platform", "isPrimary": true, "isOwner": true, "grade": "수석", "position": "실장", "jobTitle": "기술기획", }, }, }, CreatedAt: now, UpdatedAt: now, }, { ID: "user-archived", Email: "archived@example.com", Name: "보관 사용자", Status: domain.UserStatusArchived, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-suspended", Email: "suspended@example.com", Name: "정지 사용자", Status: domain.UserStatusSuspended, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-temporary-leave", Email: "temporary-leave@example.com", Name: "단기휴무 사용자", Status: domain.UserStatusTemporaryLeave, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-preboarding", Email: "preboarding@example.com", Name: "입사대기 사용자", Status: domain.UserStatusPreboarding, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-baron-guest", Email: "baron-guest@example.com", Name: "Baron Guest", Status: domain.UserStatusBaronGuest, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-extended-leave", Email: "extended-leave@example.com", Name: "장기휴직 사용자", Status: domain.UserStatusExtendedLeave, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, } usersBySlug := []domain.User{ {ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now}, } usersByList := []domain.User{ { ID: "user-appointment-only", Email: "appointment@example.com", Name: "겸직 사용자", Status: domain.UserStatusActive, Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantSlug": "sso", "isManager": true, "position": "파트장", }, }, }, CreatedAt: now, 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) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil) mockUsers.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").Return(usersByList, int64(len(usersByList)), "", 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.Equal(t, "baron.org-context.v1", got["schemaVersion"]) scope := got["scope"].(map[string]any) require.Equal(t, "group-hanmac-family", scope["tenantId"]) require.Equal(t, "hanmac-family", scope["tenantSlug"]) tenantsPayload := got["tenants"].([]any) require.Len(t, tenantsPayload, 4) require.Equal(t, "group-hanmac-family", tenantsPayload[0].(map[string]any)["id"]) require.Equal(t, "company-hanmac", tenantsPayload[1].(map[string]any)["id"]) require.Equal(t, "dept-platform", tenantsPayload[2].(map[string]any)["id"]) require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"]) require.NotContains(t, got, "users") deptPlatform := tenantsPayload[2].(map[string]any) platformMembers := deptPlatform["members"].([]any) require.Len(t, platformMembers, 3) 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") require.NotContains(t, firstUser, "tenantSlugs") require.NotContains(t, firstUser, "memberships") require.NotContains(t, firstUser, "role") require.NotContains(t, firstUser, "status") require.NotContains(t, firstUser, "metadata") require.NotContains(t, firstUser, "createdAt") require.NotContains(t, firstUser, "updatedAt") require.Equal(t, "lead@example.com", firstUser["email"]) require.Equal(t, "플랫폼 리드", firstUser["name"]) require.Equal(t, true, firstUser["isOwner"]) require.Equal(t, false, firstUser["isManager"]) require.Equal(t, true, firstUser["isPrimary"]) require.NotContains(t, firstUser, "isLeader") require.Equal(t, "수석", firstUser["grade"]) require.Equal(t, "실장", firstUser["position"]) require.Equal(t, "기술기획", firstUser["jobTitle"]) teamSSO := tenantsPayload[3].(map[string]any) ssoMembers := teamSSO["members"].([]any) require.Len(t, ssoMembers, 1) appointmentOnly := ssoMembers[0].(map[string]any) require.Equal(t, "appointment@example.com", appointmentOnly["email"]) require.Equal(t, false, appointmentOnly["isOwner"]) require.Equal(t, true, appointmentOnly["isManager"]) require.NotContains(t, appointmentOnly, "isLeader") tree := got["tree"].(map[string]any) require.Equal(t, "group-hanmac-family", tree["id"]) require.NotContains(t, tree, "directUserIds") require.Contains(t, tree, "members") require.NotContains(t, toJSONString(t, got), "directUserIds") require.NotContains(t, toJSONString(t, got), "private-team") require.NotContains(t, toJSONString(t, got), "root-other") require.NotContains(t, toJSONString(t, got), "archived@example.com") require.Contains(t, toJSONString(t, got), "suspended@example.com") require.Contains(t, toJSONString(t, got), "temporary-leave@example.com") require.NotContains(t, toJSONString(t, got), "preboarding@example.com") require.NotContains(t, toJSONString(t, got), "baron-guest@example.com") 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) 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, 5, 13, 12, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "company-hanmac", Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } 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) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil) req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUserIds=true", 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.NotContains(t, got, "users") tenantsPayload := got["tenants"].([]any) members := tenantsPayload[0].(map[string]any)["members"].([]any) require.Len(t, members, 1) member := members[0].(map[string]any) require.Equal(t, "user-1", member["id"]) require.Equal(t, "010-1234-5678", member["phone"]) require.NotContains(t, member, "tenantIds") require.NotContains(t, member, "tenantSlugs") require.NotContains(t, member, "memberships") tree := got["tree"].(map[string]any) treeMembers := tree["members"].([]any) require.Len(t, treeMembers, 1) require.Equal(t, "user-1", treeMembers[0].(map[string]any)["id"]) require.Equal(t, "010-1234-5678", treeMembers[0].(map[string]any)["phone"]) 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) 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, 5, 13, 12, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) 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&includeUsers=false", 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)) scope := got["scope"].(map[string]any) require.Equal(t, "company-hanmac", scope["tenantId"]) require.Equal(t, "hanmac", scope["tenantSlug"]) require.Contains(t, toJSONString(t, got), "dept-platform") require.NotContains(t, toJSONString(t, got), "company-other") } func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) { app := fiber.New() h := &TenantHandler{} app.Get("/org-context", h.GetOrgContext) req := httptest.NewRequest(http.MethodGet, "/org-context", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUserRepo := new(MockUserRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserRepo: mockUserRepo, } 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() 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() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse json.NewDecoder(resp.Body).Decode(&res) 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) } func TestTenantHandler_ExportTenantsCSV(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) parentID := "parent-1" tenants := []domain.Tenant{ { ID: "t1", Name: "Tenant A", Type: domain.TenantTypeCompany, ParentID: &parentID, Slug: "tenant-a", Description: "Primary tenant", Config: domain.JSONMap{ "visibility": "internal", "orgUnitType": "센터", }, Domains: []domain.TenantDomain{ {Domain: "tenant-a.example.com"}, {Domain: "login.tenant-a.example.com"}, }, }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv") assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0]) assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync") assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터,yes") } func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) parentID := "parent-1" tenants := []domain.Tenant{ { ID: parentID, Name: "Parent Tenant", Type: domain.TenantTypeCompanyGroup, Slug: "parent-tenant", }, { ID: "child-1", Name: "Child Tenant", Type: domain.TenantTypeUserGroup, ParentID: &parentID, Slug: "child-tenant", }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) text := string(body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync") assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,") assert.NotContains(t, text, "tenant_id") assert.NotContains(t, text, "parent_tenant_id") assert.NotContains(t, text, "child-1") mockSvc.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) oldest := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC) middle := oldest.Add(time.Hour) newest := oldest.Add(2 * time.Hour) tenants := []domain.Tenant{ {ID: "newest", Name: "Newest Tenant", Type: domain.TenantTypeCompany, Slug: "newest", CreatedAt: newest}, {ID: "middle", Name: "Middle Tenant", Type: domain.TenantTypeCompany, Slug: "middle", CreatedAt: middle}, {ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest}, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) lines := strings.Split(strings.TrimSpace(string(body)), "\n") require.Equal(t, http.StatusOK, resp.StatusCode) require.Len(t, lines, 4) assert.Contains(t, lines[1], "oldest,Oldest Tenant") assert.Contains(t, lines[2], "middle,Middle Tenant") assert.Contains(t, lines[3], "newest,Newest Tenant") mockSvc.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) parentID := "11111111-2222-4333-8444-555555555555" childID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" grandchildID := "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" unrelatedID := "cccccccc-dddd-4eee-8fff-111111111111" tenants := []domain.Tenant{ { ID: parentID, Name: "Parent Org", Type: domain.TenantTypeCompany, Slug: "parent-org", }, { ID: childID, Name: "Child Org", Type: domain.TenantTypeOrganization, ParentID: &parentID, Slug: "child-org", }, { ID: grandchildID, Name: "Leaf Team", Type: domain.TenantTypeUserGroup, ParentID: &childID, Slug: "leaf-team", }, { ID: unrelatedID, Name: "Unrelated Org", Type: domain.TenantTypeOrganization, Slug: "unrelated-org", }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) text := string(body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync") assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,") assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,") assert.NotContains(t, text, unrelatedID) assert.NotContains(t, text, "Parent Org") mockSvc.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(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"}, {ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: "tenant_admin", TenantID: parent("company"), }) return c.Next() }) app.Get("/tenants/export", h.ExportTenantsCSV) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once() req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) text := string(body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, text, "public-team") assert.NotContains(t, text, "private-team") assert.NotContains(t, text, "private-child") mockSvc.AssertExpectations(t) } func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} 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("tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Imported Tenant,COMPANY,parent-1,imported-tenant,Imported memo,imported.example.com;login.imported.example.com\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 Tenant", "imported-tenant", domain.TenantTypeCompany, "Imported memo", []string{"imported.example.com", "login.imported.example.com"}, mock.MatchedBy(func(parentID *string) bool { return parentID != nil && *parentID == "parent-1" }), "", ).Return(&domain.Tenant{ID: "imported-1", Name: "Imported Tenant", Slug: "imported-tenant"}, nil) 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) var got map[string]any json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, float64(1), got["created"]) assert.Equal(t, float64(0), got["updated"]) assert.Equal(t, float64(0), got["failed"]) mockSvc.AssertExpectations(t) } func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} 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_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,ORGANIZATION,parent-slug,child-slug,,\n")) assert.NoError(t, err) assert.NoError(t, writer.Close()) parentID := "parent-id" mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, "Parent Tenant", "parent-slug", domain.TenantTypeCompany, "", []string{}, (*string)(nil), "", ).Return(&domain.Tenant{ID: parentID, Name: "Parent Tenant", Slug: "parent-slug"}, nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, "Child Tenant", "child-slug", domain.TenantTypeOrganization, "", []string{}, mock.MatchedBy(func(got *string) bool { return got != nil && *got == parentID }), "", ).Return(&domain.Tenant{ID: "child-id", Name: "Child Tenant", Slug: "child-slug"}, 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) var got map[string]any json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, float64(2), got["created"]) assert.Equal(t, float64(0), got["failed"]) mockSvc.AssertExpectations(t) } func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(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"}) 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 Org,ORGANIZATION,parent-1,imported-org,,\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 Org", "imported-org", domain.TenantTypeOrganization, "", []string{}, mock.MatchedBy(func(parentID *string) bool { return parentID != nil && *parentID == "parent-1" }), "", ).Return(&domain.Tenant{ID: "imported-org-id", Name: "Imported Org", Slug: "imported-org"}, 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) var got map[string]any json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, float64(1), got["created"]) assert.Equal(t, float64(0), got["failed"]) 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")) } func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) { records, err := parseTenantCSVRecords(strings.NewReader( "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" + "Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터,no\n", )) assert.NoError(t, err) assert.Len(t, records, 1) assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains) assert.Equal(t, "internal", records[0].Visibility) assert.Equal(t, "센터", records[0].OrgUnitType) assert.Equal(t, "no", records[0].WorksmobileSync) config, err := tenantCSVRecordConfig(records[0]) assert.NoError(t, err) assert.Equal(t, true, config["worksmobileExcluded"]) } func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) { got := normalizeTenantDomainInputs([]string{ "samaneng.com, hanmaceng.co.kr", " LOGIN.HMAC.KR\nportal.hmac.kr ", "samaneng.com", }) assert.Equal(t, []string{ "samaneng.com", "hanmaceng.co.kr", "login.hmac.kr", "portal.hmac.kr", }, got) } func TestNormalizeTenantConfigForcesIndexedForLoginIDFields(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "userSchema": []any{ map[string]any{ "key": "emp_no", "label": "사번", "type": "text", "indexed": false, "isLoginId": true, "maxLength": 20, }, }, }) assert.NoError(t, err) fields, ok := config["userSchema"].([]any) assert.True(t, ok) assert.Len(t, fields, 1) field, ok := fields[0].(map[string]any) assert.True(t, ok) assert.Equal(t, true, field["indexed"]) assert.Equal(t, true, field["isLoginId"]) assert.NotContains(t, field, "maxLength") } func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) { _, err := normalizeTenantConfig(map[string]any{ "userSchema": []any{ map[string]any{ "key": "emp_no", "type": "number", "isLoginId": true, }, }, }) assert.Error(t, err) assert.Contains(t, err.Error(), "login ID fields must be text") } func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "visibility": "internal", "orgUnitType": "센터", "worksmobileExcluded": true, }) assert.NoError(t, err) assert.Equal(t, "internal", config["visibility"]) assert.Equal(t, "센터", config["orgUnitType"]) assert.Equal(t, true, config["worksmobileExcluded"]) } func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) { for _, orgUnitType := range []string{"TF", "TF팀", "임원직속"} { t.Run(orgUnitType, func(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "orgUnitType": orgUnitType, }) assert.NoError(t, err) assert.Equal(t, orgUnitType, config["orgUnitType"]) }) } } func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) { _, err := normalizeTenantConfig(map[string]any{ "visibility": "secret", }) assert.Error(t, err) assert.Contains(t, err.Error(), "visibility must be public, internal, or private") } func TestValidateTenantOrgConfigScopeRequiresHanmacFamilyDescendant(t *testing.T) { hanmacFamily := domain.Tenant{ID: "family", Slug: "hanmac-family", Type: domain.TenantTypeCompanyGroup} saman := domain.Tenant{ID: "saman", Slug: "saman", Type: domain.TenantTypeCompany, ParentID: &hanmacFamily.ID} outsider := domain.Tenant{ID: "outsider", Slug: "outsider", Type: domain.TenantTypeCompany} err := validateTenantOrgConfigScope(saman, []domain.Tenant{hanmacFamily, saman}, domain.JSONMap{ "visibility": "private", "orgUnitType": "팀", }) assert.NoError(t, err) err = validateTenantOrgConfigScope(outsider, []domain.Tenant{hanmacFamily, saman, outsider}, domain.JSONMap{ "visibility": "private", }) assert.Error(t, err) assert.Contains(t, err.Error(), "only hanmac-family descendants") } func TestFilterPublicTenantsExcludesInternalPrivateAndDescendants(t *testing.T) { root := domain.Tenant{ID: "root", Slug: "hanmac-family"} publicTenant := domain.Tenant{ID: "public", Slug: "public", ParentID: &root.ID} internalTenant := domain.Tenant{ID: "internal", Slug: "internal", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "internal"}} privateTenant := domain.Tenant{ID: "private", Slug: "private", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "private"}} privateChild := domain.Tenant{ID: "private-child", Slug: "private-child", ParentID: &privateTenant.ID} filtered := filterPublicTenants([]domain.Tenant{ root, publicTenant, internalTenant, privateTenant, privateChild, }) assert.Equal(t, []domain.Tenant{root, publicTenant}, filtered) } func TestTenantHandler_ApproveTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Post("/tenants/:id/approve", h.ApproveTenant) mockSvc.On("ApproveTenant", mock.Anything, "t1").Return(nil) req := httptest.NewRequest("POST", "/tenants/t1/approve", nil) resp, _ := app.Test(req) 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() 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, IdentityCache: newReadyIdentityMirror(t, now), } 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) } func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) != nil { return args.Get(0).([]domain.Tenant), args.Error(1) } return nil, args.Error(1) }