forked from baron/baron-sso
네이버 계정 정합성 맞춤
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
@@ -171,6 +172,7 @@ func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context,
|
||||
|
||||
type fakeUserHandlerWorksmobileSyncer struct {
|
||||
upserts []domain.User
|
||||
updates []domain.User
|
||||
}
|
||||
|
||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
|
||||
@@ -186,6 +188,11 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx contex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
|
||||
f.updates = append(f.updates, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||
return nil
|
||||
}
|
||||
@@ -206,6 +213,18 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
|
||||
assert.Contains(t, metadata, "userType")
|
||||
}
|
||||
|
||||
func TestIdentityMatchesSearchFindsSameEmailLocalPartAcrossHanmacFamilyDomains(t *testing.T) {
|
||||
identity := service.KratosIdentity{
|
||||
ID: "user-han",
|
||||
Traits: map[string]any{
|
||||
"email": "han@samaneng.com",
|
||||
"name": "안헌",
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, identityMatchesSearch(identity, "han@hanmaceng.co.kr"))
|
||||
}
|
||||
|
||||
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
internalTenantID := "internal-tenant"
|
||||
@@ -249,6 +268,44 @@ func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestSanitizeUserRepresentativeTenantsAllowsPublicTeamOrgPrimary(t *testing.T) {
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
teamTenantID := "team-tenant"
|
||||
metadata := map[string]any{
|
||||
"primaryTenantId": teamTenantID,
|
||||
"primaryTenantName": "IS3",
|
||||
"primaryTenantSlug": "is-3",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
|
||||
},
|
||||
}
|
||||
appointments := []map[string]any{
|
||||
{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
|
||||
}
|
||||
|
||||
mockTenant.On("GetTenant", mock.Anything, teamTenantID).Return(&domain.Tenant{
|
||||
ID: teamTenantID,
|
||||
Slug: "is-3",
|
||||
Config: domain.JSONMap{
|
||||
"visibility": "public",
|
||||
"orgUnitType": "팀",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cleared)
|
||||
assert.Equal(t, teamTenantID, metadata["primaryTenantId"])
|
||||
assert.Equal(t, "IS3", metadata["primaryTenantName"])
|
||||
assert.Equal(t, "is-3", metadata["primaryTenantSlug"])
|
||||
assert.Equal(t, true, appointments[0]["isPrimary"])
|
||||
metadataAppointments := metadata["additionalAppointments"].([]any)
|
||||
firstAppointment := metadataAppointments[0].(map[string]any)
|
||||
assert.Equal(t, true, firstAppointment["isPrimary"])
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
type MockTenantServiceForUser struct {
|
||||
mock.Mock
|
||||
service.TenantService
|
||||
@@ -292,11 +349,16 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(int64), args.Error(2)
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "ListTenants" {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
@@ -659,6 +721,59 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersDoesNotAutoProvisionWorksmobileUsers(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
worksmobile := &fakeUserHandlerWorksmobileSyncer{}
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
UserRepo: mockRepo,
|
||||
Worksmobile: worksmobile,
|
||||
}
|
||||
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-123",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
|
||||
ID: "t-123",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Maybe()
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
|
||||
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "user1@test.com",
|
||||
"name": "User One",
|
||||
"tenantSlug": "test-tenant",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
assert.Empty(t, worksmobile.upserts)
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsRequestedUserID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1135,6 +1250,84 @@ func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *te
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
createdAt := time.Date(2026, 6, 15, 4, 55, 0, 0, time.UTC)
|
||||
primaryTenantID := "primary-tenant-id"
|
||||
targetTenantID := "target-tenant-id"
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "target-team").Return(&domain.Tenant{
|
||||
ID: targetTenantID,
|
||||
Slug: "target-team",
|
||||
Name: "Target Team",
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
|
||||
ID: primaryTenantID,
|
||||
Slug: "primary-team",
|
||||
Name: "Primary Team",
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||
{
|
||||
ID: "additional-member",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "additional@example.com",
|
||||
"name": "Additional Member",
|
||||
"tenant_id": primaryTenantID,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": targetTenantID,
|
||||
"tenantSlug": "target-team",
|
||||
"tenantName": "Target Team",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "outside-member",
|
||||
State: "active",
|
||||
CreatedAt: createdAt.Add(-time.Minute),
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "outside@example.com",
|
||||
"name": "Outside Member",
|
||||
"tenant_id": primaryTenantID,
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?tenantSlug=target-team&limit=20&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(1), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "additional-member", res.Items[0].ID)
|
||||
require.Equal(t, "additional@example.com", res.Items[0].Email)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
@@ -1540,6 +1733,91 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNextAvailableHanmacLocalPartIncrementsTrailingNumericSuffix(t *testing.T) {
|
||||
used := map[string]hanmacLocalPartOwner{
|
||||
"jhchoi11": {Email: "jhchoi11@hanmaceng.co.kr"},
|
||||
"jhchoi12": {Email: "jhchoi12@hallasanup.com"},
|
||||
"yskim11": {Email: "yskim11@hanmaceng.co.kr"},
|
||||
"yskim12": {Email: "yskim12@hanmaceng.co.kr"},
|
||||
"yskim13": {Email: "yskim13@hanmaceng.co.kr"},
|
||||
}
|
||||
|
||||
assert.Equal(t, "jhchoi13", nextAvailableHanmacLocalPart("jhchoi11", used))
|
||||
assert.Equal(t, "yskim14", nextAvailableHanmacLocalPart("yskim11", used))
|
||||
assert.Equal(t, "mjkim", nextAvailableHanmacLocalPart("mjkim", used))
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"users":[{"email":"internal@jangheon.co.kr","name":"Internal User","tenantSlug":"personal-team"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var payload map[string][]map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload["results"], 1)
|
||||
require.Equal(t, false, payload["results"][0]["success"])
|
||||
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockTenant.On("GetTenantByDomain", mock.Anything, "pre-cast.co.kr").Return(nil, nil)
|
||||
|
||||
body := `{"users":[{"email":"internal@pre-cast.co.kr","name":"Internal User"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var payload map[string][]map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload["results"], 1)
|
||||
require.Equal(t, false, payload["results"][0]["success"])
|
||||
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
|
||||
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1570,7 +1848,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
|
||||
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
{ID: "owner-user", Email: "han@hanmaceng.co.kr", Name: "안헌", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
}, nil).Maybe()
|
||||
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Maybe()
|
||||
|
||||
@@ -1583,12 +1861,23 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previousLogger) })
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||
assert.Contains(t, result["error"].(string), "han")
|
||||
assert.Contains(t, result["error"].(string), "han@hanmaceng.co.kr")
|
||||
assert.Contains(t, result["error"].(string), "안헌")
|
||||
assert.Contains(t, logBuffer.String(), "hanmac create email local-part conflict")
|
||||
assert.Contains(t, logBuffer.String(), "owner-user")
|
||||
assert.Contains(t, logBuffer.String(), "han@hanmaceng.co.kr")
|
||||
mockOry.AssertNotCalled(t, "CreateUser")
|
||||
}
|
||||
|
||||
@@ -1635,9 +1924,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
results := result["results"].([]any)
|
||||
assert.True(t, results[0].(map[string]any)["success"].(bool))
|
||||
assert.Len(t, worksmobile.upserts, 1)
|
||||
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
|
||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
|
||||
assert.Len(t, worksmobile.updates, 1)
|
||||
assert.Equal(t, "u-1", worksmobile.updates[0].ID)
|
||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.updates[0].Status)
|
||||
})
|
||||
|
||||
t.Run("Success - Super admin assigns legacy roles as user", func(t *testing.T) {
|
||||
@@ -1682,10 +1971,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
KetoOutboxRepo: mockOutbox,
|
||||
UserRepo: mockRepo,
|
||||
}
|
||||
app.Put("/users/bulk", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
@@ -1735,6 +2026,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
|
||||
},
|
||||
}, nil).Once()
|
||||
mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(user *domain.User) bool {
|
||||
return user != nil &&
|
||||
user.ID == "u-1" &&
|
||||
user.TenantID != nil &&
|
||||
*user.TenantID == "primary-tenant-id"
|
||||
})).Return(nil).Once()
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "Tenant" &&
|
||||
entry.Object == "team-a-id" &&
|
||||
@@ -1753,6 +2050,7 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
|
||||
@@ -2258,6 +2556,73 @@ func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRejectsHanmacDuplicateLocalPart(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
UserRepo: mockRepo,
|
||||
}
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
rootID := "hanmac-family-id"
|
||||
targetTenantID := "brsw-id"
|
||||
ownerTenantID := "hanmac-id"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
{ID: targetTenantID, Slug: "brsw", Name: "바론", ParentID: &rootID},
|
||||
{ID: ownerTenantID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
|
||||
}
|
||||
userID := "target-user-id"
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "jmhwang11@brsw.kr",
|
||||
"name": "황재민",
|
||||
"role": domain.RoleUser,
|
||||
"tenant_id": targetTenantID,
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
State: "active",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, targetTenantID).Return(&tenants[1], nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
|
||||
return slices.Contains(ids, targetTenantID) && slices.Contains(ids, ownerTenantID)
|
||||
})).Return([]domain.User{
|
||||
{ID: "owner-user-id", Email: "jmhwang2@hanmaceng.co.kr", Name: "황지만", TenantID: &ownerTenantID, CompanyCode: "hanmac"},
|
||||
}, nil).Maybe()
|
||||
mockRepo.On("FindByCompanyCodes", mock.Anything, mock.MatchedBy(func(slugs []string) bool {
|
||||
return slices.Contains(slugs, "brsw") && slices.Contains(slugs, "hanmac")
|
||||
})).Return([]domain.User{}, nil).Maybe()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"email": "jmhwang2@brsw.kr"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
message, _ := result["error"].(string)
|
||||
assert.Contains(t, message, "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
|
||||
assert.Contains(t, message, "jmhwang2")
|
||||
assert.Contains(t, message, "jmhwang2@hanmaceng.co.kr")
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -2769,9 +3134,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
assert.Len(t, worksmobile.upserts, 1)
|
||||
assert.Equal(t, "some-id", worksmobile.upserts[0].ID)
|
||||
assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID)
|
||||
assert.Empty(t, worksmobile.upserts)
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -2850,6 +3213,66 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users", h.CreateUser)
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
|
||||
|
||||
body := `{"email":"internal@hanmaceng.co.kr","password":"Password1!","name":"Internal User"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserRejectsInternalDomainExplicitPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Post("/users", h.CreateUser)
|
||||
|
||||
personalTenantID := "personal-tenant-id"
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: personalTenantID,
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"email":"internal@samaneng.com","password":"Password1!","name":"Internal User","tenantSlug":"personal-team"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -2925,9 +3348,9 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
|
||||
ID: "old-tenant-id",
|
||||
Slug: "old-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
|
||||
@@ -2936,7 +3359,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "new-tenant-id",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Maybe()
|
||||
@@ -2952,6 +3375,100 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
identity := &service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@brsw.kr",
|
||||
"name": "Internal User",
|
||||
"tenant_id": "company-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"tenantSlug":"personal-team"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserRejectsPersonalTenantInternalDomainEmail(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
identity := &service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "external@example.com",
|
||||
"name": "External User",
|
||||
"tenant_id": "personal-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "personal-tenant-id").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"email":"user@hallasanup.com"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -3272,6 +3789,163 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
|
||||
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserTenantSlugWithoutPrimaryFlagKeepsRepresentativeTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
|
||||
ID: "old-tenant-id",
|
||||
Slug: "old-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
|
||||
return extractTraitString(traits, "tenant_id") == "old-tenant-id"
|
||||
}), mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
body := `{"tenantSlug":"new-tenant"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserPrimaryTenantFlagChangesRepresentativeTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Put("/users/:id", h.UpdateUser)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "old-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Maybe()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
|
||||
return extractTraitString(traits, "tenant_id") == "new-tenant-id"
|
||||
}), mock.Anything).Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "Test User",
|
||||
"tenant_id": "new-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
body := `{"tenantSlug":"new-tenant","isPrimaryTenant":true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/users/bulk", h.BulkUpdateUsers)
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
|
||||
ID: "user-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "user@brsw.kr",
|
||||
"name": "Internal User",
|
||||
"tenant_id": "company-tenant-id",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
}, nil)
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-team",
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil)
|
||||
|
||||
body := `{"userIds":["user-id"],"tenantSlug":"personal-team"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var payload map[string][]map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload["results"], 1)
|
||||
require.Equal(t, false, payload["results"][0]["success"])
|
||||
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||
handler := &UserHandler{}
|
||||
identity := service.KratosIdentity{
|
||||
|
||||
Reference in New Issue
Block a user