1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/user_handler_test.go

4096 lines
138 KiB
Go

package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"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"
)
// --- Mocks ---
type MockKratosAdmin struct {
mock.Mock
}
func (m *MockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
args := m.Called(ctx)
return args.Get(0).([]service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
args := m.Called(ctx, identifier)
return args.String(0), args.Error(1)
}
func (m *MockKratosAdmin) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdmin) UpdateIdentity(ctx context.Context, id string, traits map[string]any, state string) (*service.KratosIdentity, error) {
args := m.Called(ctx, id, traits, state)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdmin) UpdateIdentityPassword(ctx context.Context, id, pw string) error {
return m.Called(ctx, id, pw).Error(0)
}
func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
args := m.Called(ctx, identityID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]service.KratosSession), args.Error(1)
}
func (m *MockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosSession), args.Error(1)
}
func (m *MockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error {
return m.Called(ctx, sessionID).Error(0)
}
type MockOryProvider struct {
mock.Mock
}
func (m *MockOryProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
args := m.Called(user, password)
return args.String(0), args.Error(1)
}
func (m *MockOryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return m.Called(loginID, newPassword, r).Error(0)
}
func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
args := m.Called()
return args.Get(0).(*domain.PasswordPolicy), args.Error(1)
}
type userHandlerMockKetoService struct {
mock.Mock
}
func (m *userHandlerMockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *userHandlerMockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *userHandlerMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *userHandlerMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *userHandlerMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
type userHandlerMockKetoOutboxRepository struct {
mock.Mock
}
func (m *userHandlerMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
return m.Called(ctx, entry).Error(0)
}
func (m *userHandlerMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
return m.Called(tx, entry).Error(0)
}
func (m *userHandlerMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, limit)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *userHandlerMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, namespace, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *userHandlerMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
}
func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
type fakeUserHandlerWorksmobileSyncer struct {
upserts []domain.User
updates []domain.User
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
f.upserts = append(f.upserts, user)
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
}
func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
metadata := map[string]any{
"hanmacFamily": true,
"userType": "hanmac",
"employeeId": "E001",
}
sanitized := sanitizeUserMetadata(metadata)
assert.NotContains(t, sanitized, "hanmacFamily")
assert.NotContains(t, sanitized, "userType")
assert.Equal(t, "E001", sanitized["employeeId"])
assert.Contains(t, metadata, "hanmacFamily")
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"
publicTenantID := "public-tenant"
metadata := map[string]any{
"primaryTenantId": internalTenantID,
"primaryTenantName": "비공개팀",
"primaryTenantSlug": "private-team",
"additionalAppointments": []any{
map[string]any{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
map[string]any{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
},
}
appointments := []map[string]any{
{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
}
mockTenant.On("GetTenant", mock.Anything, internalTenantID).Return(&domain.Tenant{
ID: internalTenantID,
Slug: "private-team",
Config: domain.JSONMap{"visibility": "private"},
}, nil)
mockTenant.On("GetTenant", mock.Anything, publicTenantID).Return(&domain.Tenant{
ID: publicTenantID,
Slug: "public-team",
Config: domain.JSONMap{"visibility": "public"},
}, nil).Maybe()
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
require.NoError(t, err)
assert.True(t, cleared)
assert.NotContains(t, metadata, "primaryTenantId")
assert.NotContains(t, metadata, "primaryTenantName")
assert.NotContains(t, metadata, "primaryTenantSlug")
assert.Equal(t, false, appointments[0]["isPrimary"])
metadataAppointments := metadata["additionalAppointments"].([]any)
firstAppointment := metadataAppointments[0].(map[string]any)
assert.Equal(t, false, firstAppointment["isPrimary"])
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
}
func (m *MockTenantServiceForUser) 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 *MockTenantServiceForUser) 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 *MockTenantServiceForUser) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "GetTenantByDomain" {
args := m.Called(ctx, emailDomain)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
}
return nil, nil
}
func (m *MockTenantServiceForUser) 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 *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
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 nil, 0, nil
}
func (m *MockTenantServiceForUser) 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)
}
func (m *MockTenantServiceForUser) 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)
}
// --- Tests ---
func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) {
app := fiber.New()
mockRepo := new(MockUserRepoForHandler)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{UserRepo: mockRepo, TenantService: mockTenant}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/export", h.ExportUsersCSV)
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
tenantID := "tenant-uuid"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
}, nil).Once()
mockRepo.On("List", mock.Anything, 0, 10000, "", []string{tenantID}, "").
Return([]domain.User{
{
ID: "u-1",
Email: "user@test.com",
Name: "Test User",
Phone: "010-1111-2222",
Role: domain.RoleSuperAdmin,
Status: "active",
TenantID: &tenantID,
Tenant: &domain.Tenant{ID: tenantID, Slug: "test-tenant"},
Department: "Legacy Department",
Grade: "책임",
Position: "팀장",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
}, int64(1), "", nil).Maybe()
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Grade,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,tenant-uuid,test-tenant,책임,팀장")
assert.NotContains(t, body, "Role")
assert.NotContains(t, body, "Department")
mockTenant.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}
func TestUserHandler_ExportUsersCSV_UnknownTenantSlugDoesNotFallbackToAllUsers(t *testing.T) {
app := fiber.New()
mockRepo := new(MockUserRepoForHandler)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{UserRepo: mockRepo, TenantService: mockTenant}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/export", h.ExportUsersCSV)
mockTenant.On("GetTenantBySlug", mock.Anything, "missing-tenant").Return(nil, nil).Once()
req := httptest.NewRequest("GET", "/users/export?tenantSlug=missing-tenant&includeIds=true", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
app := fiber.New()
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{UserRepo: mockRepo}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/export", h.ExportUsersCSV)
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
tenantID := "tenant-uuid"
mockRepo.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").
Return([]domain.User{
{
ID: "user-uuid",
Email: "user@test.com",
Name: "Test User",
Phone: "010-1111-2222",
Status: "active",
TenantID: &tenantID,
Tenant: &domain.Tenant{ID: tenantID, Slug: "test-tenant"},
Grade: "책임",
Position: "팀장",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
}, int64(1), "", nil).Maybe()
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Grade,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant,책임,팀장")
assert.NotContains(t, body, "user-uuid")
assert.NotContains(t, body, "tenant-uuid")
assert.NotContains(t, body, "ID,")
mockRepo.AssertExpectations(t)
}
func TestUserHandler_ExportUsersCSV_NonSuperAdminForbidden(t *testing.T) {
app := fiber.New()
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{UserRepo: mockRepo}
tenantID := "tenant-uuid"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "tenant_admin",
TenantID: &tenantID,
ManageableTenants: []domain.Tenant{
{ID: tenantID, Slug: "test-tenant"},
},
})
return c.Next()
})
app.Get("/users/export", h.ExportUsersCSV)
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestUserHandler_BulkCreateUsers(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)
t.Run("Success - 2 users", func(t *testing.T) {
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
},
},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
},
},
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// [FIX] Search-first diagnostic calls
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "user1@test.com",
"name": "User One",
"tenantSlug": "test-tenant",
"metadata": map[string]any{"emp_id": "E001"},
},
{
"email": "user2@test.com",
"name": "User Two",
"tenantSlug": "test-tenant",
"metadata": map[string]any{"emp_id": "E002"},
},
},
}
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)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.Len(t, results, 2)
assert.True(t, results[0].(map[string]any)["success"].(bool))
assert.True(t, results[1].(map[string]any)["success"].(bool))
})
t.Run("Fail - Tenant Not Found", func(t *testing.T) {
mockTenant.On("GetTenantBySlug", mock.Anything, "wrong-tenant").Return(nil, errors.New("not found")).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
payload := map[string]any{
"users": []map[string]any{
{
"email": "fail@test.com",
"name": "Fail User",
"tenantSlug": "wrong-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)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.False(t, results[0].(map[string]any)["success"].(bool))
assert.Contains(t, results[0].(map[string]any)["message"].(string), "tenant not found")
})
t.Run("Fail - Schema Validation (Required)", func(t *testing.T) {
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
},
},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
},
},
}, nil)
payload := map[string]any{
"users": []map[string]any{
{
"email": "missing-meta@test.com",
"name": "No Meta",
"tenantSlug": "test-tenant",
"metadata": map[string]any{}, // emp_id missing
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.False(t, results[0].(map[string]any)["success"].(bool))
assert.Contains(t, results[0].(map[string]any)["message"].(string), "field emp_id is required")
})
t.Run("Fail - Schema Validation (Regex)", func(t *testing.T) {
app := fiber.New()
app.Post("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleUser,
ManageableTenants: []domain.Tenant{
{ID: "t-regex", Slug: "regex-tenant"},
},
})
return h.BulkCreateUsers(c)
})
mockTenant.On("GetTenantBySlug", mock.Anything, "regex-tenant").Return(&domain.Tenant{
ID: "t-regex",
Slug: "regex-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "validation": "^E[0-9]{3}$"},
},
},
}, nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "regex-fail@test.com",
"name": "Regex Fail",
"tenantSlug": "regex-tenant",
"metadata": map[string]any{"emp_id": "abcde"}, // Should start with E and 3 digits
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
res := results[0].(map[string]any)
assert.False(t, res["success"].(bool))
message, _ := res["message"].(string)
assert.Contains(t, message, "match validation pattern")
})
}
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)
mockOry := new(MockOryProvider)
const requestedUserID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
payload := map[string]any{
"users": []map[string]any{
{
"userId": requestedUserID,
"email": "restore@test.com",
"name": "Restore User",
"tenantId": "tenant-123",
"tenantSlug": "restore-tenant",
"metadata": map[string]any{},
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
results := result["results"].([]any)
require.Len(t, results, 1)
row := results[0].(map[string]any)
assert.False(t, row["success"].(bool))
assert.Contains(t, row["message"].(string), "사용자 UUID 가져오기는 지원하지 않습니다")
mockOry.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "FindIdentityIDByIdentifier", mock.Anything, mock.Anything)
}
func TestUserHandler_BulkCreateUsersRejectsDuplicateAliasEmailsInBatch(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).Maybe()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "user1@samaneng.com",
"name": "User One",
"tenantSlug": "rnd-saman",
"metadata": map[string]interface{}{
"sub_email": []interface{}{"shared@hanmaceng.co.kr"},
},
},
{
"email": "user2@samaneng.com",
"name": "User Two",
"tenantSlug": "rnd-saman",
"metadata": map[string]interface{}{
"worksmobileAliasEmails": []interface{}{"shared@hanmaceng.co.kr"},
},
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.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 result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
results := result["results"].([]interface{})
require.Len(t, results, 2)
for _, item := range results {
row := item.(map[string]interface{})
require.False(t, row["success"].(bool))
require.Equal(t, "blockingError", row["status"])
require.Contains(t, row["message"].(string), "duplicate email")
}
mockOry.AssertExpectations(t)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
}
func TestUserHandler_BulkCreateUsersRejectsPrimaryEmailUsedAsSubEmail(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).Maybe()
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "user1@samaneng.com",
"name": "User One",
"tenantSlug": "rnd-saman",
"metadata": map[string]interface{}{
"sub_email": []interface{}{"user2@samaneng.com"},
},
},
{
"email": "user2@samaneng.com",
"name": "User Two",
"tenantSlug": "rnd-saman",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.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 result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
results := result["results"].([]interface{})
require.Len(t, results, 2)
for _, item := range results {
row := item.(map[string]interface{})
require.False(t, row["success"].(bool))
require.Equal(t, "blockingError", row["status"])
require.Contains(t, row["message"].(string), "duplicate email")
}
mockOry.AssertExpectations(t)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything)
}
func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(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)
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-primary",
Slug: "test-tenant",
Name: "Primary Tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "t-primary").Return(&domain.Tenant{
ID: "t-primary",
Slug: "test-tenant",
Name: "Primary Tenant",
Config: domain.JSONMap{},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "second-tenant").Return(&domain.Tenant{
ID: "t-second",
Slug: "second-tenant",
Name: "Second Tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "dual@test.com",
"name": "Dual User",
"tenantSlug": "test-tenant",
"metadata": map[string]any{"employee_id": "EMP001"},
"additionalAppointments": []map[string]any{
{
"tenantSlug": "second-tenant",
"department": "센터",
"grade": "수석",
"jobTitle": "Architecture",
"metadata": map[string]any{"employee_id": "EMP002"},
},
},
},
},
}
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)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestApplyTenantBoundGradeNormalizesDirectorLevelNames(t *testing.T) {
tenant := &domain.Tenant{ID: "tenant-1", Slug: "tenant", Name: "Tenant"}
metadata := applyTenantBoundGrade(nil, tenant, "상무")
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
require.Len(t, appointments, 1)
appointment := appointments[0].(map[string]any)
require.Equal(t, "상무이사", appointment["grade"])
metadata = applyTenantBoundGrade(metadata, tenant, "전무")
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
require.Len(t, appointments, 1)
appointment = appointments[0].(map[string]any)
require.Equal(t, "전무이사", appointment["grade"])
}
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(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)
mockTenant.On("GetTenantBySlug", mock.Anything, "gpdtdc").Return(&domain.Tenant{
ID: "t-gpdtdc",
Slug: "gpdtdc",
Name: "GPDTDC",
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "t-gpdtdc").Return(&domain.Tenant{
ID: "t-gpdtdc",
Slug: "gpdtdc",
Name: "GPDTDC",
Config: domain.JSONMap{},
}, nil)
mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "user@samaneng.com",
"name": "Domain User",
"tenantSlug": "gpdtdc",
},
},
}
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, http.StatusOK, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.True(t, results[0].(map[string]any)["success"].(bool))
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitTenantMissing(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)
mockTenant.On("GetTenantByDomain", mock.Anything, "samaneng.com").Return(&domain.Tenant{
ID: "t-saman",
Slug: "saman",
Name: "삼안",
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "user@samaneng.com",
"name": "Domain Primary User",
},
},
}
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, http.StatusOK, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.True(t, results[0].(map[string]any)["success"].(bool))
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
}
type identityMirrorRedisStub struct {
mockRedisRepo
pageCalls int
fullCalls int
failFull bool
lastQuery service.IdentityMirrorPageQuery
}
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
s.fullCalls++
if s.failFull {
return nil, errors.New("full identity mirror materialization is forbidden")
}
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
continue
}
var identity service.KratosIdentity
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
continue
}
if strings.TrimSpace(identity.ID) == "" {
continue
}
identities = append(identities, identity)
}
return identities, nil
}
func (s *identityMirrorRedisStub) ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
s.pageCalls++
s.lastQuery = query
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
continue
}
var identity service.KratosIdentity
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
continue
}
if strings.TrimSpace(identity.ID) == "" {
continue
}
identities = append(identities, identity)
}
return pageIdentityMirrorSlice(identities, query)
}
func (s *identityMirrorRedisStub) StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error {
raw, err := json.Marshal(identity)
if err != nil {
return err
}
s.data[identityMirrorKey(identity.ID)] = string(raw)
return nil
}
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
raw := s.data["identity:mirror:state"]
if strings.TrimSpace(raw) == "" {
return domain.IdentityCacheStatus{RedisReady: true, Status: "empty"}, nil
}
var status domain.IdentityCacheStatus
if err := json.Unmarshal([]byte(raw), &status); err != nil {
return domain.IdentityCacheStatus{}, err
}
return status, nil
}
func (s *identityMirrorRedisStub) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
var deleted int64
for key := range s.data {
if strings.HasPrefix(key, "identity:mirror:") || strings.HasPrefix(key, "identity:index:") {
delete(s.data, key)
deleted++
}
}
return domain.IdentityCacheFlushResult{
Status: "success",
FlushedKeys: deleted,
UpdatedAt: time.Now().UTC(),
}, nil
}
func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockRepo := new(MockUserRepoForHandler)
createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC)
mirrorIdentity := service.KratosIdentity{
ID: "mirror-user-1",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "mirror1@example.com",
"name": "Mirror One",
},
}
rawMirrorIdentity, err := json.Marshal(mirrorIdentity)
require.NoError(t, err)
state := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: identityMirrorVersion,
ObservedCount: 1,
}
rawState, err := json.Marshal(state)
require.NoError(t, err)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: mockRepo,
IdentityCache: &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
"identity:mirror:state": string(rawState),
}}},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.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, "mirror-user-1", res.Items[0].ID)
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
cache := h.IdentityCache.(*identityMirrorRedisStub)
require.Equal(t, 1, cache.pageCalls)
require.Equal(t, 0, cache.fullCalls)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
}
func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
app := fiber.New()
createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
identities := []service.KratosIdentity{
{ID: "user-new", State: "active", CreatedAt: createdAt.Add(2 * time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "new@example.com", "name": "New User"}},
{ID: "user-needle", State: "active", CreatedAt: createdAt.Add(time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "needle@example.com", "name": "Needle User"}},
{ID: "user-old", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "old@example.com", "name": "Old User"}},
}
data := map[string]string{}
for _, identity := range identities {
raw, err := json.Marshal(identity)
require.NoError(t, err)
data[identityMirrorKey(identity.ID)] = string(raw)
}
state := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: identityMirrorVersion,
ObservedCount: int64(len(identities)),
}
rawState, err := json.Marshal(state)
require.NoError(t, err)
data["identity:mirror:state"] = string(rawState)
cache := &identityMirrorRedisStub{
mockRedisRepo: mockRedisRepo{data: data},
failFull: true,
}
h := &UserHandler{
KratosAdmin: new(MockKratosAdmin),
IdentityCache: cache,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/users", h.ListUsers)
req := httptest.NewRequest("GET", "/users?limit=1&search=needle", 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.Len(t, res.Items, 1)
require.Equal(t, "user-needle", res.Items[0].ID)
require.Equal(t, 1, cache.pageCalls)
require.Equal(t, 0, cache.fullCalls)
require.Equal(t, 1, cache.lastQuery.Limit)
require.Equal(t, "needle", cache.lastQuery.Search)
}
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{}}}
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
kratosIdentities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
req := httptest.NewRequest("GET", "/users?limit=50&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(2), res.Total)
require.Len(t, res.Items, 2)
require.Equal(t, "kratos-user-1", res.Items[0].ID)
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
var status domain.IdentityCacheStatus
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
require.Equal(t, "ready", status.Status)
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
require.Equal(t, int64(2), status.ObservedCount)
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: mockRedisRepo{data: map[string]string{
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
}}}
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
identities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Second), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(identities, nil).Once()
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
count, err := h.WarmIdentityMirror(context.Background())
require.NoError(t, err)
require.Equal(t, 2, count)
require.Empty(t, redis.data[identityMirrorKey("stale-user")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
var status domain.IdentityCacheStatus
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
require.Equal(t, "ready", status.Status)
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
require.Equal(t, int64(2), status.ObservedCount)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 6, 8, 6, 55, 0, 0, time.UTC)
legacyIdentity := service.KratosIdentity{
ID: "legacy-partial-user",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "legacy@example.com",
"name": "Legacy Partial",
},
}
rawLegacyIdentity, err := json.Marshal(legacyIdentity)
require.NoError(t, err)
legacyState := domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
ObservedCount: 1,
}
rawLegacyState, err := json.Marshal(legacyState)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
"identity:mirror:state": string(rawLegacyState),
}}}
kratosIdentities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
req := httptest.NewRequest("GET", "/users?limit=50&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(2), res.Total)
var status domain.IdentityCacheStatus
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 6, 8, 6, 50, 0, 0, time.UTC)
partialIdentity := service.KratosIdentity{
ID: "partial-user",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "partial@example.com",
"name": "Partial",
},
}
rawPartialIdentity, err := json.Marshal(partialIdentity)
require.NoError(t, err)
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
}}}
kratosIdentities := []service.KratosIdentity{
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
}
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
req := httptest.NewRequest("GET", "/users?limit=50&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(2), res.Total)
require.Empty(t, redis.data[identityMirrorKey("partial-user")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
h := &UserHandler{KratosAdmin: mockKratos}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]any{"email": "c@example.com", "name": "C"}},
{ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]any{"email": "b@example.com", "name": "B"}},
{ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]any{"email": "a@example.com", "name": "A"}},
}, nil).Maybe()
req := httptest.NewRequest("GET", "/users?limit=2", 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.Len(t, res.Items, 2)
require.NotEmpty(t, res.NextCursor)
require.Equal(t, int64(3), res.Total)
}
func TestUserHandler_GetUserUsesIdentityMirrorBeforeKratos(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 6, 12, 8, 20, 0, 0, time.UTC)
userID := "2b7fd276-b25f-45ef-b691-ea9d72e701e1"
identity := service.KratosIdentity{
ID: userID,
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "mirror-user@example.com",
"name": "Mirror User",
},
}
rawIdentity, err := json.Marshal(identity)
require.NoError(t, err)
redis := &mockRedisRepo{data: map[string]string{
identityMirrorKey(userID): string(rawIdentity),
}}
h := &UserHandler{
KratosAdmin: mockKratos,
IdentityCache: redis,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/:id", h.GetUser)
req := httptest.NewRequest(http.MethodGet, "/users/"+userID, nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got userSummary
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, userID, got.ID)
require.Equal(t, "mirror-user@example.com", got.Email)
require.Equal(t, "Mirror User", got.Name)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "FindIdentityIDByIdentifier", mock.Anything, mock.Anything)
}
func TestUserHandler_UpdateIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
redis := &mockRedisRepo{data: map[string]string{
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
}}
h := &UserHandler{IdentityCache: redis}
identity := service.KratosIdentity{
ID: "user-1",
Traits: map[string]any{
"email": "user1@example.com",
},
}
h.updateIdentityMirrorEntry(identity)
require.Empty(t, redis.data["identity:mirror:state"])
require.NotEmpty(t, redis.data[identityMirrorKey("user-1")])
}
func TestUserHandler_DeleteIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
redis := &mockRedisRepo{data: map[string]string{
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
identityMirrorKey("u-1"): `{"id":"u-1"}`,
}}
h := &UserHandler{IdentityCache: redis}
h.deleteIdentityMirrorEntry("u-1")
require.Empty(t, redis.data["identity:mirror:state"])
require.Empty(t, redis.data[identityMirrorKey("u-1")])
}
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
UserRepo: mockRepo,
}
app.Post("/users/bulk", h.BulkCreateUsers)
rootID := "hanmac-family-id"
companyID := "hanmac-id"
tenants := []domain.Tenant{
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족", ParentID: &rootID},
{ID: companyID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
{ID: "external-id", Slug: "external", Name: "외부사", ParentID: &rootID},
}
t.Run("domain only email receives suggested final email with next suffix", func(t *testing.T) {
mockTenant.On("GetTenantBySlug", mock.Anything, "hanmac").Return(&domain.Tenant{
ID: companyID,
Slug: "hanmac",
ParentID: &rootID,
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{
ID: companyID,
Slug: "hanmac",
ParentID: &rootID,
}, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID, "external-id"}).Return([]domain.User{
{Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
{Email: "cyhan1@samaneng.com", CompanyCode: "hanmac", TenantID: &companyID},
}, nil).Maybe()
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "external"}).Return([]domain.User{}, nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "@hanmaceng.co.kr",
"name": "한치영",
"tenantSlug": "hanmac",
},
},
}
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, http.StatusOK, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
row := results[0].(map[string]any)
assert.True(t, row["success"].(bool))
assert.Equal(t, "cyhan2@hanmaceng.co.kr", row["email"])
assert.Equal(t, "@hanmaceng.co.kr", row["originalEmail"])
assert.Contains(t, row["warnings"].([]any), "suggested")
})
t.Run("full email duplicate local part is blocking error", func(t *testing.T) {
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
UserRepo: mockRepo,
}
app := fiber.New()
app.Post("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleUser,
ManageableTenants: []domain.Tenant{
{ID: "h-company-id", Slug: "h-company"},
},
})
return h.BulkCreateUsers(c)
})
hRootID := "h-root-id"
hCompanyID := "h-company-id"
hTenants := []domain.Tenant{
{ID: hRootID, Slug: "hanmac-family", Name: "한맥가족", ParentID: nil},
{ID: hCompanyID, Slug: "h-company", Name: "한맥기술", ParentID: &hRootID},
}
mockTenant.On("GetTenantBySlug", mock.Anything, "h-company").Return(&hTenants[1], nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, hCompanyID).Return(&hTenants[1], nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(hTenants, int64(len(hTenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID)
})).Return([]domain.User{
{Email: "han@hanmaceng.co.kr", TenantID: &hCompanyID, CompanyCode: "h-company"},
}, nil).Maybe()
mockRepo.On("FindByCompanyCodes", mock.Anything, mock.MatchedBy(func(codes []string) bool {
return slices.Contains(codes, "h-company") || slices.Contains(codes, "hanmac-family")
})).Return([]domain.User{
{Email: "han@hanmaceng.co.kr", TenantID: &hCompanyID, CompanyCode: "h-company"},
}, nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
payload := map[string]any{
"users": []map[string]any{
{
"email": "han@samaneng.com",
"name": "한치영",
"tenantSlug": "h-company",
},
},
}
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, http.StatusOK, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results, _ := result["results"].([]any)
if assert.Len(t, results, 1) {
row := results[0].(map[string]any)
assert.False(t, row["success"].(bool))
assert.Equal(t, "blockingError", row["status"])
message, _ := row["message"].(string)
assert.Contains(t, message, "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
}
})
}
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)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
UserRepo: mockRepo,
}
app.Post("/users", h.CreateUser)
rootID := "hanmac-family-id"
companyID := "hanmac-id"
tenants := []domain.Tenant{
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족", ParentID: &rootID},
{ID: companyID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
}
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "hanmac").Return(&domain.Tenant{
ID: companyID,
Slug: "hanmac",
}, 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{
{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()
payload := map[string]any{
"email": "han@samaneng.com",
"name": "한치영",
"tenantSlug": "hanmac",
}
body, _ := json.Marshal(payload)
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")
}
func TestUserHandler_BulkUpdateUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockRepo := new(MockUserRepoForHandler)
worksmobile := &fakeUserHandlerWorksmobileSyncer{}
h := &UserHandler{KratosAdmin: mockKratos, UserRepo: mockRepo, Worksmobile: worksmobile}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.BulkUpdateUsers(c)
})
t.Run("Success - Update Role and Status", func(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", Traits: map[string]any{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: "u-1",
Traits: map[string]any{
"email": "u1@test.com",
"name": "Bulk User",
"tenant_id": "tenant-1",
},
State: "inactive",
}, nil).Maybe()
status := "inactive"
payload := map[string]any{
"userIds": []string{"u-1"},
"status": &status,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.True(t, results[0].(map[string]any)["success"].(bool))
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) {
for _, role := range []string{"tenant_admin", "rp_admin"} {
payload := map[string]any{
"userIds": []string{"u-1"},
"role": role,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
}
})
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
app := fiber.New()
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: "tenant_admin"})
return h.BulkUpdateUsers(c)
})
role := domain.RoleSuperAdmin
payload := map[string]any{
"userIds": []string{"u-1"},
"role": &role,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, fiber.StatusForbidden, resp.StatusCode)
})
}
func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
app := fiber.New()
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})
return h.BulkUpdateUsers(c)
})
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "u1@test.com",
"name": "Bulk User",
"tenant_id": "primary-tenant-id",
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "team-a").Return(&domain.Tenant{
ID: "team-a-id",
Name: "Team A",
Slug: "team-a",
}, nil).Once()
mockKratos.On(
"UpdateIdentity",
mock.Anything,
"u-1",
mock.MatchedBy(func(traits map[string]any) bool {
if extractTraitString(traits, "tenant_id") != "primary-tenant-id" {
return false
}
appointments, ok := traits["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
return ok &&
appointment["tenantId"] == "team-a-id" &&
appointment["tenantSlug"] == "team-a" &&
appointment["tenantName"] == "Team A"
}),
mock.Anything,
).Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "u1@test.com",
"name": "Bulk User",
"tenant_id": "primary-tenant-id",
"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" &&
entry.Relation == "members" &&
entry.Subject == "User:u-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil).Once()
body := `{"userIds":["u-1"],"tenantSlug":"team-a","isAddTenant":true}`
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)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
h := &UserHandler{KratosAdmin: mockKratos}
app.Delete("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.BulkDeleteUsers(c)
})
t.Run("Success - Delete multiple", func(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ID: "u-1"}, nil).Maybe()
mockKratos.On("GetIdentity", mock.Anything, "u-2").Return(&service.KratosIdentity{ID: "u-2"}, nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-2").Return(nil).Maybe()
payload := map[string]any{
"userIds": []string{"u-1", "u-2"},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("DELETE", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
})
}
func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: userRepo,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
{Namespace: "RelyingParty", Object: "client-2", Relation: "audit_viewer", SubjectID: "User:u-1"},
}, nil).Maybe()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Maybe()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-2", "audit_viewer", "User:u-1").Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-2" && entry.Relation == "audit_viewer" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
// [FIX] Diagnostic call for fixed UUID mapping
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Maybe()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_BulkDeleteUsers_CleansUpRelyingPartyRelations(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
}
app.Delete("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.BulkDeleteUsers(c)
})
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ID: "u-1"}, nil).Maybe()
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
}, nil).Maybe()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Maybe()
payload := map[string]any{
"userIds": []string{"u-1"},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodDelete, "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: userRepo,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{}, nil).Times(3)
mockOutbox.On("ListCurrentBySubject", mock.Anything, "RelyingParty", "User:u-1").Return([]domain.KetoOutbox{
{
Namespace: "RelyingParty",
Object: "client-1",
Relation: "admins",
Subject: "User:u-1",
Action: domain.KetoOutboxActionCreate,
},
{
Namespace: "RelyingParty",
Object: "client-2",
Relation: "config_editor",
Subject: "User:u-1",
Action: domain.KetoOutboxActionCreate,
},
}, nil).Maybe()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Maybe()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-2", "config_editor", "User:u-1").Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-2" && entry.Relation == "config_editor" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
// [FIX] Diagnostic call for fixed UUID mapping
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Maybe()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_DeleteUserRecordsCascadeRelyingPartyCleanupAudit(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
auditRepo := &mockAuditRepo{}
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: userRepo,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
AuditRepo: auditRepo,
}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
}, nil).Maybe()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
// [FIX] Diagnostic call for fixed UUID mapping
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "u-1").Return("", nil).Maybe()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Maybe()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode)
require.Len(t, auditRepo.logs, 1)
log := auditRepo.logs[0]
assert.Equal(t, "admin-1", log.UserID)
assert.Equal(t, "DELETE /api/v1/dev/clients/client-1/relations/admins", log.EventType)
details := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(log.Details), &details))
assert.Equal(t, "REMOVE_RELATION", details["action"])
assert.Equal(t, "client-1", details["target_id"])
assert.Equal(t, "user_delete", details["source"])
assert.Equal(t, "u-1", details["deleted_user_id"])
assert.Equal(t, "User:u-1", details["relation_subject"])
before, ok := details["before"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "admins", before["relation"])
assert.Equal(t, "User:u-1", before["subject"])
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
// Mock requester as regular user with access to the tenant
tenantID := "t-123"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "requester-1",
Role: domain.RoleUser,
TenantID: &tenantID,
ManageableTenants: []domain.Tenant{
{ID: tenantID, Slug: "test-tenant"},
},
})
return h.UpdateUser(c)
})
t.Run("Fail - Regular user updating admin_only field", func(t *testing.T) {
tenantID := "t-123"
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID},
}, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "salary", "adminOnly": true},
},
},
}, nil)
payload := map[string]any{
"metadata": map[string]any{"salary": 5000},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 400, resp.StatusCode) // validation failed
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result["error"].(string), "field salary is admin only")
})
}
func TestUserHandler_UpdateUser_GlobalCustomClaimWritePermission(t *testing.T) {
newApp := func(t *testing.T, existingPermission string, updateIdentity bool) (*fiber.App, *MockKratosAdmin, *MockTenantServiceForUser, *map[string]any) {
t.Helper()
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
capturedTraits := map[string]any(nil)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
tenantID := "t-123"
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "requester-1",
Role: domain.RoleUser,
TenantID: &tenantID,
ManageableTenants: []domain.Tenant{
{ID: tenantID, Slug: "test-tenant"},
},
})
return h.UpdateUser(c)
})
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Custom Claim User",
"tenant_id": tenantID,
"global_custom_claims": map[string]any{
"contract_date": "2026-06-09",
},
"global_custom_claim_types": map[string]any{
"contract_date": "date",
},
"global_custom_claim_permissions": map[string]any{
"contract_date": map[string]any{
"readPermission": "user_and_admin",
"writePermission": existingPermission,
},
},
},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{},
},
}, nil).Maybe()
if updateIdentity {
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
capturedTraits = args.Get(2).(map[string]any)
}).Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Custom Claim User",
},
}, nil).Once()
} else {
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{},
}, nil).Maybe()
}
return app, mockKratos, mockTenant, &capturedTraits
}
requestBody := func(nextValue string) *bytes.Reader {
body, _ := json.Marshal(map[string]any{
"metadata": map[string]any{
"global_custom_claims": map[string]any{
"contract_date": nextValue,
},
"global_custom_claim_types": map[string]any{
"contract_date": "date",
},
"global_custom_claim_permissions": map[string]any{
"contract_date": map[string]any{
"readPermission": "user_and_admin",
"writePermission": "user_and_admin",
},
},
},
})
return bytes.NewReader(body)
}
t.Run("regular user cannot change admin_only global custom claim value", func(t *testing.T) {
app, mockKratos, mockTenant, _ := newApp(t, "admin_only", false)
req := httptest.NewRequest(http.MethodPut, "/users/u-1", requestBody("2026-07-01"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
})
t.Run("regular user can change user_and_admin global custom claim value", func(t *testing.T) {
app, mockKratos, mockTenant, capturedTraits := newApp(t, "user_and_admin", true)
req := httptest.NewRequest(http.MethodPut, "/users/u-1", requestBody("2026-07-01"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, *capturedTraits)
claims := (*capturedTraits)["global_custom_claims"].(map[string]any)
require.Equal(t, "2026-07-01", claims["contract_date"])
permissions := (*capturedTraits)["global_custom_claim_permissions"].(map[string]any)
require.Equal(t, map[string]any{
"readPermission": "user_and_admin",
"writePermission": "user_and_admin",
}, permissions["contract_date"])
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
})
}
func TestUserHandler_UpdateUser_AcceptsDeprecatedAdminRolesAsUser(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
h := &UserHandler{KratosAdmin: mockKratos}
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)
})
for _, role := range []string{"tenant_admin", "rp_admin"} {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
Traits: map[string]any{"email": "user@test.com", "role": domain.RoleUser},
State: "active",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: "u-1", Traits: map[string]any{"email": "user@test.com", "role": domain.RoleUser},
}, nil).Maybe()
payload := map[string]any{"role": role}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
h := &UserHandler{KratosAdmin: mockKratos}
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)
})
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "old@example.com",
"name": "사용자",
"role": domain.RoleUser,
},
State: "active",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "new@example.com",
"name": "사용자",
"role": domain.RoleUser,
},
State: "active",
}, nil).Maybe()
body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
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.StatusOK, resp.StatusCode)
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)
h := &UserHandler{KratosAdmin: mockKratos}
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)
})
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user@example.com",
"name": "사용자",
"role": domain.RoleUser,
"sub_email": []interface{}{"alias@hanmaceng.co.kr"},
"aliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
"secondary_emails": []interface{}{"alias@hanmaceng.co.kr"},
"worksmobileAliasEmails": []interface{}{"alias@hanmaceng.co.kr"},
},
State: "active",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user@example.com",
"name": "사용자",
"role": domain.RoleUser,
"sub_email": []interface{}{},
"aliasEmails": []interface{}{},
"secondary_emails": []interface{}{},
"worksmobileAliasEmails": []interface{}{},
},
State: "active",
}, nil).Maybe()
body, _ := json.Marshal(map[string]interface{}{
"metadata": map[string]interface{}{
"sub_email": []interface{}{},
},
})
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.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_RejectsNonSuperAdminEmailChange(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
h := &UserHandler{KratosAdmin: mockKratos}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-2", Role: domain.RoleUser})
return h.UpdateUser(c)
})
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "old@example.com",
"name": "사용자",
"role": domain.RoleUser,
},
State: "active",
}, nil).Maybe()
body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"})
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.StatusForbidden, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
tenantID := "tenant-uuid"
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "isLoginId": true},
},
},
}, nil).Maybe()
traits := map[string]any{
"tenant_id": tenantID,
}
metadata := map[string]any{
tenantID: map[string]any{
"emp_no": "E1001",
},
"worksmobileAliasEmails": map[string]any{
"0": "alias@hanmaceng.co.kr",
},
}
records := syncCustomLoginIDs(context.Background(), mockTenant, traits, metadata, "user-1")
require.Len(t, records, 1)
require.Equal(t, tenantID, records[0].TenantID)
require.Equal(t, "E1001", records[0].LoginID)
mockTenant.AssertNotCalled(t, "GetTenant", mock.Anything, "worksmobileAliasEmails")
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
tenantID := "t-123"
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"email": "user@test.com",
"companyCode": "test-tenant",
"tenant_id": tenantID,
},
}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
},
},
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Maybe()
// Expect traits to include 'custom_login_ids' synced from 'emp_no'
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"custom_login_ids": []any{"E1001"},
"email": "user@test.com",
},
}, nil).Maybe()
payload := map[string]any{
"metadata": map[string]any{
tenantID: map[string]any{
"emp_no": "E1001",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockKratos.AssertExpectations(t)
})
t.Run("Success - Sync LoginID from existing traits when not in metadata", func(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
tenantID := "t-123"
userID := "u-2"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"email": "user2@test.com",
"companyCode": "test-tenant",
"tenant_id": tenantID,
"id": "old-id",
tenantID: map[string]any{
"emp_no": "E2002",
},
},
}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "isLoginId": true},
},
},
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Maybe()
// Even if metadata is empty, it should sync from existing traits
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"custom_login_ids": []any{"E2002"},
},
}, nil).Maybe()
payload := map[string]any{
"name": "New Name",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockKratos.AssertExpectations(t)
})
}
func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"custom_login_ids": []any{"dyddus1210"},
"email": "dyddus1210@gmail.com",
"companyCode": "test-tenant",
"tenant_id": "t-1",
"emp_id": "dyddus1210",
},
}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, "t-1").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_id", "isLoginId": true},
},
},
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"custom_login_ids": []any{"dyddus1210"},
"email": "dyddus1210@gmail.com",
},
}, nil).Maybe()
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Maybe()
payload := map[string]any{
"password": "asdfzxcv1234!",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockOry.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "UpdateIdentityPassword", mock.Anything, mock.Anything, mock.Anything)
}
func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
userID := "u-2"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"email": "dyddus1210@gmail.com",
"companyCode": "test-tenant",
},
}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]any{
"email": "dyddus1210@gmail.com",
},
}, nil).Maybe()
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Maybe()
payload := map[string]any{
"password": "asdfzxcv1234!",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockOry.AssertExpectations(t)
}
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(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)
tenantID := "t-123"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{
map[string]any{"key": "emp_no", "label": "Employee No", "isLoginId": true},
},
},
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// Expect OryProvider.CreateUser to be called with attributes["custom_login_ids"] synced from metadata
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
// Mock GetIdentity after creation
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{
ID: "some-id",
Traits: map[string]any{
"custom_login_ids": []any{"E1001"},
"email": "new@test.com",
"companyCode": "test-tenant",
},
}, nil).Maybe()
// Mock ListManageableTenants for mapIdentitySummary
mockTenant.On("ListManageableTenants", mock.Anything, "some-id").Return([]domain.Tenant{}, nil).Maybe()
payload := map[string]any{
"email": "new@test.com",
"name": "New User",
"tenantSlug": "test-tenant",
"metadata": map[string]any{
tenantID: map[string]any{
"emp_no": "E1001",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 201, resp.StatusCode)
mockOry.AssertExpectations(t)
})
}
func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(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", h.CreateUser)
tenantID := "33333333-3333-3333-3333-333333333333"
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "saman",
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "saman").Return(&domain.Tenant{
ID: tenantID,
Slug: "saman",
}, nil)
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{
ID: "some-id",
Traits: map[string]any{
"email": "new@samaneng.com",
"name": "Appointment User",
"companyCode": "saman",
"tenant_id": tenantID,
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID, "tenantSlug": "saman"},
},
},
State: "active",
}, nil).Maybe()
payload := map[string]any{
"email": "new@samaneng.com",
"name": "Appointment User",
"additionalAppointments": []map[string]any{
{"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"},
},
"metadata": map[string]any{
"userType": "hanmac",
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 201, resp.StatusCode)
assert.Empty(t, worksmobile.upserts)
mockOry.AssertExpectations(t)
}
func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(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 := "01970f0d-9666-7548-963d-2890351f03dd"
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockTenant.On(
"RegisterTenant",
mock.Anything,
"Personal - personal-user@example.com",
mock.MatchedBy(func(slug string) bool { return strings.HasPrefix(slug, "personal-") }),
domain.TenantTypePersonal,
"Automatically provisioned personal tenant",
[]string(nil),
(*string)(nil),
"",
).Return(&domain.Tenant{
ID: personalTenantID,
Slug: "personal-01970f0d96667548963d2890351f03dd",
Name: "Personal - personal-user@example.com",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, personalTenantID).Return(&domain.Tenant{
ID: personalTenantID,
Slug: "personal-01970f0d96667548963d2890351f03dd",
Name: "Personal - personal-user@example.com",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Twice()
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-01970f0d96667548963d2890351f03dd").Return(&domain.Tenant{
ID: personalTenantID,
Slug: "personal-01970f0d96667548963d2890351f03dd",
Name: "Personal - personal-user@example.com",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Maybe()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{
ID: "some-id",
Traits: map[string]any{
"email": "personal-user@example.com",
"name": "Personal User",
"companyCode": "personal-01970f0d96667548963d2890351f03dd",
"tenant_id": personalTenantID,
},
State: "active",
}, nil).Maybe()
payload := map[string]any{
"email": "personal-user@example.com",
"name": "Personal User",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(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)
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()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "tenant-id").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Twice()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{
ID: "some-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "tenant-id",
"role": domain.RoleUser,
},
}, nil).Maybe()
body := `{"email":"user@test.com","password":"Password1!","name":"Test User","tenantSlug":"test-tenant"}`
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.StatusCreated, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
mockKratos.AssertExpectations(t)
_, legacyErr := tenantSlugFromRequest("", "test-tenant")
require.Error(t, legacyErr)
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
}
func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
identity := &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,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil).Maybe()
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.Anything, 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).Maybe()
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)
mockTenant.AssertExpectations(t)
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)
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)
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": "primary-tenant-id",
"role": domain.RoleUser,
"additionalAppointments": []any{
map[string]any{
"tenantId": "primary-tenant-id",
"tenantSlug": "primary-tenant",
"tenantName": "대표 조직",
"isPrimary": true,
},
},
},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "private-team").Return(&domain.Tenant{
ID: "private-team-id",
Name: "비공개 팀",
Slug: "private-team",
Config: domain.JSONMap{
"visibility": "private",
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, "private-team-id").Return(&domain.Tenant{
ID: "private-team-id",
Name: "비공개 팀",
Slug: "private-team",
Config: domain.JSONMap{
"visibility": "private",
},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "primary-tenant-id").Return(&domain.Tenant{
ID: "primary-tenant-id",
Name: "대표 조직",
Slug: "primary-tenant",
}, nil).Maybe()
var capturedTraits map[string]any
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
capturedTraits = args.Get(2).(map[string]any)
}).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "primary-tenant-id",
"role": domain.RoleUser,
"additionalAppointments": []any{
map[string]any{
"tenantId": "primary-tenant-id",
"tenantSlug": "primary-tenant",
"tenantName": "대표 조직",
"isPrimary": true,
},
map[string]any{
"tenantId": "private-team-id",
"tenantSlug": "private-team",
"tenantName": "비공개 팀",
"isPrimary": false,
},
},
},
}, nil)
body := `{"tenantSlug":"private-team","isAddTenant":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)
require.Equal(t, "primary-tenant-id", capturedTraits["tenant_id"])
appointments, ok := capturedTraits["additionalAppointments"].([]any)
require.True(t, ok)
require.Len(t, appointments, 2)
added := appointments[1].(map[string]any)
require.Equal(t, "private-team-id", added["tenantId"])
require.Equal(t, "private-team", added["tenantSlug"])
require.Equal(t, "비공개 팀", added["tenantName"])
require.Equal(t, false, added["isPrimary"])
}
func TestUserHandler_UpdateUserRemoveTenantDropsAdditionalAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
KetoOutboxRepo: mockOutbox,
}
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)
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": "primary-tenant-id",
"role": domain.RoleUser,
"additionalAppointments": []any{
map[string]any{
"tenantId": "primary-tenant-id",
"tenantSlug": "primary-tenant",
"tenantName": "대표 조직",
"isPrimary": true,
},
map[string]any{
"tenantId": "private-team-id",
"tenantSlug": "private-team",
"tenantName": "비공개 팀",
"isPrimary": false,
},
},
},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "private-team").Return(&domain.Tenant{
ID: "private-team-id",
Name: "비공개 팀",
Slug: "private-team",
Config: domain.JSONMap{
"visibility": "private",
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, "private-team-id").Return(&domain.Tenant{
ID: "private-team-id",
Name: "비공개 팀",
Slug: "private-team",
Config: domain.JSONMap{
"visibility": "private",
},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "primary-tenant-id").Return(&domain.Tenant{
ID: "primary-tenant-id",
Name: "대표 조직",
Slug: "primary-tenant",
}, nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "private-team-id" &&
entry.Relation == "members" &&
entry.Subject == "User:user-id" &&
entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
var capturedTraits map[string]any
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
capturedTraits = args.Get(2).(map[string]any)
}).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "primary-tenant-id",
"role": domain.RoleUser,
"additionalAppointments": []any{
map[string]any{
"tenantId": "primary-tenant-id",
"tenantSlug": "primary-tenant",
"tenantName": "대표 조직",
"isPrimary": true,
},
},
},
}, nil)
body := `{"tenantSlug":"private-team","isRemoveTenant":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)
require.Equal(t, "primary-tenant-id", capturedTraits["tenant_id"])
appointments, ok := capturedTraits["additionalAppointments"].([]any)
require.True(t, ok)
require.Len(t, appointments, 1)
remaining := appointments[0].(map[string]any)
require.Equal(t, "primary-tenant-id", remaining["tenantId"])
require.Equal(t, "primary-tenant", remaining["tenantSlug"])
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_UpdateUserAddTenantRejectsUnmanageableTenantForTenantAdmin(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
allowedTenantID := "allowed-tenant-id"
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "tenant-admin-id",
Role: "tenant_admin",
ManageableTenants: []domain.Tenant{
{ID: allowedTenantID, Slug: "allowed-team"},
},
})
return c.Next()
})
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": allowedTenantID,
"role": domain.RoleUser,
},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "outside-team").Return(&domain.Tenant{
ID: "outside-tenant-id",
Name: "관리 외부 팀",
Slug: "outside-team",
}, nil).Once()
body := `{"tenantSlug":"outside-team","isAddTenant":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.StatusForbidden, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(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@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, 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).Maybe()
body := `{"userIds":["user-id"],"tenantSlug":"new-tenant"}`
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)
mockTenant.AssertExpectations(t)
mockKratos.AssertExpectations(t)
legacyTenantSlug := "legacy-tenant"
_, legacyErr := tenantSlugPointerFromRequest(nil, &legacyTenantSlug)
require.Error(t, legacyErr)
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_MapToLocalUserPreservesTenantBoundGradeForCompatibility(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{
ID: "user-grade-id",
State: "active",
Traits: map[string]any{
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"tenant_id": "tenant-1",
"additionalAppointments": []any{
map[string]any{
"tenantId": "tenant-1",
"grade": "수석",
},
},
},
}
localUser := handler.mapToLocalUser(identity)
assert.Equal(t, domain.RoleUser, localUser.Role)
assert.Equal(t, "수석", localUser.Grade)
assert.Equal(t, "팀장", localUser.Position)
assert.NotContains(t, localUser.Metadata, "grade")
assert.Contains(t, localUser.Metadata, "additionalAppointments")
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
return "", nil
}
func (m *MockTenantServiceForUser) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}