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

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