forked from baron/baron-sso
feat: update worksmobile sync and restore planning
This commit is contained in:
@@ -750,11 +750,14 @@ func main() {
|
||||
|
||||
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
||||
admin.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteCredentialBatchPasswords)
|
||||
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
|
||||
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
|
||||
@@ -20,10 +20,11 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileActionUpsert = "UPSERT"
|
||||
WorksmobileActionDelete = "DELETE"
|
||||
WorksmobileActionDryRun = "DRY_RUN"
|
||||
WorksmobileActionSuspend = "SUSPEND"
|
||||
WorksmobileActionUpsert = "UPSERT"
|
||||
WorksmobileActionDelete = "DELETE"
|
||||
WorksmobileActionDryRun = "DRY_RUN"
|
||||
WorksmobileActionSuspend = "SUSPEND"
|
||||
WorksmobileActionPasswordReset = "PASSWORD_RESET"
|
||||
)
|
||||
|
||||
type WorksmobileOutbox struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
type WorksmobileOutboxRepository interface {
|
||||
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
|
||||
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
|
||||
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
|
||||
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
|
||||
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
|
||||
MarkRetry(ctx context.Context, id string) error
|
||||
@@ -56,6 +58,24 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")
|
||||
if credentialBatchID != "" {
|
||||
query = query.Where("payload ->> 'credentialBatchId' = ?", credentialBatchID)
|
||||
}
|
||||
var rows []domain.WorksmobileOutbox
|
||||
err := query.Order("created_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
|
||||
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"payload": payload,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
|
||||
@@ -290,6 +290,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
},
|
||||
"state": "active",
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||
payload["id"] = requestedID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
@@ -316,6 +319,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||
return "", fmt.Errorf("kratos admin: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||
}
|
||||
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
},
|
||||
},
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||
payload["id"] = requestedID
|
||||
}
|
||||
verifiable := []map[string]interface{}{
|
||||
{
|
||||
"value": user.Email,
|
||||
@@ -179,6 +182,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||
return "", fmt.Errorf("ory provider: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||
}
|
||||
|
||||
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
|
||||
return created.ID, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -35,6 +36,76 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestCreateUserSendsRequestedIdentityID(t *testing.T) {
|
||||
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
if payload["id"] != requestedID {
|
||||
t.Fatalf("expected id=%s, got=%v", requestedID, payload["id"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": requestedID})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
id, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: requestedID,
|
||||
Email: "restore@test.com",
|
||||
Name: "Restore User",
|
||||
}, "Sup3rStr0ng!Pass#2026")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
if id != requestedID {
|
||||
t.Fatalf("expected %s, got %s", requestedID, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsRequestedIdentityIDMismatch(t *testing.T) {
|
||||
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "generated-id"})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
_, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: requestedID,
|
||||
Email: "restore@test.com",
|
||||
Name: "Restore User",
|
||||
}, "Sup3rStr0ng!Pass#2026")
|
||||
if err == nil || !strings.Contains(err.Error(), "requested identity id was not preserved") {
|
||||
t.Fatalf("expected requested identity id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPassword_Success(t *testing.T) {
|
||||
const (
|
||||
loginID = "user@example.com"
|
||||
|
||||
@@ -30,6 +30,8 @@ type WorksmobileDirectoryClient interface {
|
||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
AddUserAliasEmail(ctx context.Context, userID string, email string) error
|
||||
ResetUserPassword(ctx context.Context, userID string, password string) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
SetUserActive(ctx context.Context, userID string, active bool) error
|
||||
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
|
||||
@@ -283,6 +285,45 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("worksmobile alias email is required")
|
||||
}
|
||||
err := c.sendDirectoryJSON(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
|
||||
nil,
|
||||
)
|
||||
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
password = strings.TrimSpace(password)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("worksmobile password is required")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"passwordConfig": WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
@@ -756,22 +797,23 @@ type WorksmobileOrgUnitPatchPayload struct {
|
||||
}
|
||||
|
||||
type WorksmobileRemoteUser struct {
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
Task string `json:"task"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
DomainName string `json:"domainName"`
|
||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
Task string `json:"task"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
DomainName string `json:"domainName"`
|
||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type WorksmobileRemoteGroup struct {
|
||||
@@ -907,6 +949,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
|
||||
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
|
||||
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
|
||||
user.OrgUnitManagers = parseWorksmobileOrgUnitManagers(resource)
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1029,6 +1072,43 @@ func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOr
|
||||
return worksmobileOrgUnitDetail{}
|
||||
}
|
||||
|
||||
func parseWorksmobileOrgUnitManagers(resource map[string]any) map[string]*bool {
|
||||
result := map[string]*bool{}
|
||||
collectWorksmobileOrgUnitManagers(resource["organizations"], result)
|
||||
collectWorksmobileOrgUnitManagers(resource["orgUnits"], result)
|
||||
for key, raw := range resource {
|
||||
if !strings.Contains(strings.ToLower(key), "works") {
|
||||
continue
|
||||
}
|
||||
if values, ok := raw.(map[string]any); ok {
|
||||
collectWorksmobileOrgUnitManagers(values["organizations"], result)
|
||||
collectWorksmobileOrgUnitManagers(values["orgUnits"], result)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectWorksmobileOrgUnitManagers(raw any, result map[string]*bool) {
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range values {
|
||||
orgUnit, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id := firstStringFromMap(orgUnit, "orgUnitId", "id", "value"); id != "" {
|
||||
result[id] = boolPointerFromMap(orgUnit, "isManager", "manager")
|
||||
}
|
||||
collectWorksmobileOrgUnitManagers(orgUnit["organizations"], result)
|
||||
collectWorksmobileOrgUnitManagers(orgUnit["orgUnits"], result)
|
||||
}
|
||||
}
|
||||
|
||||
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
|
||||
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
|
||||
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
|
||||
|
||||
@@ -113,6 +113,50 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
|
||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusCreated,
|
||||
body: `{}`,
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.AddUserAliasEmail(context.Background(), "ypshim@samaneng.com", "ypshim@hanmaceng.co.kr")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.request)
|
||||
require.Equal(t, http.MethodPost, transport.request.Method)
|
||||
require.Equal(t, "/v1.0/users/ypshim@samaneng.com/alias-emails/ypshim@hanmaceng.co.kr", transport.request.URL.Path)
|
||||
require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusOK,
|
||||
body: `{}`,
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.ResetUserPassword(context.Background(), "target@samaneng.com", "Aa1!Aa1!Aa1!Aa1!")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.request)
|
||||
require.Equal(t, http.MethodPatch, transport.request.Method)
|
||||
require.Equal(t, "/v1.0/users/target@samaneng.com", transport.request.URL.Path)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
||||
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||
require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
@@ -472,6 +516,71 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
|
||||
Email: "ypshim@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
AliasEmails: []string{"ypshim@hanmaceng.co.kr"},
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: "Aa1!Aa1!Aa1!Aa1!",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Equal(t, "ypshim@samaneng.com", client.createdUsers[0].Email)
|
||||
require.Empty(t, client.createdUsers[0].AliasEmails)
|
||||
require.Equal(t, []string{"ypshim@samaneng.com:ypshim@hanmaceng.co.kr"}, client.aliasEmails)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesUserPasswordResetAndMarksProcessed(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-reset",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Action: domain.WorksmobileActionPasswordReset,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"loginEmail": "target@samaneng.com",
|
||||
"request": map[string]any{
|
||||
"email": "target@samaneng.com",
|
||||
"passwordConfig": map[string]any{
|
||||
"passwordCreationType": "ADMIN",
|
||||
"password": "Aa1!Aa1!Aa1!Aa1!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-reset"}, repo.processedIDs)
|
||||
require.Equal(t, []string{"target@samaneng.com:Aa1!Aa1!Aa1!Aa1!"}, client.passwordResets)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
@@ -615,7 +724,7 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) {
|
||||
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) {
|
||||
localUsers := []domain.User{
|
||||
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
|
||||
}
|
||||
@@ -626,13 +735,37 @@ func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t
|
||||
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
|
||||
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
|
||||
|
||||
require.Empty(t, diffOnly)
|
||||
require.Len(t, diffOnly, 1)
|
||||
require.Equal(t, "needs_update", diffOnly[0].Status)
|
||||
require.Len(t, all, 1)
|
||||
require.Equal(t, "matched", all[0].Status)
|
||||
require.Equal(t, "needs_update", all[0].Status)
|
||||
require.Equal(t, "works-1", all[0].WorksmobileID)
|
||||
require.Empty(t, all[0].ExternalKey)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIncludesRecentFailedJobForMissingUser(t *testing.T) {
|
||||
localUsers := []domain.User{
|
||||
{ID: "user-1", Email: "missing@samaneng.com", Name: "Missing"},
|
||||
}
|
||||
jobSummaries := map[string]worksmobileUserJobSummary{
|
||||
"user-1": {
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
RetryCount: 3,
|
||||
LastError: "worksmobile api failed",
|
||||
LastAttemptAt: "2026-06-01T05:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(localUsers, nil, false, nil, jobSummaries)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||
require.Equal(t, domain.WorksmobileOutboxStatusFailed, items[0].WorksmobileJobStatus)
|
||||
require.Equal(t, 3, items[0].WorksmobileJobRetryCount)
|
||||
require.Equal(t, "worksmobile api failed", items[0].WorksmobileLastError)
|
||||
require.Equal(t, "2026-06-01T05:00:00Z", items[0].WorksmobileLastAttemptAt)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
|
||||
remoteUsers := []WorksmobileRemoteUser{
|
||||
{ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"},
|
||||
@@ -894,6 +1027,41 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
|
||||
require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName)
|
||||
require.NotNil(t, user.PrimaryOrgUnitIsManager)
|
||||
require.True(t, *user.PrimaryOrgUnitIsManager)
|
||||
require.NotNil(t, user.OrgUnitManagers["works-org-1"])
|
||||
require.True(t, *user.OrgUnitManagers["works-org-1"])
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
|
||||
user := parseWorksmobileDirectoryUser(map[string]any{
|
||||
"userId": "works-user",
|
||||
"email": "tester@samaneng.com",
|
||||
"userName": map[string]any{
|
||||
"lastName": "홍길동",
|
||||
},
|
||||
"organizations": []any{
|
||||
map[string]any{
|
||||
"primary": true,
|
||||
"orgUnits": []any{
|
||||
map[string]any{
|
||||
"orgUnitId": "externalKey:primary-org",
|
||||
"primary": true,
|
||||
"isManager": false,
|
||||
},
|
||||
map[string]any{
|
||||
"orgUnitId": "externalKey:secondary-org",
|
||||
"primary": false,
|
||||
"isManager": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, user.OrgUnitManagers, 2)
|
||||
require.NotNil(t, user.OrgUnitManagers["externalKey:primary-org"])
|
||||
require.False(t, *user.OrgUnitManagers["externalKey:primary-org"])
|
||||
require.NotNil(t, user.OrgUnitManagers["externalKey:secondary-org"])
|
||||
require.True(t, *user.OrgUnitManagers["externalKey:secondary-org"])
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||
@@ -908,11 +1076,14 @@ func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeWorksmobileOutboxRepo struct {
|
||||
ready []domain.WorksmobileOutbox
|
||||
created []domain.WorksmobileOutbox
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
failedIDs []string
|
||||
recent []domain.WorksmobileOutbox
|
||||
ready []domain.WorksmobileOutbox
|
||||
created []domain.WorksmobileOutbox
|
||||
credentialBatchJobs []domain.WorksmobileOutbox
|
||||
payloadUpdates []domain.JSONMap
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
failedIDs []string
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
|
||||
@@ -921,7 +1092,31 @@ func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.Wor
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
return nil, nil
|
||||
return f.recent, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
|
||||
rows := make([]domain.WorksmobileOutbox, 0)
|
||||
for _, row := range f.credentialBatchJobs {
|
||||
if stringValue(row.Payload["tenantRootId"]) != tenantRootID {
|
||||
continue
|
||||
}
|
||||
if credentialBatchID != "" && stringValue(row.Payload["credentialBatchId"]) != credentialBatchID {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error {
|
||||
f.payloadUpdates = append(f.payloadUpdates, payload)
|
||||
for i := range f.credentialBatchJobs {
|
||||
if f.credentialBatchJobs[i].ID == id {
|
||||
f.credentialBatchJobs[i].Payload = payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
@@ -958,6 +1153,8 @@ type fakeWorksmobileDirectoryClient struct {
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
aliasEmails []string
|
||||
passwordResets []string
|
||||
users []WorksmobileRemoteUser
|
||||
orgUnitMatchKeys []string
|
||||
groups []WorksmobileRemoteGroup
|
||||
@@ -1062,6 +1259,16 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
f.passwordResets = append(f.passwordResets, userID+":"+password)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error {
|
||||
f.deletedUsers = append(f.deletedUsers, userID)
|
||||
return nil
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
||||
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
|
||||
continue
|
||||
}
|
||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID)
|
||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, item)
|
||||
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
|
||||
@@ -70,7 +70,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
||||
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status)
|
||||
}
|
||||
|
||||
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID)
|
||||
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID, "")
|
||||
require.NoError(t, err)
|
||||
seen := map[string]bool{}
|
||||
for _, credential := range credentials {
|
||||
|
||||
@@ -51,15 +51,21 @@ type WorksmobilePasswordConfig struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type WorksmobilePasswordResetPayload struct {
|
||||
Email string `json:"email"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
|
||||
}
|
||||
|
||||
type WorksmobileUserOrganization struct {
|
||||
DomainID int64 `json:"domainId,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
|
||||
}
|
||||
|
||||
type WorksmobileUserOrgUnit struct {
|
||||
OrgUnitID string `json:"orgUnitId"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
PositionID string `json:"positionId,omitempty"`
|
||||
IsManager *bool `json:"isManager,omitempty"`
|
||||
}
|
||||
@@ -156,12 +162,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
tenantByID = map[string]domain.Tenant{}
|
||||
}
|
||||
tenantByID[tenant.ID] = tenant
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
|
||||
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
|
||||
employeeNumber := metadataEmployeeNumber(user.Metadata)
|
||||
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
@@ -202,28 +207,19 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if len(appointments) == 0 {
|
||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
||||
}
|
||||
primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id")
|
||||
if primaryTenantID == "" && user.TenantID != nil {
|
||||
primaryTenantID = *user.TenantID
|
||||
}
|
||||
hasPrimary := false
|
||||
for i := range appointments {
|
||||
if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary {
|
||||
appointments[i].IsPrimary = true
|
||||
hasPrimary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPrimary {
|
||||
for i := range appointments {
|
||||
if appointments[i].TenantID == tenant.ID {
|
||||
appointments[i].IsPrimary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID)
|
||||
accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant)
|
||||
if !worksmobileAppointmentsContainDomain(appointments, tenantByID, accountDomainEnvKey) && accountDomainTenant.ID != "" {
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: accountDomainTenant.ID,
|
||||
IsPrimary: true,
|
||||
JobTitle: strings.TrimSpace(user.JobTitle),
|
||||
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
|
||||
}}, appointments...)
|
||||
}
|
||||
|
||||
organizations := make([]WorksmobileUserOrganization, 0, len(appointments))
|
||||
organizations := make([]WorksmobileUserOrganization, 0)
|
||||
organizationIndexByDomainID := map[int64]int{}
|
||||
seen := map[string]bool{}
|
||||
task := ""
|
||||
for _, appointment := range appointments {
|
||||
@@ -242,21 +238,34 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
|
||||
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
|
||||
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||
orgUnit := WorksmobileUserOrgUnit{
|
||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||
Primary: appointment.IsPrimary,
|
||||
Primary: !organizationExists,
|
||||
PositionID: appointment.PositionID,
|
||||
}
|
||||
if appointment.HasManager {
|
||||
isManager := appointment.IsManager
|
||||
orgUnit.IsManager = &isManager
|
||||
}
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: domainID,
|
||||
Primary: appointment.IsPrimary,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||
})
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
if organizationExists {
|
||||
if isPrimaryOrganization {
|
||||
organizations[organizationIndex].Primary = true
|
||||
organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
|
||||
}
|
||||
organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
|
||||
} else {
|
||||
organizationIndexByDomainID[domainID] = len(organizations)
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: domainID,
|
||||
Email: worksmobileOrganizationEmail(user, domainTenant),
|
||||
Primary: isPrimaryOrganization,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||
})
|
||||
}
|
||||
if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
task = strings.TrimSpace(appointment.JobTitle)
|
||||
}
|
||||
seen[appointment.TenantID] = true
|
||||
@@ -264,10 +273,39 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if len(organizations) == 0 {
|
||||
return nil, "", errors.New("no valid worksmobile organization")
|
||||
}
|
||||
if !worksmobileOrganizationsHavePrimary(organizations) {
|
||||
organizations[0].Primary = true
|
||||
if len(organizations[0].OrgUnits) > 0 {
|
||||
organizations[0].OrgUnits[0].Primary = true
|
||||
}
|
||||
}
|
||||
sortWorksmobileOrganizations(organizations)
|
||||
return organizations, task, nil
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool {
|
||||
for _, appointment := range appointments {
|
||||
tenant, ok := tenantByID[appointment.TenantID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool {
|
||||
for _, organization := range organizations {
|
||||
if organization.Primary {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
|
||||
rawAppointments, ok := metadata["additionalAppointments"].([]any)
|
||||
if !ok {
|
||||
@@ -326,7 +364,7 @@ func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []strin
|
||||
} {
|
||||
candidates = append(candidates, metadataStringList(user.Metadata, key)...)
|
||||
}
|
||||
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
|
||||
employeeNumber := metadataEmployeeNumber(user.Metadata)
|
||||
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
|
||||
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
|
||||
}
|
||||
@@ -351,26 +389,21 @@ func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) [
|
||||
return result
|
||||
}
|
||||
|
||||
func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error {
|
||||
seen := map[string]string{}
|
||||
primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[primaryLocalPart] = primaryEmail
|
||||
func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, existingEmails map[string]string) error {
|
||||
seen := map[string]string{strings.ToLower(strings.TrimSpace(primaryEmail)): primaryEmail}
|
||||
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail)
|
||||
if err != nil {
|
||||
normalized := strings.ToLower(strings.TrimSpace(aliasEmail))
|
||||
if _, err := mail.ParseAddress(normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, ok := seen[localPart]; ok {
|
||||
return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail)
|
||||
if previous, ok := seen[normalized]; ok {
|
||||
return fmt.Errorf("worksmobile alias email duplicates: %s and %s", previous, aliasEmail)
|
||||
}
|
||||
if owner, ok := existingLocalParts[localPart]; ok {
|
||||
return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner)
|
||||
if owner, ok := existingEmails[normalized]; ok {
|
||||
return fmt.Errorf("worksmobile alias email %s는 이미 사용 중입니다: %s", normalized, owner)
|
||||
}
|
||||
seen[localPart] = aliasEmail
|
||||
seen[normalized] = aliasEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -446,6 +479,91 @@ func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap
|
||||
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
|
||||
}
|
||||
|
||||
func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant domain.Tenant, rootConfig domain.JSONMap) (int64, error) {
|
||||
switch worksmobileEmailDomainName(email) {
|
||||
case "samaneng.com":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("SAMAN_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "hanmaceng.co.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("HANMAC_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "baroncs.co.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
case "brsw.kr":
|
||||
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
|
||||
return domainID, nil
|
||||
}
|
||||
}
|
||||
return ResolveWorksmobileDomainIDFromTenant(fallbackTenant, rootConfig)
|
||||
}
|
||||
|
||||
func worksmobileAccountDomainTenantFromEmail(email string, fallbackTenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
|
||||
envKey := worksmobileDomainIDEnvKeyFromEmail(email)
|
||||
for _, tenant := range tenantByID {
|
||||
if isWorksmobileDomainRootTenant(tenant) && worksmobileTenantDomainIDEnvKey(tenant) == envKey {
|
||||
return tenant
|
||||
}
|
||||
}
|
||||
for _, tenant := range tenantByID {
|
||||
if worksmobileTenantDomainIDEnvKey(tenant) == envKey {
|
||||
return worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
}
|
||||
}
|
||||
return worksmobileDomainClassificationTenant(fallbackTenant, tenantByID)
|
||||
}
|
||||
|
||||
func worksmobileDomainIDEnvKeyFromEmail(email string) string {
|
||||
switch worksmobileEmailDomainName(email) {
|
||||
case "samaneng.com":
|
||||
return "SAMAN_DOMAIN_ID"
|
||||
case "hanmaceng.co.kr":
|
||||
return "HANMAC_DOMAIN_ID"
|
||||
case "baroncs.co.kr":
|
||||
return "GPDTDC_DOMAIN_ID"
|
||||
case "brsw.kr":
|
||||
return "BARONGROUP_DOMAIN_ID"
|
||||
default:
|
||||
return worksmobileTenantDomainIDEnvKey(domain.Tenant{})
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileEmailDomainName(email string) string {
|
||||
address, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(address.Address, "@")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
|
||||
func worksmobileOrganizationEmail(user domain.User, domainTenant domain.Tenant) string {
|
||||
domainName := worksmobileTenantMailDomain(domainTenant)
|
||||
if domainName == "" {
|
||||
return ""
|
||||
}
|
||||
primaryEmail := strings.ToLower(strings.TrimSpace(user.Email))
|
||||
if worksmobileEmailDomainName(primaryEmail) == domainName {
|
||||
return primaryEmail
|
||||
}
|
||||
for _, alias := range BuildWorksmobileAliasEmails(user, domainTenant) {
|
||||
if worksmobileEmailDomainName(alias) == domainName {
|
||||
return alias
|
||||
}
|
||||
}
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
|
||||
if err != nil || localPart == "" {
|
||||
return ""
|
||||
}
|
||||
return localPart + "@" + domainName
|
||||
}
|
||||
|
||||
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
|
||||
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
|
||||
return "SAMAN_DOMAIN_ID"
|
||||
@@ -597,6 +715,70 @@ func metadataString(metadata domain.JSONMap, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func metadataEmployeeNumber(metadata domain.JSONMap) string {
|
||||
for _, key := range []string{"employee_id", "employeeNumber", "employee_number"} {
|
||||
value, ok := metadata[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if normalized := normalizeMetadataEmployeeNumber(value); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeMetadataEmployeeNumber(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
case map[string]any:
|
||||
return normalizeMetadataCharacterMap(v)
|
||||
case domain.JSONMap:
|
||||
return normalizeMetadataCharacterMap(map[string]any(v))
|
||||
case map[string]string:
|
||||
converted := make(map[string]any, len(v))
|
||||
for key, value := range v {
|
||||
converted[key] = value
|
||||
}
|
||||
return normalizeMetadataCharacterMap(converted)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMetadataCharacterMap(value map[string]any) string {
|
||||
type characterEntry struct {
|
||||
index int
|
||||
value string
|
||||
}
|
||||
entries := make([]characterEntry, 0, len(value))
|
||||
for key, raw := range value {
|
||||
index, err := strconv.Atoi(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
part, ok := raw.(string)
|
||||
if !ok || part == "" {
|
||||
return ""
|
||||
}
|
||||
entries = append(entries, characterEntry{index: index, value: part})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].index < entries[j].index
|
||||
})
|
||||
var builder strings.Builder
|
||||
for _, entry := range entries {
|
||||
builder.WriteString(entry.value)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
|
||||
value, _ := metadataOptionalBool(metadata, keys...)
|
||||
return value
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -134,6 +135,36 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "john1@samaneng.com",
|
||||
Name: "John Doe",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"employee_id": map[string]any{
|
||||
"0": "j",
|
||||
"1": "o",
|
||||
"2": "h",
|
||||
"3": "n",
|
||||
},
|
||||
},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "john", payload.EmployeeNumber)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
@@ -198,7 +229,7 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
}
|
||||
@@ -259,7 +290,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1003), payload.DomainID)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Equal(t, "First affiliation task", payload.Task)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
@@ -269,7 +300,99 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOrgIsGPDTDC(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
samanID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "dhlee@samaneng.com",
|
||||
Name: "GPDTDC Saman User",
|
||||
TenantID: &leafTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": leafTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
samanTenant := domain.Tenant{
|
||||
ID: samanID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
gpdtdcTenant := domain.Tenant{
|
||||
ID: gpdtdcID,
|
||||
Slug: "gpdtdc",
|
||||
Name: "총괄기획&기술개발센터",
|
||||
}
|
||||
leafTenant := domain.Tenant{
|
||||
ID: leafTenantID,
|
||||
Slug: "infra-bim2",
|
||||
Name: "인프라 BIM2",
|
||||
ParentID: &gpdtdcID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
leafTenant,
|
||||
map[string]domain.Tenant{
|
||||
samanID: samanTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
leafTenantID: leafTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email)
|
||||
require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email)
|
||||
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
|
||||
payload := WorksmobileUserPayload{
|
||||
Email: "user@samaneng.com",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:primary", Primary: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
DomainID: 1003,
|
||||
Primary: false,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:secondary", Primary: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), `"primary":false`)
|
||||
require.Contains(t, string(data), `"orgUnitId":"externalKey:secondary","primary":false`)
|
||||
}
|
||||
|
||||
func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) {
|
||||
@@ -441,19 +564,51 @@ func TestBuildWorksmobileUserPayloadAddsSubEmailMetadataAlias(t *testing.T) {
|
||||
require.Equal(t, []string{"alias1@hanmaceng.co.kr", "alias2@hanmaceng.co.kr"}, payload.AliasEmails)
|
||||
}
|
||||
|
||||
func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) {
|
||||
err := ValidateWorksmobileAliasLocalParts(
|
||||
func TestBuildWorksmobileUserPayloadKeepsSubEmailAliasWithPrimaryLocalPart(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "ypshim@samaneng.com",
|
||||
Name: "Saman User",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"sub_email": "ypshim@hanmaceng.co.kr",
|
||||
},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"ypshim@hanmaceng.co.kr"}, payload.AliasEmails)
|
||||
}
|
||||
|
||||
func TestValidateWorksmobileAliasEmailsAllowsSameLocalPartOnDifferentDomains(t *testing.T) {
|
||||
err := ValidateWorksmobileAliasEmails(
|
||||
"main@samaneng.com",
|
||||
[]string{"main@hanmaceng.co.kr"},
|
||||
map[string]string{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "local-part")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateWorksmobileAliasLocalParts(
|
||||
err = ValidateWorksmobileAliasEmails(
|
||||
"main@samaneng.com",
|
||||
[]string{"main@samaneng.com"},
|
||||
map[string]string{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicates")
|
||||
|
||||
err = ValidateWorksmobileAliasEmails(
|
||||
"main@samaneng.com",
|
||||
[]string{"alias@hanmaceng.co.kr"},
|
||||
map[string]string{"alias": "existing-user"},
|
||||
map[string]string{"alias@hanmaceng.co.kr": "existing-user"},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "이미 사용 중")
|
||||
|
||||
@@ -100,9 +100,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
||||
payload.AliasEmails = nil
|
||||
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
|
||||
}
|
||||
@@ -111,6 +118,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
|
||||
case domain.WorksmobileActionSuspend:
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
|
||||
case domain.WorksmobileActionPasswordReset:
|
||||
var payload WorksmobilePasswordResetPayload
|
||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = worksmobileOutboxUserIdentifier(job)
|
||||
}
|
||||
return w.client.ResetUserPassword(ctx, identifier, payload.PasswordConfig.Password)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const HanmacFamilyTenantSlug = "hanmac-family"
|
||||
@@ -25,9 +27,12 @@ type WorksmobileAdminService interface {
|
||||
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
|
||||
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
|
||||
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
|
||||
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
|
||||
}
|
||||
|
||||
type WorksmobileConfigSummary struct {
|
||||
@@ -49,10 +54,36 @@ type WorksmobileBackfillDryRun struct {
|
||||
}
|
||||
|
||||
type WorksmobileInitialPasswordCredential struct {
|
||||
Email string `json:"email"`
|
||||
InitialPassword string `json:"initialPassword"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PrimaryLeafOrgName string `json:"primaryLeafOrgName,omitempty"`
|
||||
InitialPassword string `json:"initialPassword"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileCredentialBatch struct {
|
||||
BatchID string `json:"batchId"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
UserCount int `json:"userCount"`
|
||||
PendingCount int `json:"pendingCount"`
|
||||
ProcessingCount int `json:"processingCount"`
|
||||
ProcessedCount int `json:"processedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
HasPasswords bool `json:"hasPasswords"`
|
||||
DeletedAt string `json:"deletedAt,omitempty"`
|
||||
Failures []WorksmobileCredentialBatchFailure `json:"failures,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type WorksmobileCredentialBatchFailure struct {
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileComparison struct {
|
||||
@@ -93,6 +124,10 @@ type WorksmobileComparisonItem struct {
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -185,8 +220,10 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
return WorksmobileComparison{}, err
|
||||
}
|
||||
|
||||
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
||||
|
||||
return WorksmobileComparison{
|
||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
|
||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
|
||||
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
||||
}, nil
|
||||
}
|
||||
@@ -340,7 +377,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -394,18 +431,104 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
}
|
||||
item.Payload["displayName"] = strings.TrimSpace(user.Name)
|
||||
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
|
||||
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
|
||||
item.Payload["credentialBatchId"] = batchID
|
||||
item.Payload["credentialOperation"] = "worksmobile_user_sync"
|
||||
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
|
||||
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
|
||||
user, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.TenantID == nil {
|
||||
return nil, errors.New("target user has no tenant")
|
||||
}
|
||||
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || tenantRoot.ID != root.ID {
|
||||
return nil, errors.New("target user is outside hanmac-family subtree")
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil, errors.New("target user status is excluded from Worksmobile password reset")
|
||||
}
|
||||
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password := GenerateWorksmobileInitialPassword()
|
||||
request := WorksmobilePasswordResetPayload{
|
||||
Email: strings.TrimSpace(payload.Email),
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
batchID := strings.TrimSpace(credentialBatchID)
|
||||
batchCreatedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
dedupeSuffix := batchID
|
||||
if dedupeSuffix == "" {
|
||||
dedupeSuffix = batchCreatedAt
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: domain.WorksmobileActionPasswordReset,
|
||||
DedupeKey: "user:password-reset:" + user.ID + ":" + dedupeSuffix,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": root.ID,
|
||||
"loginEmail": request.Email,
|
||||
"userExternalKey": user.ID,
|
||||
"initialPassword": password,
|
||||
"displayName": strings.TrimSpace(user.Name),
|
||||
"primaryLeafOrgName": worksmobileUserPrimaryOrgName(*user, tenantByID),
|
||||
"credentialBatchId": batchID,
|
||||
"credentialOperation": "worksmobile_password_reset",
|
||||
"credentialBatchCreatedAt": batchCreatedAt,
|
||||
"request": request,
|
||||
},
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentialBatchID = strings.TrimSpace(credentialBatchID)
|
||||
var jobs []domain.WorksmobileOutbox
|
||||
if credentialBatchID != "" {
|
||||
jobs, err = s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
|
||||
} else {
|
||||
jobs, err = s.outboxRepo.ListRecent(ctx, 1000)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,6 +541,9 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
|
||||
if stringValue(job.Payload["tenantRootId"]) != root.ID {
|
||||
continue
|
||||
}
|
||||
if credentialBatchID != "" && stringValue(job.Payload["credentialBatchId"]) != credentialBatchID {
|
||||
continue
|
||||
}
|
||||
email := stringValue(job.Payload["loginEmail"])
|
||||
password := stringValue(job.Payload["initialPassword"])
|
||||
if email == "" || password == "" || seen[email] {
|
||||
@@ -425,15 +551,60 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
|
||||
}
|
||||
seen[email] = true
|
||||
credentials = append(credentials, WorksmobileInitialPasswordCredential{
|
||||
Email: email,
|
||||
InitialPassword: password,
|
||||
Status: job.Status,
|
||||
LastError: job.LastError,
|
||||
Email: email,
|
||||
Name: stringValue(job.Payload["displayName"]),
|
||||
PrimaryLeafOrgName: stringValue(job.Payload["primaryLeafOrgName"]),
|
||||
InitialPassword: password,
|
||||
Status: job.Status,
|
||||
LastError: job.LastError,
|
||||
})
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aggregateWorksmobileCredentialBatches(jobs), nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return WorksmobileCredentialBatch{}, err
|
||||
}
|
||||
credentialBatchID = strings.TrimSpace(credentialBatchID)
|
||||
if credentialBatchID == "" {
|
||||
return WorksmobileCredentialBatch{}, errors.New("credential batch id is required")
|
||||
}
|
||||
jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID)
|
||||
if err != nil {
|
||||
return WorksmobileCredentialBatch{}, err
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
|
||||
}
|
||||
deletedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
for i := range jobs {
|
||||
nextPayload := scrubWorksmobileCredentialPayload(jobs[i].Payload, deletedAt)
|
||||
if err := s.outboxRepo.UpdatePayload(ctx, jobs[i].ID, nextPayload); err != nil {
|
||||
return WorksmobileCredentialBatch{}, err
|
||||
}
|
||||
jobs[i].Payload = nextPayload
|
||||
}
|
||||
batches := aggregateWorksmobileCredentialBatches(jobs)
|
||||
if len(batches) == 0 {
|
||||
return WorksmobileCredentialBatch{}, errors.New("credential batch not found")
|
||||
}
|
||||
return batches[0], nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
|
||||
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
@@ -663,7 +834,7 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
|
||||
if existingUser.ID == user.ID {
|
||||
continue
|
||||
}
|
||||
addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
|
||||
addWorksmobileEmail(existing, existingUser.Email, existingUser.ID)
|
||||
if existingUser.TenantID == nil {
|
||||
continue
|
||||
}
|
||||
@@ -672,16 +843,16 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
|
||||
continue
|
||||
}
|
||||
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
|
||||
addWorksmobileLocalPart(existing, alias, existingUser.ID)
|
||||
addWorksmobileEmail(existing, alias, existingUser.ID)
|
||||
}
|
||||
}
|
||||
return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
|
||||
return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing)
|
||||
}
|
||||
|
||||
func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
|
||||
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
|
||||
if err == nil && localPart != "" {
|
||||
target[localPart] = owner
|
||||
func addWorksmobileEmail(target map[string]string, email string, owner string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" {
|
||||
target[normalized] = owner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,6 +1004,196 @@ func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload,
|
||||
return outboxPayload
|
||||
}
|
||||
|
||||
func aggregateWorksmobileCredentialBatches(jobs []domain.WorksmobileOutbox) []WorksmobileCredentialBatch {
|
||||
byBatchID := map[string]*WorksmobileCredentialBatch{}
|
||||
for _, job := range jobs {
|
||||
batchID := stringValue(job.Payload["credentialBatchId"])
|
||||
if batchID == "" {
|
||||
continue
|
||||
}
|
||||
batch, ok := byBatchID[batchID]
|
||||
if !ok {
|
||||
createdAt := worksmobileCredentialBatchCreatedAt(job)
|
||||
batch = &WorksmobileCredentialBatch{
|
||||
BatchID: batchID,
|
||||
Operation: stringValue(job.Payload["credentialOperation"]),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: job.UpdatedAt,
|
||||
}
|
||||
byBatchID[batchID] = batch
|
||||
}
|
||||
batch.UserCount++
|
||||
if batch.Operation == "" {
|
||||
batch.Operation = stringValue(job.Payload["credentialOperation"])
|
||||
}
|
||||
jobBatchCreatedAt := worksmobileCredentialBatchCreatedAt(job)
|
||||
if jobBatchCreatedAt.Before(batch.CreatedAt) || batch.CreatedAt.IsZero() {
|
||||
batch.CreatedAt = jobBatchCreatedAt
|
||||
}
|
||||
if job.UpdatedAt.After(batch.UpdatedAt) {
|
||||
batch.UpdatedAt = job.UpdatedAt
|
||||
}
|
||||
switch job.Status {
|
||||
case domain.WorksmobileOutboxStatusPending:
|
||||
batch.PendingCount++
|
||||
case domain.WorksmobileOutboxStatusProcessing:
|
||||
batch.ProcessingCount++
|
||||
case domain.WorksmobileOutboxStatusProcessed:
|
||||
batch.ProcessedCount++
|
||||
case domain.WorksmobileOutboxStatusFailed:
|
||||
batch.FailedCount++
|
||||
batch.Failures = append(batch.Failures, WorksmobileCredentialBatchFailure{
|
||||
UserID: job.ResourceID,
|
||||
Email: worksmobileCredentialJobEmail(job),
|
||||
Status: job.Status,
|
||||
RetryCount: job.RetryCount,
|
||||
LastError: strings.TrimSpace(job.LastError),
|
||||
UpdatedAt: job.UpdatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if worksmobilePayloadHasPassword(job.Payload) {
|
||||
batch.HasPasswords = true
|
||||
}
|
||||
if deletedAt := stringValue(job.Payload["credentialDeletedAt"]); deletedAt != "" {
|
||||
batch.DeletedAt = deletedAt
|
||||
}
|
||||
}
|
||||
batches := make([]WorksmobileCredentialBatch, 0, len(byBatchID))
|
||||
for _, batch := range byBatchID {
|
||||
batches = append(batches, *batch)
|
||||
}
|
||||
sort.Slice(batches, func(i, j int) bool {
|
||||
return batches[i].CreatedAt.After(batches[j].CreatedAt)
|
||||
})
|
||||
return batches
|
||||
}
|
||||
|
||||
func worksmobileCredentialBatchCreatedAt(job domain.WorksmobileOutbox) time.Time {
|
||||
if value := stringValue(job.Payload["credentialBatchCreatedAt"]); value != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return parsed.UTC()
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed.UTC()
|
||||
}
|
||||
}
|
||||
if !job.UpdatedAt.IsZero() && !job.CreatedAt.IsZero() && job.UpdatedAt.After(job.CreatedAt) {
|
||||
return job.UpdatedAt.UTC()
|
||||
}
|
||||
return job.CreatedAt.UTC()
|
||||
}
|
||||
|
||||
func worksmobileCredentialJobEmail(job domain.WorksmobileOutbox) string {
|
||||
if email := stringValue(job.Payload["loginEmail"]); email != "" {
|
||||
return email
|
||||
}
|
||||
switch request := job.Payload["request"].(type) {
|
||||
case WorksmobileUserPayload:
|
||||
return strings.TrimSpace(request.Email)
|
||||
case WorksmobilePasswordResetPayload:
|
||||
return strings.TrimSpace(request.Email)
|
||||
case map[string]any:
|
||||
return stringValue(request["email"])
|
||||
case domain.JSONMap:
|
||||
return stringValue(request["email"])
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func scrubWorksmobileCredentialPayload(payload domain.JSONMap, deletedAt string) domain.JSONMap {
|
||||
nextPayload := make(domain.JSONMap, len(payload)+1)
|
||||
for key, value := range payload {
|
||||
nextPayload[key] = value
|
||||
}
|
||||
delete(nextPayload, "initialPassword")
|
||||
nextPayload["credentialDeletedAt"] = deletedAt
|
||||
nextPayload["request"] = scrubWorksmobileRequestPassword(nextPayload["request"])
|
||||
return nextPayload
|
||||
}
|
||||
|
||||
func scrubWorksmobileRequestPassword(request any) any {
|
||||
switch v := request.(type) {
|
||||
case WorksmobileUserPayload:
|
||||
v.PasswordConfig.Password = ""
|
||||
return v
|
||||
case WorksmobilePasswordResetPayload:
|
||||
v.PasswordConfig.Password = ""
|
||||
return v
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
|
||||
return next
|
||||
case domain.JSONMap:
|
||||
next := make(domain.JSONMap, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"])
|
||||
return next
|
||||
default:
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
func scrubWorksmobilePasswordConfig(config any) any {
|
||||
switch v := config.(type) {
|
||||
case WorksmobilePasswordConfig:
|
||||
v.Password = ""
|
||||
return v
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["password"] = ""
|
||||
return next
|
||||
case domain.JSONMap:
|
||||
next := make(domain.JSONMap, len(v))
|
||||
for key, value := range v {
|
||||
next[key] = value
|
||||
}
|
||||
next["password"] = ""
|
||||
return next
|
||||
default:
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobilePayloadHasPassword(payload domain.JSONMap) bool {
|
||||
if stringValue(payload["initialPassword"]) != "" {
|
||||
return true
|
||||
}
|
||||
switch request := payload["request"].(type) {
|
||||
case WorksmobileUserPayload:
|
||||
return strings.TrimSpace(request.PasswordConfig.Password) != ""
|
||||
case WorksmobilePasswordResetPayload:
|
||||
return strings.TrimSpace(request.PasswordConfig.Password) != ""
|
||||
case map[string]any:
|
||||
return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
|
||||
case domain.JSONMap:
|
||||
return worksmobilePasswordConfigHasPassword(request["passwordConfig"])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobilePasswordConfigHasPassword(config any) bool {
|
||||
switch v := config.(type) {
|
||||
case WorksmobilePasswordConfig:
|
||||
return strings.TrimSpace(v.Password) != ""
|
||||
case map[string]any:
|
||||
return stringValue(v["password"]) != ""
|
||||
case domain.JSONMap:
|
||||
return stringValue(v["password"]) != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
@@ -842,7 +1203,40 @@ func stringValue(value any) string {
|
||||
}
|
||||
}
|
||||
|
||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
|
||||
type worksmobileUserJobSummary struct {
|
||||
Status string
|
||||
RetryCount int
|
||||
LastError string
|
||||
LastAttemptAt string
|
||||
}
|
||||
|
||||
func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]worksmobileUserJobSummary {
|
||||
result := map[string]worksmobileUserJobSummary{}
|
||||
for _, job := range jobs {
|
||||
if job.ResourceType != domain.WorksmobileResourceUser {
|
||||
continue
|
||||
}
|
||||
if job.ResourceID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[job.ResourceID]; exists {
|
||||
continue
|
||||
}
|
||||
result[job.ResourceID] = worksmobileUserJobSummary{
|
||||
Status: job.Status,
|
||||
RetryCount: job.RetryCount,
|
||||
LastError: strings.TrimSpace(job.LastError),
|
||||
LastAttemptAt: job.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
|
||||
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
||||
jobSummaryByUserID = jobSummaries[0]
|
||||
}
|
||||
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
||||
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
||||
for _, remote := range remoteUsers {
|
||||
@@ -872,7 +1266,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
if !matched {
|
||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||
}
|
||||
if matched && !includeMatched {
|
||||
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote)
|
||||
if matched && !includeMatched && !needsUpdate {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
}
|
||||
@@ -886,8 +1281,19 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
||||
Status: "missing_in_worksmobile",
|
||||
}
|
||||
if summary, ok := jobSummaryByUserID[user.ID]; ok {
|
||||
item.WorksmobileJobStatus = summary.Status
|
||||
item.WorksmobileJobRetryCount = summary.RetryCount
|
||||
item.WorksmobileLastAttemptAt = summary.LastAttemptAt
|
||||
if summary.Status == domain.WorksmobileOutboxStatusFailed {
|
||||
item.WorksmobileLastError = summary.LastError
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
item.Status = "matched"
|
||||
if needsUpdate {
|
||||
item.Status = "needs_update"
|
||||
}
|
||||
item.WorksmobileID = remote.ID
|
||||
item.ExternalKey = remote.ExternalID
|
||||
item.WorksmobileName = remote.DisplayName
|
||||
@@ -958,6 +1364,62 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
|
||||
return true
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localManagers := worksmobileUserExplicitOrgUnitManagers(user)
|
||||
if len(localManagers) == 0 {
|
||||
return false
|
||||
}
|
||||
remoteManagers := remote.OrgUnitManagers
|
||||
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
|
||||
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
|
||||
}
|
||||
for remoteOrgUnitID, remoteManager := range remoteManagers {
|
||||
if remoteManager == nil {
|
||||
continue
|
||||
}
|
||||
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
|
||||
if ok && localManager != *remoteManager {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
|
||||
managers := map[string]bool{}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
if appointment.TenantID == "" || !appointment.HasManager {
|
||||
continue
|
||||
}
|
||||
managers[appointment.TenantID] = appointment.IsManager
|
||||
}
|
||||
return managers
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
|
||||
normalized := strings.TrimSpace(orgUnitID)
|
||||
if after, ok := strings.CutPrefix(normalized, "externalKey:"); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrgID(user domain.User) string {
|
||||
if user.TenantID == nil {
|
||||
return ""
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
|
||||
func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
@@ -36,7 +37,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
||||
}
|
||||
existing := domain.User{
|
||||
ID: "existing-user",
|
||||
Email: "used@samaneng.com",
|
||||
Email: "used@hanmaceng.co.kr",
|
||||
Name: "Existing",
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
@@ -48,7 +49,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.Error(t, err)
|
||||
@@ -88,7 +89,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -101,6 +102,253 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, "batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-leaf"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "people-growth",
|
||||
Name: "인재성장",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action)
|
||||
require.Equal(t, "reset-batch-1", outboxRepo.created[0].Payload["credentialBatchId"])
|
||||
require.Equal(t, "worksmobile_password_reset", outboxRepo.created[0].Payload["credentialOperation"])
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Equal(t, "인재성장", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||
credentialBatchJobs: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "batch-user@samaneng.com",
|
||||
"displayName": "Batch User",
|
||||
"primaryLeafOrgName": "인재성장",
|
||||
"initialPassword": "BatchPass1!",
|
||||
"credentialBatchId": "batch-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "other-user@samaneng.com",
|
||||
"initialPassword": "OtherPass1!",
|
||||
"credentialBatchId": "batch-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "legacy-user@samaneng.com",
|
||||
"initialPassword": "LegacyPass1!",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
credentials, err := service.ListInitialPasswordCredentials(context.Background(), rootID, "batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []WorksmobileInitialPasswordCredential{
|
||||
{
|
||||
Email: "batch-user@samaneng.com",
|
||||
Name: "Batch User",
|
||||
PrimaryLeafOrgName: "인재성장",
|
||||
InitialPassword: "BatchPass1!",
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
},
|
||||
}, credentials)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDeletesCredentialBatchPasswordsButKeepsHistory(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{
|
||||
credentialBatchJobs: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusProcessed,
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "batch-user@samaneng.com",
|
||||
"initialPassword": "BatchPass1!",
|
||||
"credentialBatchId": "batch-1",
|
||||
"credentialOperation": "worksmobile_user_sync",
|
||||
"request": map[string]any{"passwordConfig": map[string]any{"password": "BatchPass1!"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "job-2",
|
||||
ResourceID: "failed-user",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
RetryCount: 2,
|
||||
LastError: "worksmobile api failed",
|
||||
Payload: domain.JSONMap{
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": "failed-user@samaneng.com",
|
||||
"initialPassword": "FailedPass1!",
|
||||
"credentialBatchId": "batch-1",
|
||||
"credentialOperation": "worksmobile_user_sync",
|
||||
"request": map[string]any{"passwordConfig": map[string]any{"password": "FailedPass1!"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
|
||||
&fakeWorksmobileUserRepo{},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
before, err := service.ListCredentialBatches(context.Background(), rootID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, before, 1)
|
||||
require.True(t, before[0].HasPasswords)
|
||||
require.Equal(t, 1, before[0].FailedCount)
|
||||
require.Len(t, before[0].Failures, 1)
|
||||
require.Equal(t, "failed-user", before[0].Failures[0].UserID)
|
||||
require.Equal(t, "failed-user@samaneng.com", before[0].Failures[0].Email)
|
||||
require.Equal(t, "worksmobile api failed", before[0].Failures[0].LastError)
|
||||
|
||||
after, err := service.DeleteCredentialBatchPasswords(context.Background(), rootID, "batch-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "batch-1", after.BatchID)
|
||||
require.False(t, after.HasPasswords)
|
||||
require.Equal(t, 2, after.UserCount)
|
||||
require.NotEmpty(t, after.DeletedAt)
|
||||
require.Len(t, outboxRepo.payloadUpdates, 2)
|
||||
require.Empty(t, stringValue(outboxRepo.payloadUpdates[0]["initialPassword"]))
|
||||
require.Empty(t, stringValue(outboxRepo.payloadUpdates[1]["initialPassword"]))
|
||||
request := outboxRepo.payloadUpdates[0]["request"].(map[string]any)
|
||||
passwordConfig := request["passwordConfig"].(map[string]any)
|
||||
require.Empty(t, stringValue(passwordConfig["password"]))
|
||||
}
|
||||
|
||||
func TestAggregateWorksmobileCredentialBatchesUsesCredentialBatchCreatedAt(t *testing.T) {
|
||||
oldCreatedAt := time.Date(2026, 5, 29, 1, 4, 15, 0, time.UTC)
|
||||
batchCreatedAt := time.Date(2026, 6, 1, 7, 20, 0, 0, time.UTC)
|
||||
|
||||
batches := aggregateWorksmobileCredentialBatches([]domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
CreatedAt: oldCreatedAt,
|
||||
UpdatedAt: batchCreatedAt.Add(time.Minute),
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: domain.JSONMap{
|
||||
"credentialBatchId": "batch-1",
|
||||
"credentialOperation": "worksmobile_user_sync",
|
||||
"credentialBatchCreatedAt": batchCreatedAt.Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, batches, 1)
|
||||
require.Equal(t, batchCreatedAt, batches[0].CreatedAt)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
@@ -133,7 +381,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -1139,6 +1387,95 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-manager",
|
||||
Email: "manager@samaneng.com",
|
||||
Name: "Manager User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"isManager": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
remoteManager := false
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-manager",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||
PrimaryOrgUnitIsManager: &remoteManager,
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||
primaryTenantID := "tenant-company"
|
||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-secondary-manager",
|
||||
Email: "secondary-manager@samaneng.com",
|
||||
Name: "Secondary Manager User",
|
||||
TenantID: &secondaryTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": primaryTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondaryTenantID,
|
||||
"isPrimary": false,
|
||||
"isManager": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
remotePrimaryManager := false
|
||||
remoteSecondaryManager := false
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-secondary-manager",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
|
||||
PrimaryOrgUnitIsManager: &remotePrimaryManager,
|
||||
OrgUnitManagers: map[string]*bool{
|
||||
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
||||
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
type fakeWorksmobileTenantService struct {
|
||||
tenants map[string]domain.Tenant
|
||||
list []domain.Tenant
|
||||
|
||||
Reference in New Issue
Block a user