forked from baron/baron-sso
네이버 계정 정합성 맞춤
This commit is contained in:
@@ -102,6 +102,44 @@ func (s *RedisService) Delete(key string) error {
|
||||
return s.Client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (s *RedisService) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
var deleted int64
|
||||
var cursor uint64
|
||||
pattern := prefix + "*"
|
||||
for {
|
||||
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
for len(keys) > 0 {
|
||||
chunkSize := len(keys)
|
||||
if chunkSize > 500 {
|
||||
chunkSize = 500
|
||||
}
|
||||
chunk := keys[:chunkSize]
|
||||
count, err := s.Client.Del(ctx, chunk...).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted += count
|
||||
keys = keys[chunkSize:]
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
|
||||
@@ -134,6 +134,27 @@ func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *
|
||||
}, stub.deleted)
|
||||
}
|
||||
|
||||
func TestRedisServiceDeleteByPrefixScansAndDeletesMatchingKeys(t *testing.T) {
|
||||
stub := &redisCommandStub{
|
||||
scans: map[string][]string{
|
||||
"orgchart:snapshot:v1:*": {
|
||||
"orgchart:snapshot:v1:super_admin:all:none",
|
||||
"orgchart:snapshot:v1:user:user-1:tenant-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := newStubbedRedisService(stub)
|
||||
|
||||
deleted, err := service.DeleteByPrefix(context.Background(), "orgchart:snapshot:v1:")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), deleted)
|
||||
require.ElementsMatch(t, []string{
|
||||
"orgchart:snapshot:v1:super_admin:all:none",
|
||||
"orgchart:snapshot:v1:user:user-1:tenant-1",
|
||||
}, stub.deleted)
|
||||
}
|
||||
|
||||
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
|
||||
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
||||
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
|
||||
} else if s.worksmobile != nil {
|
||||
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
|
||||
if err := s.worksmobile.EnqueueUserUpdateIfInScope(ctx, *localUser); err != nil {
|
||||
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
|
||||
f.userUpserts = append(f.userUpserts, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserProjectionSyncService struct {
|
||||
kratos KratosAdminService
|
||||
repo repository.UserProjectionRepository
|
||||
}
|
||||
|
||||
type UserProjectionReconciler interface {
|
||||
Reconcile(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService {
|
||||
return &UserProjectionSyncService{
|
||||
kratos: kratos,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) {
|
||||
if s == nil || s.kratos == nil || s.repo == nil {
|
||||
return 0, fmt.Errorf("user projection sync dependencies are not configured")
|
||||
}
|
||||
|
||||
identities, err := s.kratos.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
users := make([]domain.User, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
users = append(users, MapKratosIdentityToLocalUser(identity))
|
||||
}
|
||||
if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, err)
|
||||
return 0, err
|
||||
}
|
||||
return len(users), nil
|
||||
}
|
||||
|
||||
func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
traits := identity.Traits
|
||||
now := time.Now()
|
||||
createdAt := identity.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := identity.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = now
|
||||
}
|
||||
|
||||
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
|
||||
if !ok {
|
||||
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
|
||||
if !ok {
|
||||
role = domain.RoleUser
|
||||
}
|
||||
}
|
||||
grade := kratosProjectionTraitString(traits, "grade")
|
||||
if _, ok := domain.NormalizeRoleAlias(grade); ok {
|
||||
grade = ""
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
ID: identity.ID,
|
||||
Email: kratosProjectionTraitString(traits, "email"),
|
||||
Name: kratosProjectionTraitString(traits, "name"),
|
||||
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
|
||||
Role: role,
|
||||
Status: normalizeProjectionStatus(identity.State),
|
||||
Department: kratosProjectionTraitString(traits, "department"),
|
||||
Grade: grade,
|
||||
Position: kratosProjectionTraitString(traits, "position"),
|
||||
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
|
||||
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Metadata: make(domain.JSONMap),
|
||||
}
|
||||
if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" {
|
||||
user.TenantID = &tenantID
|
||||
}
|
||||
if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" {
|
||||
user.RelyingPartyID = &relyingPartyID
|
||||
}
|
||||
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "role": true,
|
||||
"companyCode": true, "company_code": true, "companyCodes": true,
|
||||
"tenant_id": true, "department": true,
|
||||
"position": true, "jobTitle": true, "affiliationType": true,
|
||||
"relying_party_id": true, "custom_login_ids": true, "id": true,
|
||||
}
|
||||
for key, value := range traits {
|
||||
if !coreTraits[key] {
|
||||
user.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func kratosProjectionTraitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := traits[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := value.(string); ok {
|
||||
return str
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
|
||||
if traits == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := traits[key].(type) {
|
||||
case []string:
|
||||
return value
|
||||
case []any:
|
||||
items := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
|
||||
items = append(items, str)
|
||||
}
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProjectionStatus(state string) string {
|
||||
normalized := domain.NormalizeUserStatus(state)
|
||||
if normalized == "" {
|
||||
return domain.UserStatusActive
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeUserProjectionRepo struct {
|
||||
replacedUsers []domain.User
|
||||
failedErr error
|
||||
replaceErr error
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
|
||||
return domain.UserProjectionStatus{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
|
||||
f.replacedUsers = append([]domain.User(nil), users...)
|
||||
return f.replaceErr
|
||||
}
|
||||
|
||||
func (f *fakeUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
|
||||
f.failedErr = syncErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
tenantID := "00000000-0000-0000-0000-000000000001"
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000101",
|
||||
Traits: map[string]any{
|
||||
"email": "one@example.com",
|
||||
"name": "One",
|
||||
"phone_number": "+821012345678",
|
||||
"companyCode": "saman",
|
||||
"companyCodes": []any{"saman", "group-a"},
|
||||
"tenant_id": tenantID,
|
||||
"department": "DX",
|
||||
"customAttr": "kept",
|
||||
},
|
||||
State: "active",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Len(t, repo.replacedUsers, 1)
|
||||
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
|
||||
assert.Equal(t, "One", repo.replacedUsers[0].Name)
|
||||
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
|
||||
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
|
||||
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
|
||||
require.NotNil(t, repo.replacedUsers[0].TenantID)
|
||||
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
|
||||
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])
|
||||
assert.NoError(t, repo.failedErr)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000102",
|
||||
Traits: map[string]any{
|
||||
"email": "two@example.com",
|
||||
"name": "Two",
|
||||
"phone_number": "+82 +821091917771",
|
||||
},
|
||||
State: "active",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
require.Len(t, repo.replacedUsers, 1)
|
||||
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kratos := new(MockKratosAdminServiceShared)
|
||||
repo := &fakeUserProjectionRepo{}
|
||||
svc := NewUserProjectionSyncService(kratos, repo)
|
||||
|
||||
expectedErr := errors.New("kratos down")
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{}, expectedErr).Once()
|
||||
|
||||
count, err := svc.Reconcile(ctx)
|
||||
|
||||
assert.Equal(t, 0, count)
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
assert.ErrorIs(t, repo.failedErr, expectedErr)
|
||||
assert.Empty(t, repo.replacedUsers)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
|
||||
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
||||
ID: "00000000-0000-0000-0000-000000000201",
|
||||
State: domain.UserStatusArchived,
|
||||
Traits: map[string]any{
|
||||
"email": "archived@example.com",
|
||||
"name": "Archived User",
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, domain.UserStatusArchived, user.Status)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -33,6 +34,7 @@ type WorksmobileDirectoryClient interface {
|
||||
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpdateUserOnly(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
|
||||
@@ -324,17 +326,14 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
payload = normalizeWorksmobileUserCreatePayload(payload)
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
err := c.CreateUser(ctx, payload)
|
||||
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
|
||||
if patchErr := c.updateUserByPatchOnly(ctx, payload); patchErr != nil {
|
||||
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
||||
}
|
||||
return nil
|
||||
@@ -342,6 +341,163 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
return c.updateUserByPatchOnly(ctx, payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
patchPayload := NewWorksmobileUserPatchPayload(payload)
|
||||
if patchErr := c.PatchUser(ctx, identifier, patchPayload); patchErr != nil {
|
||||
externalKey := strings.TrimSpace(payload.UserExternalKey)
|
||||
if patchAPIError, ok := patchErr.(WorksmobileHTTPError); ok && patchAPIError.StatusCode == http.StatusNotFound && externalKey != "" && externalKey != identifier {
|
||||
if externalKeyPatchErr := c.PatchUser(ctx, externalKey, patchPayload); externalKeyPatchErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
if externalKeyPatchAPIError, ok := externalKeyPatchErr.(WorksmobileHTTPError); ok && externalKeyPatchAPIError.StatusCode == http.StatusNotFound {
|
||||
if lookupPatchErr := c.patchUserByExternalKeyLookup(ctx, externalKey, payload.DomainID, patchPayload); lookupPatchErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("patch failed: %w; external key patch failed: %v; external key lookup patch failed: %v", patchErr, externalKeyPatchErr, lookupPatchErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("patch failed: %w; external key patch failed: %v", patchErr, externalKeyPatchErr)
|
||||
}
|
||||
}
|
||||
return patchErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) patchUserByExternalKeyLookup(ctx context.Context, externalKey string, requestedDomainID int64, payload WorksmobileUserPatchPayload) error {
|
||||
externalKey = strings.TrimSpace(externalKey)
|
||||
if externalKey == "" {
|
||||
return fmt.Errorf("worksmobile user external key is required")
|
||||
}
|
||||
matches, err := c.findUsersByExternalKey(ctx, externalKey, requestedDomainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("worksmobile user external key match not found after create conflict: %s", externalKey)
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
domainIDs := worksmobileRemoteUserDomainIDs(matches)
|
||||
userIDs := worksmobileRemoteUserIDs(matches)
|
||||
slog.Error(
|
||||
"Worksmobile external key matched multiple users during upsert conflict recovery",
|
||||
"externalKey", externalKey,
|
||||
"requestedDomainID", requestedDomainID,
|
||||
"domainIDs", domainIDs,
|
||||
"userIDs", userIDs,
|
||||
"matchCount", len(matches),
|
||||
)
|
||||
return fmt.Errorf("multiple worksmobile users matched external key: externalKey=%s requestedDomainID=%d domainIDs=%v userIDs=%v", externalKey, requestedDomainID, domainIDs, userIDs)
|
||||
}
|
||||
remote := matches[0]
|
||||
identifiers := compactUniqueStrings(remote.ID, remote.Email, remote.UserName)
|
||||
if len(identifiers) == 0 {
|
||||
return fmt.Errorf("worksmobile user external key match has no patch identifier: %s", externalKey)
|
||||
}
|
||||
var lastErr error
|
||||
for _, identifier := range identifiers {
|
||||
if err := c.PatchUser(ctx, identifier, payload); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) findUsersByExternalKey(ctx context.Context, externalKey string, requestedDomainID int64) ([]WorksmobileRemoteUser, error) {
|
||||
externalKey = strings.TrimSpace(externalKey)
|
||||
domainIDs := worksmobileExternalKeyLookupDomainIDs(requestedDomainID, c.DomainIDs)
|
||||
if c.directoryAuthConfigured() && len(domainIDs) > 0 {
|
||||
matches := make([]WorksmobileRemoteUser, 0, 1)
|
||||
for _, domainID := range domainIDs {
|
||||
users, err := c.listDirectoryUsers(ctx, []int64{domainID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.ExternalID == externalKey {
|
||||
matches = append(matches, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
users, err := c.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := make([]WorksmobileRemoteUser, 0, 1)
|
||||
for _, user := range users {
|
||||
if user.ExternalID == externalKey {
|
||||
matches = append(matches, user)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func worksmobileExternalKeyLookupDomainIDs(requestedDomainID int64, configuredDomainIDs []int64) []int64 {
|
||||
domainIDs := make([]int64, 0, len(configuredDomainIDs)+1)
|
||||
if requestedDomainID > 0 {
|
||||
domainIDs = append(domainIDs, requestedDomainID)
|
||||
}
|
||||
domainIDs = append(domainIDs, configuredDomainIDs...)
|
||||
return uniqueWorksmobileDomainIDs(domainIDs)
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserDomainIDs(users []WorksmobileRemoteUser) []int64 {
|
||||
domainIDs := make([]int64, 0, len(users))
|
||||
for _, user := range users {
|
||||
domainIDs = append(domainIDs, user.DomainID)
|
||||
}
|
||||
return uniqueWorksmobileDomainIDs(domainIDs)
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserIDs(users []WorksmobileRemoteUser) []string {
|
||||
ids := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
if id := strings.TrimSpace(user.ID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return compactUniqueStrings(ids...)
|
||||
}
|
||||
|
||||
func compactUniqueStrings(values ...string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWorksmobileUserCreatePayload(payload WorksmobileUserPayload) WorksmobileUserPayload {
|
||||
payload.Email = strings.TrimSpace(payload.Email)
|
||||
payload.PrivateEmail = strings.TrimSpace(payload.PrivateEmail)
|
||||
payload.CellPhone = normalizeWorksmobileOutboundCellPhone(payload.CellPhone)
|
||||
if strings.EqualFold(strings.TrimSpace(payload.PasswordConfig.PasswordCreationType), "ADMIN") &&
|
||||
strings.TrimSpace(payload.PasswordConfig.Password) != "" &&
|
||||
payload.PasswordConfig.ChangePasswordAtNextLogin == nil {
|
||||
changePasswordAtNextLogin := true
|
||||
payload.PasswordConfig.ChangePasswordAtNextLogin = &changePasswordAtNextLogin
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
@@ -995,7 +1151,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
|
||||
}
|
||||
|
||||
func normalizeWorksmobileOutboundCellPhone(value string) string {
|
||||
return domain.NormalizePhoneNumber(value)
|
||||
normalized := domain.NormalizePhoneNumber(value)
|
||||
if !strings.HasPrefix(normalized, "+82") {
|
||||
if strings.HasPrefix(normalized, "0") {
|
||||
return "+82 " + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
national := strings.TrimPrefix(normalized, "+82")
|
||||
if national == "" {
|
||||
return normalized
|
||||
}
|
||||
national = strings.TrimLeft(national, "0")
|
||||
if national == "" {
|
||||
return "+82 0"
|
||||
}
|
||||
return "+82 0" + national
|
||||
}
|
||||
|
||||
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
||||
Email: "tester@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
CellPhone: "+821041585840",
|
||||
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
|
||||
Locale: "ko_KR",
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
@@ -57,11 +58,13 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
||||
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
|
||||
require.Equal(t, "tester@samaneng.com", payload["email"])
|
||||
require.Equal(t, "user-1", payload["userExternalKey"])
|
||||
require.Equal(t, "+82 01041585840", payload["cellPhone"])
|
||||
require.NotContains(t, payload, "privateEmail")
|
||||
require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"])
|
||||
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||
require.Len(t, passwordConfig["password"], 16)
|
||||
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
|
||||
@@ -92,7 +95,7 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
|
||||
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
||||
})
|
||||
|
||||
require.Equal(t, "+821062836786", payload.CellPhone)
|
||||
require.Equal(t, "+82 01062836786", payload.CellPhone)
|
||||
}
|
||||
|
||||
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||
@@ -103,7 +106,7 @@ func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *test
|
||||
})
|
||||
|
||||
require.Len(t, payload.PhoneNumbers, 1)
|
||||
require.Equal(t, "+821062836786", payload.PhoneNumbers[0].Value)
|
||||
require.Equal(t, "+82 01062836786", payload.PhoneNumbers[0].Value)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
|
||||
@@ -155,6 +158,196 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
|
||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpdateUserOnlyPatchesWithoutCreateOrPassword(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.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300285955,
|
||||
Email: "moved@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Moved User"},
|
||||
PrivateEmail: "private@example.com",
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: "Aa1!Aa1!Aa1!Aa1!",
|
||||
},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300285955,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:new-tenant", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 1)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[0].Method)
|
||||
require.Equal(t, "/v1.0/users/moved@samaneng.com", transport.requests[0].URL.Path)
|
||||
|
||||
var patchPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
|
||||
require.NotContains(t, patchPayload, "passwordConfig")
|
||||
require.NotContains(t, patchPayload, "privateEmail")
|
||||
require.Equal(t, "moved@samaneng.com", patchPayload["email"])
|
||||
require.Equal(t, "user-1", patchPayload["userExternalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserFallsBackToExternalKeyPatchWhenEmailPatchNotFound(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300286336,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 3)
|
||||
require.Equal(t, http.MethodPost, transport.requests[0].Method)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
|
||||
require.Equal(t, "/v1.0/users/new-email@example.com", transport.requests[1].URL.Path)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
|
||||
require.Equal(t, "/v1.0/users/user-1", transport.requests[2].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserFallsBackToRemoteIDPatchWhenExternalKeyPatchNotFound(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300286336},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300286336,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 5)
|
||||
require.Equal(t, http.MethodGet, transport.requests[3].Method)
|
||||
require.Equal(t, "/v1.0/users", transport.requests[3].URL.Path)
|
||||
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodPatch, transport.requests[4].Method)
|
||||
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[4].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientExternalKeyLookupStartsWithPayloadDomain(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300285955, 300286336},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 6)
|
||||
require.Equal(t, http.MethodGet, transport.requests[3].Method)
|
||||
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodGet, transport.requests[4].Method)
|
||||
require.Equal(t, "300285955", transport.requests[4].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodPatch, transport.requests[5].Method)
|
||||
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[5].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientExternalKeyLookupRejectsDuplicateMatches(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
|
||||
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email-1@example.com"}],"responseMetaData":{}}`},
|
||||
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-2","userExternalKey":"user-1","email":"old-email-2@example.com"}],"responseMetaData":{}}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
DomainIDs: []int64{300286336, 300285955},
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "new-email@example.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "multiple worksmobile users matched external key")
|
||||
require.Len(t, transport.requests, 5)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusCreated,
|
||||
@@ -637,6 +830,45 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesAutomaticUserUpdateOnlyWithoutCreate(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: "moved@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Moved User"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300285955,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:new-tenant", Primary: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
repo.ready[0].Payload["provisioningMode"] = "update_only"
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Empty(t, client.createdUsers)
|
||||
require.Len(t, client.updatedUsers, 1)
|
||||
require.Equal(t, "moved@samaneng.com", client.updatedUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
@@ -1482,6 +1714,7 @@ type fakeWorksmobileDirectoryClient struct {
|
||||
createdOrgUnits []WorksmobileOrgUnitPayload
|
||||
deletedOrgUnits []string
|
||||
createdUsers []WorksmobileUserPayload
|
||||
updatedUsers []WorksmobileUserPayload
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
@@ -1610,6 +1843,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
f.updatedUsers = append(f.updatedUsers, payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
|
||||
return nil
|
||||
|
||||
@@ -202,6 +202,14 @@ func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.
|
||||
}
|
||||
|
||||
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
|
||||
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, true)
|
||||
}
|
||||
|
||||
func BuildWorksmobileUserPayloadForScopedDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
|
||||
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, false)
|
||||
}
|
||||
|
||||
func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap, includeFallbackTenant bool) (WorksmobileUserPayload, error) {
|
||||
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
@@ -211,7 +219,9 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
if tenantByID == nil {
|
||||
tenantByID = map[string]domain.Tenant{}
|
||||
}
|
||||
tenantByID[tenant.ID] = tenant
|
||||
if includeFallbackTenant {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
}
|
||||
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
@@ -253,7 +263,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
||||
if len(appointments) == 0 {
|
||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
||||
} else if !worksmobileAppointmentsContainTenant(appointments, tenant.ID) && !worksmobileAppointmentsHavePrimary(appointments) {
|
||||
} else if !worksmobileAppointmentsContainSyncableOrgUnit(appointments, tenantByID) && !worksmobileAppointmentsContainTenant(appointments, tenant.ID) {
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: tenant.ID,
|
||||
IsPrimary: true,
|
||||
@@ -284,6 +294,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantExcludedFromSync(appointmentTenant, tenantByID) {
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
}
|
||||
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
@@ -303,8 +317,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
|
||||
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
|
||||
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
|
||||
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||
orgUnit := WorksmobileUserOrgUnit{
|
||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||
@@ -361,6 +374,23 @@ func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment,
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainSyncableOrgUnit(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
|
||||
for _, appointment := range appointments {
|
||||
tenant, ok := tenantByID[strings.TrimSpace(appointment.TenantID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
|
||||
continue
|
||||
}
|
||||
if isWorksmobileDomainRootTenant(tenant) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
|
||||
for _, appointment := range appointments {
|
||||
if appointment.IsPrimary {
|
||||
@@ -376,6 +406,9 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
|
||||
continue
|
||||
}
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
|
||||
return true
|
||||
@@ -384,6 +417,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileTenantExcludedFromSync(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
||||
visited := map[string]bool{}
|
||||
current := tenant
|
||||
for {
|
||||
if WorksmobileExcluded(current.Config) {
|
||||
return true
|
||||
}
|
||||
parentID := worksmobileTenantParentID(current)
|
||||
if parentID == "" || visited[parentID] {
|
||||
return false
|
||||
}
|
||||
visited[parentID] = true
|
||||
parent, ok := tenantByID[parentID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileShouldSkipEmailDomainRootAppointment(appointment worksmobileAppointment, tenant domain.Tenant, appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
|
||||
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
|
||||
return false
|
||||
|
||||
@@ -315,19 +315,164 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Engineering", payload.Task)
|
||||
require.Equal(t, "PM", payload.Task)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
hanmacRootID := "11111111-1111-1111-1111-111111111111"
|
||||
samanRootID := "22222222-2222-2222-2222-222222222222"
|
||||
firstTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
secondTenantID := "44444444-4444-4444-4444-444444444444"
|
||||
user := domain.User{
|
||||
ID: "55555555-5555-5555-5555-555555555555",
|
||||
Email: "first-order@samaneng.com",
|
||||
Name: "First Order User",
|
||||
TenantID: &secondTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": firstTenantID,
|
||||
"isPrimary": false,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
hanmacRoot := domain.Tenant{
|
||||
ID: hanmacRootID,
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
|
||||
}
|
||||
samanRoot := domain.Tenant{
|
||||
ID: samanRootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
firstTenant := domain.Tenant{
|
||||
ID: firstTenantID,
|
||||
Slug: "first-team",
|
||||
Name: "First Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &hanmacRootID,
|
||||
}
|
||||
secondTenant := domain.Tenant{
|
||||
ID: secondTenantID,
|
||||
Slug: "second-team",
|
||||
Name: "Second Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &samanRootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
secondTenant,
|
||||
map[string]domain.Tenant{
|
||||
hanmacRootID: hanmacRoot,
|
||||
samanRootID: samanRoot,
|
||||
firstTenantID: firstTenant,
|
||||
secondTenantID: secondTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadSkipsExcludedAppointmentWhenChoosingWorksmobilePrimary(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
excludedRootID := "11111111-1111-1111-1111-111111111111"
|
||||
includedRootID := "22222222-2222-2222-2222-222222222222"
|
||||
excludedTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
includedTenantID := "44444444-4444-4444-4444-444444444444"
|
||||
user := domain.User{
|
||||
ID: "55555555-5555-5555-5555-555555555555",
|
||||
Email: "skip-excluded@samaneng.com",
|
||||
Name: "Skip Excluded User",
|
||||
TenantID: &includedTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": excludedTenantID},
|
||||
map[string]any{"tenantId": includedTenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
excludedRoot := domain.Tenant{
|
||||
ID: excludedRootID,
|
||||
Slug: "hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
includedRoot := domain.Tenant{
|
||||
ID: includedRootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
excludedTenant := domain.Tenant{
|
||||
ID: excludedTenantID,
|
||||
Slug: "excluded-team",
|
||||
Name: "Excluded Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &excludedRootID,
|
||||
}
|
||||
includedTenant := domain.Tenant{
|
||||
ID: includedTenantID,
|
||||
Slug: "included-team",
|
||||
Name: "Included Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &includedRootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
includedTenant,
|
||||
map[string]domain.Tenant{
|
||||
excludedRootID: excludedRoot,
|
||||
includedRootID: includedRoot,
|
||||
excludedTenantID: excludedTenant,
|
||||
includedTenantID: includedTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.Equal(t, "externalKey:"+includedTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
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.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)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {
|
||||
|
||||
@@ -141,7 +141,11 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
}
|
||||
aliasEmails := append([]string(nil), payload.AliasEmails...)
|
||||
payload.AliasEmails = nil
|
||||
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
if stringValue(job.Payload[worksmobileProvisioningModeKey]) == worksmobileProvisioningUpdateOnly {
|
||||
if err := w.client.UpdateUserOnly(ctx, payload); err != nil {
|
||||
return fmt.Errorf("worksmobile user update failed: %w", err)
|
||||
}
|
||||
} else if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
return fmt.Errorf("worksmobile user upsert failed: %w", err)
|
||||
}
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
|
||||
@@ -16,14 +16,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
HanmacFamilyTenantSlug = "hanmac-family"
|
||||
worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||
HanmacFamilyTenantSlug = "hanmac-family"
|
||||
worksmobileExcludedConfigKey = "worksmobileExcluded"
|
||||
worksmobileIdentityMirrorVersion = "kratos-full-pagination-v1"
|
||||
worksmobileProvisioningModeKey = "provisioningMode"
|
||||
worksmobileProvisioningUpdateOnly = "update_only"
|
||||
)
|
||||
|
||||
type WorksmobileSyncer interface {
|
||||
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
|
||||
EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error
|
||||
EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error
|
||||
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
|
||||
}
|
||||
|
||||
@@ -103,55 +107,62 @@ type WorksmobileComparison struct {
|
||||
}
|
||||
|
||||
type WorksmobileComparisonItem struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
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"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
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"`
|
||||
UpdateReasons []string `json:"updateReasons,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type worksmobileSyncService struct {
|
||||
tenantService TenantService
|
||||
userRepo repository.UserRepository
|
||||
outboxRepo repository.WorksmobileOutboxRepository
|
||||
client WorksmobileDirectoryClient
|
||||
tenantService TenantService
|
||||
userRepo repository.UserRepository
|
||||
outboxRepo repository.WorksmobileOutboxRepository
|
||||
client WorksmobileDirectoryClient
|
||||
identityMirror WorksmobileIdentityMirror
|
||||
}
|
||||
|
||||
type WorksmobileIdentityMirror interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error)
|
||||
}
|
||||
|
||||
func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService {
|
||||
@@ -163,6 +174,13 @@ func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.
|
||||
}
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMirror) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.identityMirror = source
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
|
||||
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -344,7 +362,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||
users, err := s.comparisonUsers(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
return WorksmobileComparison{}, err
|
||||
}
|
||||
@@ -360,11 +378,96 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
||||
|
||||
return WorksmobileComparison{
|
||||
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
|
||||
Users: compareWorksmobileUsersWithRemoteGroups(users, remoteUsers, includeMatched, tenantByID, remoteGroups, worksmobileUserJobSummaries(recentJobs)),
|
||||
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
if s.identityMirror != nil {
|
||||
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
|
||||
if err == nil &&
|
||||
status.RedisReady &&
|
||||
status.Status == "ready" &&
|
||||
status.MirrorVersion == worksmobileIdentityMirrorVersion {
|
||||
identities, err := s.identityMirror.ListIdentityMirrors(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
|
||||
}
|
||||
}
|
||||
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||
}
|
||||
|
||||
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
|
||||
allowed := make(map[string]bool, len(tenantIDs))
|
||||
for _, tenantID := range tenantIDs {
|
||||
allowed[strings.TrimSpace(tenantID)] = true
|
||||
}
|
||||
users := make([]domain.User, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
tenantID := traitString(identity.Traits, "tenant_id")
|
||||
if tenantID == "" || !allowed[tenantID] {
|
||||
continue
|
||||
}
|
||||
user := worksmobileUserFromIdentity(identity)
|
||||
users = append(users, user)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
|
||||
metadata := domain.JSONMap{}
|
||||
for key, value := range identity.Traits {
|
||||
metadata[key] = value
|
||||
}
|
||||
tenantID := traitString(identity.Traits, "tenant_id")
|
||||
status := domain.UserStatusActive
|
||||
if identity.State == "inactive" {
|
||||
status = domain.UserStatusArchived
|
||||
}
|
||||
if traitStatus := traitString(identity.Traits, "status"); traitStatus != "" {
|
||||
status = domain.NormalizeUserStatus(traitStatus)
|
||||
}
|
||||
user := domain.User{
|
||||
ID: strings.TrimSpace(identity.ID),
|
||||
Email: traitString(identity.Traits, "email"),
|
||||
Name: traitString(identity.Traits, "name"),
|
||||
Phone: traitString(identity.Traits, "phone_number"),
|
||||
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
|
||||
AffiliationType: traitString(identity.Traits, "affiliationType"),
|
||||
Department: traitString(identity.Traits, "department"),
|
||||
Grade: traitString(identity.Traits, "grade"),
|
||||
Position: traitString(identity.Traits, "position"),
|
||||
JobTitle: traitString(identity.Traits, "jobTitle"),
|
||||
Metadata: metadata,
|
||||
Status: status,
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}
|
||||
if tenantID != "" {
|
||||
user.TenantID = &tenantID
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func traitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := traits[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -545,35 +648,32 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||
}
|
||||
_, tenantInScope := tenantByID[tenant.ID]
|
||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
||||
}
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
*user,
|
||||
*tenant,
|
||||
tenantByID,
|
||||
root.Config,
|
||||
)
|
||||
if err != nil {
|
||||
err := errors.New("target user status is excluded from Worksmobile sync")
|
||||
if recordErr := s.recordRejectedUserSync(ctx, root.ID, *user, *tenant, err); recordErr != nil {
|
||||
return nil, errors.Join(err, recordErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
initialPassword = strings.TrimSpace(initialPassword)
|
||||
if initialPassword != "" {
|
||||
payload.PasswordConfig = WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: initialPassword,
|
||||
}
|
||||
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
|
||||
if !tenantInScope {
|
||||
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
|
||||
}
|
||||
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action := WorksmobileUserStatusAction(user.Status)
|
||||
if action == domain.WorksmobileActionUpsert {
|
||||
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
@@ -594,10 +694,48 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, rootID string, user domain.User, tenant domain.Tenant, reason error) error {
|
||||
payload := WorksmobileUserPayload{
|
||||
Email: strings.TrimSpace(user.Email),
|
||||
UserExternalKey: user.ID,
|
||||
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
|
||||
CellPhone: domain.NormalizePhoneNumber(user.Phone),
|
||||
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
|
||||
Locale: "ko_KR",
|
||||
Task: strings.TrimSpace(user.JobTitle),
|
||||
}
|
||||
outboxPayload := worksmobileUserOutboxPayload(rootID, payload, user.Status)
|
||||
outboxPayload["displayName"] = strings.TrimSpace(user.Name)
|
||||
outboxPayload["primaryLeafOrgName"] = strings.TrimSpace(tenant.Name)
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: WorksmobileUserStatusAction(user.Status),
|
||||
DedupeKey: worksmobileUserSyncDedupeKey("rejected", user.ID),
|
||||
Payload: outboxPayload,
|
||||
Status: domain.WorksmobileOutboxStatusFailed,
|
||||
LastError: reason.Error(),
|
||||
}
|
||||
return s.outboxRepo.Create(ctx, item)
|
||||
}
|
||||
|
||||
func worksmobileUserSyncDedupeKey(action, userID string) string {
|
||||
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
|
||||
}
|
||||
|
||||
func worksmobileAdminInitialPasswordConfig(password string) WorksmobilePasswordConfig {
|
||||
password = strings.TrimSpace(password)
|
||||
if password == "" {
|
||||
password = GenerateWorksmobileInitialPassword()
|
||||
}
|
||||
changePasswordAtNextLogin := true
|
||||
return WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -629,10 +767,11 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
|
||||
return nil, err
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
|
||||
if _, ok := tenantByID[tenant.ID]; !ok {
|
||||
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
|
||||
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
|
||||
}
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
|
||||
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -848,6 +987,14 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
|
||||
return s.enqueueUserUpsertIfInScope(ctx, user, false)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
|
||||
return s.enqueueUserUpsertIfInScope(ctx, user, true)
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) enqueueUserUpsertIfInScope(ctx context.Context, user domain.User, updateOnly bool) error {
|
||||
if user.TenantID == nil || *user.TenantID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -887,12 +1034,19 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
action := WorksmobileUserStatusAction(user.Status)
|
||||
if action == domain.WorksmobileActionUpsert && !updateOnly {
|
||||
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig("")
|
||||
}
|
||||
outboxPayload := worksmobileUserOutboxPayload(root.ID, payload, user.Status)
|
||||
if action == domain.WorksmobileActionUpsert && updateOnly {
|
||||
outboxPayload[worksmobileProvisioningModeKey] = worksmobileProvisioningUpdateOnly
|
||||
}
|
||||
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: action,
|
||||
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
Payload: outboxPayload,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1429,10 +1583,15 @@ func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]wor
|
||||
}
|
||||
|
||||
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||
return compareWorksmobileUsersWithRemoteGroups(localUsers, remoteUsers, includeMatched, localTenants, nil, jobSummaries...)
|
||||
}
|
||||
|
||||
func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, remoteGroups []WorksmobileRemoteGroup, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
|
||||
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
|
||||
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
|
||||
jobSummaryByUserID = jobSummaries[0]
|
||||
}
|
||||
remoteOrgUnitByExternalID := worksmobileRemoteOrgUnitByExternalID(remoteGroups)
|
||||
remoteByExternalID := map[string]WorksmobileRemoteUser{}
|
||||
remoteByEmail := map[string]WorksmobileRemoteUser{}
|
||||
for _, remote := range remoteUsers {
|
||||
@@ -1462,7 +1621,11 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
if !matched {
|
||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||
}
|
||||
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote, localTenants)
|
||||
updateReasons := []string(nil)
|
||||
if matched {
|
||||
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
}
|
||||
needsUpdate := len(updateReasons) > 0
|
||||
if matched && !includeMatched && !needsUpdate {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
@@ -1491,6 +1654,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
item.Status = "matched"
|
||||
if needsUpdate {
|
||||
item.Status = "needs_update"
|
||||
item.UpdateReasons = updateReasons
|
||||
}
|
||||
item.WorksmobileID = remote.ID
|
||||
item.ExternalKey = remote.ExternalID
|
||||
@@ -1571,6 +1735,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemoteOrgUnitByExternalID(remoteGroups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup {
|
||||
result := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
|
||||
for _, remote := range remoteGroups {
|
||||
externalID := strings.TrimSpace(remote.ExternalID)
|
||||
if externalID == "" {
|
||||
continue
|
||||
}
|
||||
result[externalID] = remote
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
|
||||
return normalizeWorksmobileAccountStatus(
|
||||
remote.AccountStatus,
|
||||
@@ -1582,29 +1758,40 @@ func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
|
||||
)
|
||||
}
|
||||
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
return len(worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)) > 0
|
||||
}
|
||||
|
||||
func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []string {
|
||||
reasons := []string{}
|
||||
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||
return true
|
||||
reasons = append(reasons, "external_key")
|
||||
}
|
||||
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
|
||||
return true
|
||||
reasons = append(reasons, "name")
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||
return true
|
||||
reasons = append(reasons, "email")
|
||||
}
|
||||
if worksmobileUserPhoneNeedsUpdate(user, remote) {
|
||||
return true
|
||||
reasons = append(reasons, "phone")
|
||||
}
|
||||
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
|
||||
return true
|
||||
reasons = append(reasons, "employee_number")
|
||||
}
|
||||
return false
|
||||
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
|
||||
reasons = append(reasons, "organization")
|
||||
}
|
||||
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||
reasons = append(reasons, "manager")
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
|
||||
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
|
||||
if localPhone == "" && remotePhone == "" {
|
||||
if localPhone == "" {
|
||||
return false
|
||||
}
|
||||
if localPhone != remotePhone {
|
||||
@@ -1636,11 +1823,11 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
|
||||
return localEmployeeNumber != remoteEmployeeNumber
|
||||
}
|
||||
|
||||
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
||||
if len(remote.Organizations) == 0 || user.TenantID == nil || localTenants == nil {
|
||||
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
if localTenants == nil {
|
||||
return false
|
||||
}
|
||||
tenantID := strings.TrimSpace(*user.TenantID)
|
||||
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
|
||||
if tenantID == "" {
|
||||
return false
|
||||
}
|
||||
@@ -1648,11 +1835,34 @@ func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote Worksmobile
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||
if err != nil {
|
||||
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||
if err != nil || len(expectedOrganizations) == 0 {
|
||||
return worksmobileUserPrimaryOrganizationNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
}
|
||||
remoteOrganizations := remote.Organizations
|
||||
if len(remoteOrganizations) == 0 {
|
||||
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
|
||||
} else {
|
||||
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
|
||||
}
|
||||
return !worksmobileUserOrganizationsEqual(expectedOrganizations, remoteOrganizations)
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrganizationNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
tenantID := worksmobileUserComparisonPrimaryTenantID(user)
|
||||
if tenantID == "" {
|
||||
return false
|
||||
}
|
||||
return !worksmobileUserOrganizationsEqual(expected.Organizations, remote.Organizations)
|
||||
tenant, ok := localTenants[tenantID]
|
||||
if !ok || isWorksmobileDomainRootTenant(tenant) {
|
||||
return false
|
||||
}
|
||||
expectedPrimary := "externalKey:" + tenantID
|
||||
if remoteOrgUnit, ok := remoteOrgUnitByExternalID[tenantID]; ok && strings.TrimSpace(remoteOrgUnit.ID) != "" {
|
||||
expectedPrimary = strings.TrimSpace(remoteOrgUnit.ID)
|
||||
}
|
||||
remotePrimaryOrgUnits := worksmobileRemotePrimaryOrgUnitIDs(remote)
|
||||
return !worksmobileOrgUnitIDContains(remotePrimaryOrgUnits, expectedPrimary)
|
||||
}
|
||||
|
||||
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
|
||||
@@ -1664,9 +1874,131 @@ func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) doma
|
||||
return nil
|
||||
}
|
||||
|
||||
func worksmobileUserComparisonPrimaryTenantID(user domain.User) string {
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.TenantID) != "" {
|
||||
return strings.TrimSpace(appointment.TenantID)
|
||||
}
|
||||
}
|
||||
if user.TenantID == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*user.TenantID)
|
||||
}
|
||||
|
||||
func worksmobileUserComparisonTenantID(user domain.User, localTenants map[string]domain.Tenant) string {
|
||||
if user.TenantID != nil {
|
||||
tenantID := strings.TrimSpace(*user.TenantID)
|
||||
if _, ok := localTenants[tenantID]; ok {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
tenantID := strings.TrimSpace(appointment.TenantID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := localTenants[tenantID]; ok {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
|
||||
if strings.TrimSpace(remote.PrimaryOrgUnitID) == "" {
|
||||
return nil
|
||||
}
|
||||
return []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: remote.DomainID,
|
||||
Email: strings.TrimSpace(remote.Email),
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
|
||||
Primary: true,
|
||||
PositionID: strings.TrimSpace(remote.PrimaryOrgUnitPositionID),
|
||||
IsManager: remote.PrimaryOrgUnitIsManager,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileRemoteUserOrganizationsForCompare(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
|
||||
if len(remote.Organizations) == 0 {
|
||||
return nil
|
||||
}
|
||||
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
|
||||
result := make([]WorksmobileUserOrganization, len(remote.Organizations))
|
||||
for i, organization := range remote.Organizations {
|
||||
result[i] = organization
|
||||
if result[i].DomainID == 0 {
|
||||
result[i].DomainID = remote.DomainID
|
||||
}
|
||||
result[i].OrgUnits = make([]WorksmobileUserOrgUnit, len(organization.OrgUnits))
|
||||
copy(result[i].OrgUnits, organization.OrgUnits)
|
||||
for j, orgUnit := range result[i].OrgUnits {
|
||||
result[i].OrgUnits[j].OrgUnitID = worksmobileCanonicalRemoteOrgUnitID(orgUnit.OrgUnitID, remoteOrgUnitByExternalID)
|
||||
}
|
||||
if primaryOrgUnitID == "" {
|
||||
continue
|
||||
}
|
||||
for j, orgUnit := range result[i].OrgUnits {
|
||||
if strings.TrimSpace(orgUnit.OrgUnitID) != worksmobileCanonicalRemoteOrgUnitID(primaryOrgUnitID, remoteOrgUnitByExternalID) {
|
||||
continue
|
||||
}
|
||||
result[i].Primary = true
|
||||
result[i].OrgUnits[j].Primary = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileCanonicalRemoteOrgUnitID(orgUnitID string, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
|
||||
orgUnitID = strings.TrimSpace(orgUnitID)
|
||||
if orgUnitID == "" || strings.HasPrefix(orgUnitID, "externalKey:") {
|
||||
return orgUnitID
|
||||
}
|
||||
for externalID, remoteOrgUnit := range remoteOrgUnitByExternalID {
|
||||
if strings.TrimSpace(remoteOrgUnit.ID) == orgUnitID && strings.TrimSpace(externalID) != "" {
|
||||
return "externalKey:" + strings.TrimSpace(externalID)
|
||||
}
|
||||
}
|
||||
return orgUnitID
|
||||
}
|
||||
|
||||
func worksmobileRemotePrimaryOrgUnitIDs(remote WorksmobileRemoteUser) []string {
|
||||
result := make([]string, 0, 1)
|
||||
for _, organization := range remote.Organizations {
|
||||
if !organization.Primary {
|
||||
continue
|
||||
}
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
if orgUnit.Primary {
|
||||
result = append(result, orgUnit.OrgUnitID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result) == 0 && strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
|
||||
result = append(result, remote.PrimaryOrgUnitID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitIDContains(values []string, expected string) bool {
|
||||
expected = strings.TrimSpace(expected)
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type worksmobileComparableOrgUnit struct {
|
||||
organizationPrimary bool
|
||||
organizationEmail string
|
||||
unitPrimary bool
|
||||
positionID string
|
||||
comparePosition bool
|
||||
@@ -1688,9 +2020,6 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
|
||||
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
|
||||
return false
|
||||
}
|
||||
if strings.ToLower(expectedUnit.organizationEmail) != strings.ToLower(remoteUnit.organizationEmail) {
|
||||
return false
|
||||
}
|
||||
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
|
||||
return false
|
||||
}
|
||||
@@ -1704,6 +2033,23 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
|
||||
return true
|
||||
}
|
||||
|
||||
func worksmobilePrimaryOrgUnitCompareKey(organizations []WorksmobileUserOrganization) string {
|
||||
for _, organization := range organizations {
|
||||
if !organization.Primary {
|
||||
continue
|
||||
}
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
if !orgUnit.Primary {
|
||||
continue
|
||||
}
|
||||
if key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID); key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
|
||||
result := map[string]worksmobileComparableOrgUnit{}
|
||||
for _, organization := range organizations {
|
||||
@@ -1714,7 +2060,6 @@ func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUser
|
||||
}
|
||||
result[key] = worksmobileComparableOrgUnit{
|
||||
organizationPrimary: organization.Primary,
|
||||
organizationEmail: strings.TrimSpace(organization.Email),
|
||||
unitPrimary: orgUnit.Primary,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
|
||||
@@ -1736,7 +2081,6 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
|
||||
}
|
||||
result[key] = worksmobileComparableOrgUnit{
|
||||
organizationPrimary: organization.Primary,
|
||||
organizationEmail: strings.TrimSpace(organization.Email),
|
||||
unitPrimary: orgUnit.Primary,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
manager: orgUnit.IsManager,
|
||||
@@ -1766,22 +2110,43 @@ func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemot
|
||||
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
|
||||
remoteManagers := worksmobileRemoteOrgUnitManagerMap(remote)
|
||||
for localOrgUnitID, localManager := range localManagers {
|
||||
remoteManager := false
|
||||
if value, ok := remoteManagers[localOrgUnitID]; ok && value != nil {
|
||||
remoteManager = *value
|
||||
}
|
||||
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
|
||||
if ok && localManager != *remoteManager {
|
||||
if localManager != remoteManager {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileRemoteOrgUnitManagerMap(remote WorksmobileRemoteUser) map[string]*bool {
|
||||
result := map[string]*bool{}
|
||||
for orgUnitID, isManager := range remote.OrgUnitManagers {
|
||||
normalized := worksmobileOrgUnitLocalExternalKey(orgUnitID)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
result[normalized] = isManager
|
||||
}
|
||||
for _, organization := range remote.Organizations {
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
normalized := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
result[normalized] = orgUnit.IsManager
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
|
||||
result[worksmobileOrgUnitLocalExternalKey(remote.PrimaryOrgUnitID)] = remote.PrimaryOrgUnitIsManager
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
|
||||
managers := map[string]bool{}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
|
||||
@@ -160,9 +160,11 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
|
||||
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) {
|
||||
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
@@ -198,10 +200,14 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
|
||||
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
|
||||
require.NotEmpty(t, initialPassword)
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, request.PasswordConfig.Password)
|
||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||
require.Equal(t, initialPassword, request.PasswordConfig.Password)
|
||||
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
|
||||
@@ -286,6 +292,48 @@ func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *te
|
||||
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceUserUpdateIsUpdateOnlyWithoutInitialPassword(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,
|
||||
)
|
||||
|
||||
require.NoError(t, service.EnqueueUserUpdateIfInScope(context.Background(), target))
|
||||
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, "update_only", outboxRepo.created[0].Payload["provisioningMode"])
|
||||
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.True(t, request.PasswordConfig.IsZero())
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
@@ -333,6 +381,49 @@ func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testi
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServicePasswordResetAllowsExcludedPrimaryTenant(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
excludedOrgID := "excluded-org"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
excludedOrg := domain.Tenant{
|
||||
ID: excludedOrgID,
|
||||
Slug: "excluded-team",
|
||||
Name: "Excluded Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
Config: domain.JSONMap{"worksmobileExcluded": true},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &excludedOrgID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, excludedOrgID: excludedOrg}, list: []domain.Tenant{root, excludedOrg}},
|
||||
&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, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
root := domain.Tenant{
|
||||
@@ -1650,6 +1741,69 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
|
||||
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceGetComparisonUsesIdentityMirrorWhenReady(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
company := domain.Tenant{
|
||||
ID: companyID,
|
||||
Name: "계열사",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
userRepo := &fakeWorksmobileUserRepo{byTenant: []domain.User{{
|
||||
ID: "local-only-user",
|
||||
Email: "local-only@example.com",
|
||||
Name: "Local Only",
|
||||
TenantID: &companyID,
|
||||
Status: domain.UserStatusActive,
|
||||
}}}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
|
||||
userRepo,
|
||||
&fakeWorksmobileOutboxRepo{},
|
||||
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
|
||||
ID: "works-user",
|
||||
ExternalID: "mirror-user",
|
||||
Email: "mirror@example.com",
|
||||
DisplayName: "Mirror User",
|
||||
PrimaryOrgUnitID: "externalKey:" + companyID,
|
||||
}}},
|
||||
)
|
||||
service.SetIdentityMirror(&fakeWorksmobileIdentityMirror{
|
||||
status: domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
MirrorVersion: "kratos-full-pagination-v1",
|
||||
ObservedCount: 1,
|
||||
},
|
||||
identities: []KratosIdentity{{
|
||||
ID: "mirror-user",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "mirror@example.com",
|
||||
"name": "Mirror User",
|
||||
"tenant_id": companyID,
|
||||
"role": domain.RoleUser,
|
||||
"affiliationType": "internal",
|
||||
},
|
||||
}},
|
||||
})
|
||||
|
||||
comparison, err := service.GetComparison(context.Background(), rootID, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, userRepo.requestedTenantIDs)
|
||||
require.Len(t, comparison.Users, 1)
|
||||
require.Equal(t, "matched", comparison.Users[0].Status)
|
||||
require.Equal(t, "mirror-user", comparison.Users[0].BaronID)
|
||||
require.Equal(t, "mirror-user", comparison.Users[0].ExternalKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
@@ -1898,6 +2052,7 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
excludedCompanyID := "excluded-company"
|
||||
excludedOrgID := "excluded-org"
|
||||
@@ -1944,12 +2099,26 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
|
||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "", "")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.ErrorContains(t, err, "excluded from Worksmobile sync")
|
||||
require.Empty(t, outboxRepo.created)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileResourceUser, outboxRepo.created[0].ResourceType)
|
||||
require.Equal(t, user.ID, outboxRepo.created[0].ResourceID)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Empty(t, outboxRepo.created[0].Status)
|
||||
require.Empty(t, outboxRepo.created[0].LastError)
|
||||
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
|
||||
require.Equal(t, user.Email, outboxRepo.created[0].Payload["loginEmail"])
|
||||
require.Equal(t, user.Name, outboxRepo.created[0].Payload["displayName"])
|
||||
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, request.Organizations)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-manager",
|
||||
@@ -1977,20 +2146,37 @@ func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||
PrimaryOrgUnitIsManager: &remoteManager,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
IsManager: &remoteManager,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
|
||||
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
||||
primaryTenantID := "tenant-company"
|
||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-saman"
|
||||
primaryTenantID := "tenant-primary"
|
||||
secondaryTenantID := "tenant-secondary"
|
||||
user := domain.User{
|
||||
ID: "user-secondary-manager",
|
||||
Email: "secondary-manager@samaneng.com",
|
||||
@@ -2026,19 +2212,39 @@ func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
||||
"externalKey:" + primaryTenantID: &remotePrimaryManager,
|
||||
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
|
||||
},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + primaryTenantID,
|
||||
Primary: true,
|
||||
IsManager: &remotePrimaryManager,
|
||||
},
|
||||
{
|
||||
OrgUnitID: "externalKey:" + secondaryTenantID,
|
||||
Primary: false,
|
||||
IsManager: &remoteSecondaryManager,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
|
||||
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
primaryTenantID: {ID: primaryTenantID, Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
@@ -2097,10 +2303,336 @@ func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-email",
|
||||
Email: "org-email@samaneng.com",
|
||||
Name: "Org Email User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-email",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-domain",
|
||||
Email: "org-domain@samaneng.com",
|
||||
Name: "Org Domain User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-domain",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersUsesRemotePrimaryOrgUnitWhenOrganizationPrimaryFlagsAreMissing(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-primary",
|
||||
Email: "org-primary@samaneng.com",
|
||||
Name: "Org Primary User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-primary",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
PrimaryOrgUnitID: "works-leaf",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "works-leaf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-leaf",
|
||||
ExternalID: tenantID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-org-external-key",
|
||||
Email: "org-external-key@samaneng.com",
|
||||
Name: "Org External Key User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{"tenantId": tenantID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-org-external-key",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
PrimaryOrgUnitID: "works-leaf",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + tenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-leaf",
|
||||
ExternalID: tenantID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
peopleGrowthID := "tenant-people-growth"
|
||||
user := domain.User{
|
||||
ID: "user-people-growth",
|
||||
Email: "people-growth@baroncs.co.kr",
|
||||
Name: "People Growth User",
|
||||
TenantID: &peopleGrowthID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-people-growth",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1003,
|
||||
PrimaryOrgUnitID: "externalKey:another-team",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:another-team",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Equal(t, peopleGrowthID, items[0].BaronPrimaryOrgID)
|
||||
require.Equal(t, "externalKey:another-team", items[0].WorksmobilePrimaryOrgID)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMatchesPrimaryOrganizationByWorksmobileResourceID(t *testing.T) {
|
||||
peopleGrowthID := "tenant-people-growth"
|
||||
user := domain.User{
|
||||
ID: "user-people-growth",
|
||||
Email: "people-growth@baroncs.co.kr",
|
||||
Name: "People Growth User",
|
||||
TenantID: &peopleGrowthID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-people-growth",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "works-current-people-growth",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-current-people-growth",
|
||||
ExternalID: peopleGrowthID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksStalePrimaryOrganizationResourceIDNeedsUpdate(t *testing.T) {
|
||||
peopleGrowthID := "tenant-people-growth"
|
||||
user := domain.User{
|
||||
ID: "user-people-growth",
|
||||
Email: "people-growth@baroncs.co.kr",
|
||||
Name: "People Growth User",
|
||||
TenantID: &peopleGrowthID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-people-growth",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "works-deleted-people-growth",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-current-people-growth",
|
||||
ExternalID: peopleGrowthID,
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-saman"
|
||||
user := domain.User{
|
||||
@@ -2167,6 +2699,63 @@ func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *te
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresRemotePhoneWhenBaronPhoneIsEmpty(t *testing.T) {
|
||||
tenantID := "tenant-halla"
|
||||
user := domain.User{
|
||||
ID: "edb8e4f6-3dfd-44d4-a8aa-87332f8b2b38",
|
||||
Email: "cyhan4@hallasanup.com",
|
||||
Name: "네이버웍스관리자",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "fe9449d1-1671-44e4-1848-033779dddbaf",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
CellPhone: "+82 01041585840",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "한라산업개발", Type: domain.TenantTypeCompany},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersTreatsSpacedKoreanCountryCodePhoneAsMatched(t *testing.T) {
|
||||
tenantID := "tenant-saman"
|
||||
user := domain.User{
|
||||
ID: "user-phone-spaced",
|
||||
Email: "phone-spaced@samaneng.com",
|
||||
Name: "Phone Spaced User",
|
||||
Phone: "+821041585840",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-phone-spaced",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
CellPhone: "+82 1041585840",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
type fakeWorksmobileTenantService struct {
|
||||
tenants map[string]domain.Tenant
|
||||
list []domain.Tenant
|
||||
@@ -2229,6 +2818,19 @@ type fakeWorksmobileUserRepo struct {
|
||||
requestedTenantIDs []string
|
||||
}
|
||||
|
||||
type fakeWorksmobileIdentityMirror struct {
|
||||
status domain.IdentityCacheStatus
|
||||
identities []KratosIdentity
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileIdentityMirror) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileIdentityMirror) ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error) {
|
||||
return f.identities, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
|
||||
Reference in New Issue
Block a user