forked from baron/baron-sso
feat: update worksmobile sync and restore planning
This commit is contained in:
@@ -13,8 +13,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -850,6 +852,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
type bulkUserItem struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Name string `json:"name"`
|
||||
@@ -906,6 +909,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
var hanmacScope *hanmacEmailScope
|
||||
var hanmacLocalParts map[string]bool
|
||||
hanmacScopeLoaded := false
|
||||
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
|
||||
|
||||
// Pre-fetch tenant data to avoid redundant DB calls
|
||||
type tenantCacheItem struct {
|
||||
@@ -1011,7 +1015,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
return cacheTenantItem(buildTenantCacheItem(tenant)), nil
|
||||
}
|
||||
|
||||
for _, item := range req.Users {
|
||||
for index, item := range req.Users {
|
||||
email := strings.TrimSpace(item.Email)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
tenantID := strings.TrimSpace(item.TenantID)
|
||||
@@ -1026,6 +1030,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
||||
continue
|
||||
}
|
||||
if message, exists := bulkEmailErrors[index]; exists {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: message})
|
||||
continue
|
||||
}
|
||||
|
||||
var tItem tenantCacheItem
|
||||
var err error
|
||||
@@ -1192,6 +1200,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
item.Metadata["additionalAppointments"] = resolvedAppointments
|
||||
}
|
||||
normalizeBulkUserAliasMetadata(item.Metadata)
|
||||
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
||||
|
||||
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
||||
@@ -1252,6 +1261,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||
ID: strings.TrimSpace(item.UserID),
|
||||
Email: userEmail,
|
||||
Name: item.Name,
|
||||
PhoneNumber: userPhone,
|
||||
@@ -1845,6 +1855,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email *string `json:"email"`
|
||||
LoginID *string `json:"loginId"`
|
||||
Password *string `json:"password"`
|
||||
Name *string `json:"name"`
|
||||
@@ -1948,6 +1959,31 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if traits == nil {
|
||||
traits = map[string]interface{}{}
|
||||
}
|
||||
if req.Email != nil {
|
||||
currentEmail := strings.TrimSpace(extractTraitString(traits, "email"))
|
||||
nextEmail := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
if nextEmail == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "email is required")
|
||||
}
|
||||
parsed, parseErr := mail.ParseAddress(nextEmail)
|
||||
if parseErr != nil || !strings.EqualFold(parsed.Address, nextEmail) {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid email")
|
||||
}
|
||||
if !strings.EqualFold(currentEmail, nextEmail) {
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
|
||||
}
|
||||
if h.UserRepo != nil {
|
||||
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
|
||||
return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
|
||||
}
|
||||
if taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), nextEmail); err == nil && taken {
|
||||
return errorJSON(c, fiber.StatusConflict, "email is already used as a login ID")
|
||||
}
|
||||
}
|
||||
traits["email"] = nextEmail
|
||||
}
|
||||
}
|
||||
delete(traits, "hanmacFamily")
|
||||
delete(traits, "userType")
|
||||
|
||||
@@ -2048,6 +2084,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if subEmailRaw, exists := req.Metadata["sub_email"]; exists {
|
||||
subEmails := normalizeUserSubEmailValues(subEmailRaw)
|
||||
traits["sub_email"] = subEmails
|
||||
traits["aliasEmails"] = subEmails
|
||||
traits["secondary_emails"] = subEmails
|
||||
traits["worksmobileAliasEmails"] = subEmails
|
||||
}
|
||||
|
||||
// [LoginID Sync based on Tenant Settings]
|
||||
// Perform sync AFTER metadata merge to ensure traits contains current values
|
||||
@@ -2860,6 +2903,156 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUserSubEmailValues(raw any) []interface{} {
|
||||
values := make([]string, 0)
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
values = append(values, fmt.Sprint(item))
|
||||
}
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
default:
|
||||
if raw != nil {
|
||||
values = append(values, fmt.Sprint(raw))
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
result := make([]interface{}, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
normalized := strings.ToLower(strings.TrimSpace(part))
|
||||
if normalized == "" || seen[normalized] {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = true
|
||||
result = append(result, normalized)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateBulkUserEmailUniqueness(users []bulkUserItem) map[int]string {
|
||||
owners := map[string]map[int]bool{}
|
||||
errorsByIndex := map[int]string{}
|
||||
|
||||
for index, user := range users {
|
||||
primaryEmail := normalizeBulkUserEmail(user.Email)
|
||||
aliases := bulkUserAliasEmailSet(user.Metadata)
|
||||
rowEmails := map[string]bool{}
|
||||
if primaryEmail != "" {
|
||||
rowEmails[primaryEmail] = true
|
||||
}
|
||||
for alias := range aliases {
|
||||
if primaryEmail != "" && alias == primaryEmail {
|
||||
errorsByIndex[index] = "duplicate email in bulk request: " + alias
|
||||
}
|
||||
rowEmails[alias] = true
|
||||
}
|
||||
for email := range rowEmails {
|
||||
if owners[email] == nil {
|
||||
owners[email] = map[int]bool{}
|
||||
}
|
||||
owners[email][index] = true
|
||||
}
|
||||
}
|
||||
|
||||
for email, indexes := range owners {
|
||||
if len(indexes) < 2 {
|
||||
continue
|
||||
}
|
||||
for index := range indexes {
|
||||
errorsByIndex[index] = "duplicate email in bulk request: " + email
|
||||
}
|
||||
}
|
||||
|
||||
return errorsByIndex
|
||||
}
|
||||
|
||||
func normalizeBulkUserAliasMetadata(metadata map[string]any) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
aliases := bulkUserAliasEmailSet(metadata)
|
||||
hasAliasField := false
|
||||
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||
if _, exists := metadata[key]; exists {
|
||||
hasAliasField = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAliasField {
|
||||
return
|
||||
}
|
||||
values := make([]interface{}, 0, len(aliases))
|
||||
for alias := range aliases {
|
||||
values = append(values, alias)
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
return fmt.Sprint(values[i]) < fmt.Sprint(values[j])
|
||||
})
|
||||
metadata["sub_email"] = values
|
||||
metadata["aliasEmails"] = values
|
||||
metadata["secondary_emails"] = values
|
||||
metadata["worksmobileAliasEmails"] = values
|
||||
}
|
||||
|
||||
func bulkUserAliasEmailSet(metadata map[string]any) map[string]bool {
|
||||
aliases := map[string]bool{}
|
||||
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||
for _, value := range bulkUserEmailValues(metadata[key]) {
|
||||
aliases[value] = true
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func bulkUserEmailValues(raw any) []string {
|
||||
values := make([]string, 0)
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
values = append(values, fmt.Sprint(item))
|
||||
}
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
default:
|
||||
if raw != nil {
|
||||
values = append(values, fmt.Sprint(raw))
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
|
||||
}) {
|
||||
email := normalizeBulkUserEmail(token)
|
||||
if email != "" {
|
||||
result = append(result, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeBulkUserEmail(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := mail.ParseAddress(normalized)
|
||||
if err != nil {
|
||||
return normalized
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(parsed.Address))
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
|
||||
@@ -600,6 +600,171 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsersPreservesRequestedUserID(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
const requestedUserID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
|
||||
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("GetTenant", mock.Anything, "tenant-123").Return(&domain.Tenant{
|
||||
ID: "tenant-123",
|
||||
Slug: "restore-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user != nil && user.ID == requestedUserID && user.Email == "restore@test.com"
|
||||
}), mock.Anything).Return(requestedUserID, nil).Once()
|
||||
|
||||
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.True(t, row["success"].(bool))
|
||||
assert.Equal(t, requestedUserID, row["userId"])
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
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).Once()
|
||||
|
||||
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).Once()
|
||||
|
||||
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)
|
||||
@@ -1429,6 +1594,138 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
||||
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).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["email"] == "new@example.com"
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "new@example.com",
|
||||
"name": "사용자",
|
||||
"role": domain.RoleUser,
|
||||
},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
|
||||
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_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).Once()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
|
||||
values, ok := traits[key].([]interface{})
|
||||
if !ok || len(values) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}), "").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).Once()
|
||||
|
||||
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).Once()
|
||||
|
||||
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"
|
||||
|
||||
@@ -72,13 +72,30 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error {
|
||||
|
||||
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
|
||||
}
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
|
||||
userID := strings.TrimSpace(c.Params("userId"))
|
||||
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
job, err := h.Service.EnqueueUserPasswordReset(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "reset_user_password", "user_id", userID)
|
||||
}
|
||||
return c.Status(fiber.StatusAccepted).JSON(job)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
||||
jobID := strings.TrimSpace(c.Params("jobId"))
|
||||
job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID)
|
||||
@@ -89,18 +106,18 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "download_initial_passwords")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil {
|
||||
if err := writer.Write([]string{"email", "name", "primaryLeafOrgName", "initialPassword", "status", "lastError"}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, credential := range credentials {
|
||||
if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
|
||||
if err := writer.Write([]string{credential.Email, credential.Name, credential.PrimaryLeafOrgName, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -114,6 +131,42 @@ func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
|
||||
return c.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) ListCredentialBatches(c *fiber.Ctx) error {
|
||||
batches, err := h.Service.ListCredentialBatches(c.Context(), strings.TrimSpace(c.Params("tenantId")))
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "list_credential_batches")
|
||||
}
|
||||
return c.JSON(batches)
|
||||
}
|
||||
|
||||
func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error {
|
||||
batchID := strings.TrimSpace(c.Params("batchId"))
|
||||
batch, err := h.Service.DeleteCredentialBatchPasswords(c.Context(), strings.TrimSpace(c.Params("tenantId")), batchID)
|
||||
if err != nil {
|
||||
return worksmobileGuardError(c, err, "delete_credential_batch_passwords", "batch_id", batchID)
|
||||
}
|
||||
return c.JSON(batch)
|
||||
}
|
||||
|
||||
type worksmobileCredentialBatchRequest struct {
|
||||
CredentialBatchID string `json:"credentialBatchId"`
|
||||
}
|
||||
|
||||
func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) {
|
||||
batchID := strings.TrimSpace(c.Query("credentialBatchId"))
|
||||
if len(bytes.TrimSpace(c.Body())) == 0 {
|
||||
return batchID, nil
|
||||
}
|
||||
var req worksmobileCredentialBatchRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" {
|
||||
return bodyBatchID, nil
|
||||
}
|
||||
return batchID, nil
|
||||
}
|
||||
|
||||
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
|
||||
return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -51,7 +52,13 @@ func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) {
|
||||
func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
||||
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||
credentials: []service.WorksmobileInitialPasswordCredential{
|
||||
{Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"},
|
||||
{
|
||||
Email: "user@hanmaceng.co.kr",
|
||||
Name: "홍길동",
|
||||
PrimaryLeafOrgName: "인재성장",
|
||||
InitialPassword: "Aa1!Aa1!Aa1!Aa1!",
|
||||
Status: "processed",
|
||||
},
|
||||
},
|
||||
})
|
||||
app := fiber.New()
|
||||
@@ -63,8 +70,87 @@ func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
|
||||
require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv")
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), "email,initialPassword,status,lastError")
|
||||
require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,")
|
||||
require.Contains(t, string(body), "email,name,primaryLeafOrgName,initialPassword,status,lastError")
|
||||
require.Contains(t, string(body), "user@hanmaceng.co.kr,홍길동,인재성장,Aa1!Aa1!Aa1!Aa1!,processed,")
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesInitialPasswordBatchID(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{
|
||||
credentials: []service.WorksmobileInitialPasswordCredential{
|
||||
{Email: "batch-user@hanmaceng.co.kr", InitialPassword: "BatchPass1!", Status: "pending"},
|
||||
},
|
||||
}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv?batchId=batch-1", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.downloadCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", h.ResetUserPassword)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/password/reset", strings.NewReader(`{"credentialBatchId":"batch-1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusAccepted, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.resetPasswordCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerReturnsCredentialBatchHistory(t *testing.T) {
|
||||
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||
credentialBatches: []service.WorksmobileCredentialBatch{
|
||||
{BatchID: "batch-1", UserCount: 2, HasPasswords: true},
|
||||
},
|
||||
})
|
||||
app := fiber.New()
|
||||
app.Get("/tenants/:tenantId/worksmobile/credential-batches", h.ListCredentialBatches)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/credential-batches", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), `"batchId":"batch-1"`)
|
||||
require.Contains(t, string(body), `"userCount":2`)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
|
||||
fakeService := &fakeWorksmobileAdminService{}
|
||||
h := NewWorksmobileHandler(fakeService)
|
||||
app := fiber.New()
|
||||
app.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", h.DeleteCredentialBatchPasswords)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/credential-batches/batch-1/passwords", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||
@@ -91,9 +177,14 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeWorksmobileAdminService struct {
|
||||
overview service.WorksmobileTenantOverview
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
syncUserErr error
|
||||
overview service.WorksmobileTenantOverview
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
syncUserErr error
|
||||
syncUserCredentialBatchID string
|
||||
resetPasswordCredentialBatchID string
|
||||
downloadCredentialBatchID string
|
||||
deletedCredentialBatchID string
|
||||
credentialBatches []service.WorksmobileCredentialBatch
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) {
|
||||
@@ -116,17 +207,33 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context,
|
||||
return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
f.syncUserCredentialBatchID = credentialBatchID
|
||||
if f.syncUserErr != nil {
|
||||
return nil, f.syncUserErr
|
||||
}
|
||||
return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
f.resetPasswordCredentialBatchID = credentialBatchID
|
||||
return &domain.WorksmobileOutbox{ID: "job-user-password-reset", ResourceID: userID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
||||
return &domain.WorksmobileOutbox{ID: jobID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) {
|
||||
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]service.WorksmobileInitialPasswordCredential, error) {
|
||||
f.downloadCredentialBatchID = credentialBatchID
|
||||
return f.credentials, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) ListCredentialBatches(ctx context.Context, tenantID string) ([]service.WorksmobileCredentialBatch, error) {
|
||||
return f.credentialBatches, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (service.WorksmobileCredentialBatch, error) {
|
||||
f.deletedCredentialBatchID = credentialBatchID
|
||||
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user