forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user