1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/tenant_handler_test.go
chan 31d107ff2e feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
2026-06-01 15:34:08 +09:00

1475 lines
52 KiB
Go

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/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) ([]domain.Tenant, int64, error) {
args := m.Called(ctx, limit, offset, parentID)
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
}
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 { 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, tenantSlug string) ([]domain.User, int64, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, tenantSlug)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
}
}
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) {
return nil, 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 MockUserProjectionRepoForHandler struct {
mock.Mock
}
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
args := m.Called(ctx)
return args.Bool(0), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
args := m.Called(ctx)
return args.Get(0).(domain.UserProjectionStatus), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
args := m.Called(ctx, tenants)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
args := m.Called(ctx, users)
return args.Error(0)
}
func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error {
args := m.Called(ctx, syncErr)
return args.Error(0)
}
func toJSONString(t *testing.T, value any) string {
t.Helper()
raw, err := json.Marshal(value)
require.NoError(t, err)
return string(raw)
}
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_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
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)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
}
func TestTenantHandler_ListTenants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
{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).Return(tenants, int64(2), nil).Maybe()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
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)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
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()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).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)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
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: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
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)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
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: domain.RoleTenantAdmin,
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).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
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_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: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
require.NoError(t, err)
require.True(t, tenantSlugsMatch(filtered, "hanmac-family", "hanmac", "private-team", "private-child"))
mockKeto.AssertExpectations(t)
}
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 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,
},
}
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, "", "").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)
firstUser := platformMembers[0].(map[string]any)
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_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},
}
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_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", 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_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUserRepo,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
Return(map[string]int64{"saman": 152}, nil).Maybe()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()
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(2), res.Items[0].MemberCount)
mockProjection.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")
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,센터")
}
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")
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")
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: domain.RoleTenantAdmin,
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 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\n" +
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터\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)
}
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": "센터",
})
assert.NoError(t, err)
assert.Equal(t, "internal", config["visibility"])
assert.Equal(t, "센터", config["orgUnitType"])
}
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 (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)
}