1
0
forked from baron/baron-sso

feat: update worksmobile sync and restore planning

This commit is contained in:
2026-06-01 17:01:53 +09:00
parent 6574fb54b9
commit 5c8a338085
36 changed files with 3922 additions and 243 deletions

View File

@@ -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 {

View File

@@ -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 ""

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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")

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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(), "이미 사용 중")

View File

@@ -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
}

View File

@@ -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 ""

View File

@@ -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