forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -68,27 +69,76 @@ func NewKratosAdminService() KratosAdminService {
|
||||
|
||||
func (s *kratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||
return nil, err
|
||||
pageToken := ""
|
||||
seenTokens := make(map[string]bool)
|
||||
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := req.URL.Query()
|
||||
query.Set("page_size", "250")
|
||||
if pageToken != "" {
|
||||
query.Set("page_token", pageToken)
|
||||
}
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var page []KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
identities = append(identities, page...)
|
||||
|
||||
nextToken := kratosNextPageToken(resp.Header.Values("Link"))
|
||||
if nextToken == "" {
|
||||
return identities, nil
|
||||
}
|
||||
if seenTokens[nextToken] {
|
||||
return nil, fmt.Errorf("kratos admin list identities pagination loop detected page_token=%s", nextToken)
|
||||
}
|
||||
seenTokens[nextToken] = true
|
||||
pageToken = nextToken
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func kratosNextPageToken(linkHeaders []string) string {
|
||||
for _, header := range linkHeaders {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if !strings.Contains(part, `rel="next"`) && !strings.Contains(part, `rel=next`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(part, "<")
|
||||
end := strings.Index(part, ">")
|
||||
if start < 0 || end <= start+1 {
|
||||
continue
|
||||
}
|
||||
rawURL := part[start+1 : end]
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if token := strings.TrimSpace(parsed.Query().Get("page_token")); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
|
||||
63
backend/internal/service/kratos_admin_service_test.go
Normal file
63
backend/internal/service/kratos_admin_service_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestKratosAdminService_ListIdentitiesFollowsNextPagination(t *testing.T) {
|
||||
var requestedTokens []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "/admin/identities", r.URL.Path)
|
||||
token := r.URL.Query().Get("page_token")
|
||||
requestedTokens = append(requestedTokens, token)
|
||||
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Type", "application/json")
|
||||
status := http.StatusOK
|
||||
body := "[]"
|
||||
switch token {
|
||||
case "":
|
||||
header.Set(
|
||||
"Link",
|
||||
`</admin/identities?page_size=2&page_token=identity-2>; rel="next"`,
|
||||
)
|
||||
body = `[{"id":"identity-1","traits":{"email":"one@example.com"}},{"id":"identity-2","traits":{"email":"two@example.com"}}]`
|
||||
case "identity-2":
|
||||
body = `[{"id":"identity-3","traits":{"email":"three@example.com"}}]`
|
||||
default:
|
||||
t.Fatalf("unexpected page_token %q", token)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
service := &kratosAdminService{
|
||||
AdminURL: "http://kratos.example",
|
||||
HTTPClient: client,
|
||||
}
|
||||
|
||||
identities, err := service.ListIdentities(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"", "identity-2"}, requestedTokens)
|
||||
require.Len(t, identities, 3)
|
||||
require.Equal(t, "identity-1", identities[0].ID)
|
||||
require.Equal(t, "identity-2", identities[1].ID)
|
||||
require.Equal(t, "identity-3", identities[2].ID)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +16,14 @@ type RedisService struct {
|
||||
Client *redis.Client
|
||||
}
|
||||
|
||||
type identityMirrorStateStore struct {
|
||||
Status string `json:"status"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
ObservedCount int64 `json:"observedCount,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// NewRedisService creates and returns a new RedisService
|
||||
func NewRedisService() (*RedisService, error) {
|
||||
redisAddr := os.Getenv("REDIS_ADDR")
|
||||
@@ -90,3 +100,139 @@ func (s *RedisService) Get(key string) (string, error) {
|
||||
func (s *RedisService) Delete(key string) error {
|
||||
return s.Client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "unavailable",
|
||||
RedisReady: false,
|
||||
LastError: "redis service unavailable",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.Client.Ping(ctx).Err(); err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: false,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
keyCount, err := s.countIdentityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: true,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
raw, err := s.Client.Get(ctx, "identity:mirror:state").Result()
|
||||
if err == redis.Nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "empty",
|
||||
RedisReady: true,
|
||||
KeyCount: keyCount,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: true,
|
||||
KeyCount: keyCount,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var stored identityMirrorStateStore
|
||||
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: "failed",
|
||||
RedisReady: true,
|
||||
KeyCount: keyCount,
|
||||
LastError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
status := stored.Status
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: status,
|
||||
RedisReady: true,
|
||||
ObservedCount: stored.ObservedCount,
|
||||
KeyCount: keyCount,
|
||||
LastRefreshedAt: stored.LastRefreshedAt,
|
||||
LastError: stored.LastError,
|
||||
UpdatedAt: stored.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return domain.IdentityCacheFlushResult{}, os.ErrInvalid
|
||||
}
|
||||
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return domain.IdentityCacheFlushResult{}, err
|
||||
}
|
||||
var deleted int64
|
||||
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 domain.IdentityCacheFlushResult{}, err
|
||||
}
|
||||
deleted += count
|
||||
keys = keys[chunkSize:]
|
||||
}
|
||||
|
||||
return domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: deleted,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(keys)), nil
|
||||
}
|
||||
|
||||
func (s *RedisService) identityCacheKeys(ctx context.Context) ([]string, error) {
|
||||
seen := make(map[string]bool)
|
||||
patterns := []string{
|
||||
"identity:mirror:*",
|
||||
"identity:index:*",
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
seen[key] = true
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(seen))
|
||||
for key := range seen {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
150
backend/internal/service/redis_service_test.go
Normal file
150
backend/internal/service/redis_service_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type redisCommandStub struct {
|
||||
scans map[string][]string
|
||||
stateValue string
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) BeforeProcess(ctx context.Context, cmd redis.Cmder) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) AfterProcess(ctx context.Context, cmd redis.Cmder) error {
|
||||
switch cmd.Name() {
|
||||
case "ping":
|
||||
if status, ok := cmd.(*redis.StatusCmd); ok {
|
||||
status.SetVal("PONG")
|
||||
}
|
||||
case "scan":
|
||||
if scan, ok := cmd.(*redis.ScanCmd); ok {
|
||||
scan.SetVal(h.scans[scanPattern(cmd.Args())], 0)
|
||||
}
|
||||
case "get":
|
||||
if str, ok := cmd.(*redis.StringCmd); ok {
|
||||
if h.stateValue == "" {
|
||||
str.SetErr(redis.Nil)
|
||||
return nil
|
||||
}
|
||||
str.SetVal(h.stateValue)
|
||||
}
|
||||
case "del":
|
||||
args := cmd.Args()
|
||||
keys := make([]string, 0, len(args)-1)
|
||||
for _, arg := range args[1:] {
|
||||
keys = append(keys, arg.(string))
|
||||
}
|
||||
h.deleted = append(h.deleted, keys...)
|
||||
if count, ok := cmd.(*redis.IntCmd); ok {
|
||||
count.SetVal(int64(len(keys)))
|
||||
}
|
||||
}
|
||||
cmd.SetErr(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) BeforeProcessPipeline(ctx context.Context, cmds []redis.Cmder) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (h *redisCommandStub) AfterProcessPipeline(ctx context.Context, cmds []redis.Cmder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanPattern(args []interface{}) string {
|
||||
for index := 0; index < len(args)-1; index++ {
|
||||
value, ok := args[index].(string)
|
||||
if ok && value == "match" {
|
||||
if pattern, ok := args[index+1].(string); ok {
|
||||
return pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newStubbedRedisService(stub *redisCommandStub) *RedisService {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:1",
|
||||
MaxRetries: -1,
|
||||
})
|
||||
client.AddHook(stub)
|
||||
return &RedisService{Client: client}
|
||||
}
|
||||
|
||||
func TestRedisServiceGetIdentityCacheStatusReadsStateAndCountsCacheKeys(t *testing.T) {
|
||||
now := time.Date(2026, 6, 9, 3, 20, 0, 0, time.UTC)
|
||||
state, err := json.Marshal(identityMirrorStateStore{
|
||||
Status: "ready",
|
||||
LastRefreshedAt: &now,
|
||||
ObservedCount: 42,
|
||||
UpdatedAt: &now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stub := &redisCommandStub{
|
||||
stateValue: string(state),
|
||||
scans: map[string][]string{
|
||||
"identity:mirror:*": {"identity:mirror:state", "identity:mirror:user:1"},
|
||||
"identity:index:*": {"identity:index:email:a", "identity:mirror:user:1"},
|
||||
},
|
||||
}
|
||||
service := newStubbedRedisService(stub)
|
||||
|
||||
status, err := service.GetIdentityCacheStatus(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ready", status.Status)
|
||||
require.True(t, status.RedisReady)
|
||||
require.Equal(t, int64(42), status.ObservedCount)
|
||||
require.Equal(t, int64(3), status.KeyCount)
|
||||
require.Equal(t, &now, status.LastRefreshedAt)
|
||||
require.Equal(t, &now, status.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *testing.T) {
|
||||
stub := &redisCommandStub{
|
||||
scans: map[string][]string{
|
||||
"identity:mirror:*": {"identity:mirror:state", "identity:mirror:user:1"},
|
||||
"identity:index:*": {"identity:index:email:a", "identity:mirror:user:1"},
|
||||
},
|
||||
}
|
||||
service := newStubbedRedisService(stub)
|
||||
|
||||
result, err := service.FlushIdentityCache(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "success", result.Status)
|
||||
require.Equal(t, int64(3), result.FlushedKeys)
|
||||
require.ElementsMatch(t, []string{
|
||||
"identity:mirror:state",
|
||||
"identity:mirror:user:1",
|
||||
"identity:index:email:a",
|
||||
}, stub.deleted)
|
||||
}
|
||||
|
||||
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
|
||||
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "unavailable", status.Status)
|
||||
require.False(t, status.RedisReady)
|
||||
require.NotEmpty(t, status.LastError)
|
||||
}
|
||||
|
||||
func TestRedisServiceFlushIdentityCacheFailsWithoutClient(t *testing.T) {
|
||||
_, err := (*RedisService)(nil).FlushIdentityCache(context.Background())
|
||||
|
||||
require.ErrorIs(t, err, os.ErrInvalid)
|
||||
}
|
||||
@@ -222,44 +222,19 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID)
|
||||
}
|
||||
|
||||
var updatedIdentity *KratosIdentity
|
||||
|
||||
// [Fix] Sync Kratos Traits & Local DB when a user is added to an organization
|
||||
if s.kratos != nil && tenant != nil {
|
||||
// Fetch Kratos Identity
|
||||
identity, err := s.kratos.GetIdentity(ctx, userID)
|
||||
if err == nil && identity != nil {
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = make(map[string]any)
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
traits["tenant_id"] = tenant.ID
|
||||
traits["department"] = group.Name
|
||||
|
||||
// Update Kratos
|
||||
updated, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
|
||||
if updateErr != nil {
|
||||
slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr)
|
||||
} else if updated != nil {
|
||||
updatedIdentity = updated
|
||||
} else {
|
||||
identity.Traits = traits
|
||||
updatedIdentity = identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local user repo
|
||||
// Kratos는 identity SSOT이고 조직/부서 정보의 원장이 아니므로 AddMember에서 traits를 수정하지 않습니다.
|
||||
if s.userRepo != nil && tenant != nil {
|
||||
localUser, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil || localUser == nil {
|
||||
if updatedIdentity != nil {
|
||||
localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity)
|
||||
if s.kratos != nil {
|
||||
identity, identityErr := s.kratos.GetIdentity(ctx, userID)
|
||||
if identityErr == nil && identity != nil {
|
||||
localUser = mapUserGroupKratosIdentityToLocalUser(*identity)
|
||||
} else {
|
||||
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", identityErr)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
|
||||
localUser = nil
|
||||
}
|
||||
}
|
||||
if localUser != nil {
|
||||
@@ -326,7 +301,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
||||
ID: identity.ID,
|
||||
Email: userGroupTraitString(traits, "email"),
|
||||
Name: userGroupTraitString(traits, "name"),
|
||||
Phone: userGroupTraitString(traits, "phone_number"),
|
||||
Phone: domain.NormalizePhoneNumber(userGroupTraitString(traits, "phone_number")),
|
||||
Role: role,
|
||||
Status: userGroupIdentityStatus(identity.State),
|
||||
Department: userGroupTraitString(traits, "department"),
|
||||
|
||||
@@ -272,14 +272,6 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil)
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
|
||||
|
||||
// Mock Kratos
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
|
||||
|
||||
// Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called)
|
||||
// mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
|
||||
// return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales"
|
||||
@@ -299,6 +291,8 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, userID)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
|
||||
// mockUserRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -326,19 +320,6 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
|
||||
_, hasCompanyCode := traits["companyCode"]
|
||||
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
|
||||
}), "active").Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "User Test",
|
||||
"tenant_id": tenantID,
|
||||
"department": "Sales",
|
||||
},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
@@ -356,6 +337,7 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
@@ -380,16 +362,6 @@ func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
Status: "active",
|
||||
}, nil)
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil)
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||
})).Return(nil).Once()
|
||||
@@ -407,6 +379,8 @@ func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
|
||||
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, userID)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
|
||||
|
||||
@@ -75,7 +75,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
ID: identity.ID,
|
||||
Email: kratosProjectionTraitString(traits, "email"),
|
||||
Name: kratosProjectionTraitString(traits, "name"),
|
||||
Phone: kratosProjectionTraitString(traits, "phone_number"),
|
||||
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
|
||||
Role: role,
|
||||
Status: normalizeProjectionStatus(identity.State),
|
||||
Department: kratosProjectionTraitString(traits, "department"),
|
||||
|
||||
@@ -28,6 +28,10 @@ func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants
|
||||
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
|
||||
@@ -79,6 +83,33 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
@@ -17,11 +18,13 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorksmobileOAuthScope = "directory"
|
||||
defaultWorksmobileOAuthScope = "directory"
|
||||
worksmobileAPIRateLimitPerMinute = 240
|
||||
)
|
||||
|
||||
type WorksmobileDirectoryClient interface {
|
||||
@@ -43,6 +46,7 @@ type WorksmobileHTTPClient struct {
|
||||
DirectoryToken string
|
||||
SCIMToken string
|
||||
HTTPClient *http.Client
|
||||
RateLimiter WorksmobileRateLimiter
|
||||
OAuthConfig WorksmobileOAuthConfig
|
||||
DomainIDs []int64
|
||||
OrgUnitWriteDelay time.Duration
|
||||
@@ -50,6 +54,16 @@ type WorksmobileHTTPClient struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type WorksmobileRateLimiter interface {
|
||||
Wait(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type worksmobileAPIRateLimiter struct {
|
||||
interval time.Duration
|
||||
mu sync.Mutex
|
||||
next map[string]time.Time
|
||||
}
|
||||
|
||||
type WorksmobileOAuthConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
@@ -64,6 +78,46 @@ type worksmobileAccessTokenCache struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewWorksmobileAPIRateLimiter(limit int, window time.Duration) WorksmobileRateLimiter {
|
||||
if limit <= 0 || window <= 0 {
|
||||
return &worksmobileAPIRateLimiter{}
|
||||
}
|
||||
return &worksmobileAPIRateLimiter{
|
||||
interval: window / time.Duration(limit),
|
||||
next: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *worksmobileAPIRateLimiter) Wait(ctx context.Context, key string) error {
|
||||
if l == nil || l.interval <= 0 {
|
||||
return nil
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
key = "UNKNOWN"
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
waitUntil := l.next[key]
|
||||
if waitUntil.Before(now) {
|
||||
waitUntil = now
|
||||
}
|
||||
l.next[key] = waitUntil.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
|
||||
if delay := time.Until(waitUntil); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig {
|
||||
c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`)
|
||||
c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`)
|
||||
@@ -280,7 +334,10 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload))
|
||||
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
|
||||
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -306,6 +363,23 @@ func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID st
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) RemoveUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("worksmobile alias email is required")
|
||||
}
|
||||
return c.sendDirectoryJSON(
|
||||
ctx,
|
||||
http.MethodDelete,
|
||||
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
password = strings.TrimSpace(password)
|
||||
@@ -315,15 +389,38 @@ func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID st
|
||||
if password == "" {
|
||||
return fmt.Errorf("worksmobile password is required")
|
||||
}
|
||||
changePasswordAtNextLogin := true
|
||||
payload := map[string]any{
|
||||
"passwordConfig": WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
|
||||
},
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) GetUser(ctx context.Context, userID string) (*WorksmobileRemoteUser, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
var response map[string]any
|
||||
if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(userID), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := parseWorksmobileDirectoryUser(response)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UndeleteUser(ctx context.Context, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users/"+url.PathEscape(userID)+"/undelete", nil)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
@@ -484,6 +581,9 @@ func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -512,6 +612,9 @@ func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path strin
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -665,6 +768,9 @@ func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context,
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
@@ -729,6 +835,9 @@ func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method st
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -801,6 +910,7 @@ type WorksmobileRemoteUser struct {
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
@@ -817,6 +927,10 @@ type WorksmobileRemoteUser struct {
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
IsAwaiting bool `json:"isAwaiting"`
|
||||
IsPending bool `json:"isPending"`
|
||||
IsSuspended bool `json:"isSuspended"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
|
||||
type WorksmobileRemoteGroup struct {
|
||||
@@ -852,18 +966,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(payload.CellPhone) != "" {
|
||||
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}}
|
||||
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: normalizeWorksmobileOutboundCellPhone(payload.CellPhone), Primary: true, Type: "mobile"}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWorksmobileOutboundCellPhone(value string) string {
|
||||
return domain.NormalizePhoneNumber(value)
|
||||
}
|
||||
|
||||
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
||||
return WorksmobileUserPatchPayload{
|
||||
DomainID: payload.DomainID,
|
||||
Email: strings.TrimSpace(payload.Email),
|
||||
UserExternalKey: strings.TrimSpace(payload.UserExternalKey),
|
||||
UserName: payload.UserName,
|
||||
CellPhone: strings.TrimSpace(payload.CellPhone),
|
||||
CellPhone: normalizeWorksmobileOutboundCellPhone(payload.CellPhone),
|
||||
EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber),
|
||||
AliasEmails: payload.AliasEmails,
|
||||
Locale: strings.TrimSpace(payload.Locale),
|
||||
@@ -937,6 +1055,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"),
|
||||
UserName: email,
|
||||
Email: email,
|
||||
AliasEmails: stringListFromMap(resource, "aliasEmails"),
|
||||
DisplayName: parseWorksmobileDirectoryUserName(resource),
|
||||
CellPhone: firstStringFromMap(resource, "cellPhone", "phoneNumber", "phone", "mobile", "mobilePhone"),
|
||||
EmployeeNumber: firstStringFromMap(
|
||||
@@ -954,6 +1073,10 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
if active, ok := resource["active"].(bool); ok {
|
||||
user.Active = active
|
||||
}
|
||||
user.IsAwaiting = boolFromMap(resource, "isAwaiting")
|
||||
user.IsPending = boolFromMap(resource, "isPending")
|
||||
user.IsSuspended = boolFromMap(resource, "isSuspended")
|
||||
user.IsDeleted = boolFromMap(resource, "isDeleted")
|
||||
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
|
||||
user.PrimaryOrgUnitID = primaryOrgUnit.ID
|
||||
user.PrimaryOrgUnitName = primaryOrgUnit.Name
|
||||
@@ -1285,6 +1408,25 @@ func firstStringFromMap(values map[string]any, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringListFromMap(values map[string]any, key string) []string {
|
||||
raw, ok := values[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
value, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boolFromMap(values map[string]any, key string) bool {
|
||||
value, _ := values[key].(bool)
|
||||
return value
|
||||
@@ -1324,6 +1466,42 @@ func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) {
|
||||
return strings.TrimRight(baseURL, "/") + path, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) waitForWorksmobileAPI(ctx context.Context, method string, requestURL *url.URL) error {
|
||||
if c.RateLimiter == nil {
|
||||
return nil
|
||||
}
|
||||
return c.RateLimiter.Wait(ctx, worksmobileRateLimitKey(method, requestURL))
|
||||
}
|
||||
|
||||
func worksmobileRateLimitKey(method string, requestURL *url.URL) string {
|
||||
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
|
||||
if normalizedMethod == "" {
|
||||
normalizedMethod = "GET"
|
||||
}
|
||||
return normalizedMethod + " " + normalizeWorksmobileRateLimitPath(requestURL)
|
||||
}
|
||||
|
||||
func normalizeWorksmobileRateLimitPath(requestURL *url.URL) string {
|
||||
if requestURL == nil {
|
||||
return "/"
|
||||
}
|
||||
path := requestURL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(segments) == 1 && segments[0] == "" {
|
||||
return "/"
|
||||
}
|
||||
for i := 1; i < len(segments); i++ {
|
||||
switch strings.ToLower(segments[i-1]) {
|
||||
case "users", "orgunits", "groups", "alias-emails":
|
||||
segments[i] = "{id}"
|
||||
}
|
||||
}
|
||||
return "/" + strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
|
||||
@@ -64,6 +64,28 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
||||
require.Len(t, passwordConfig["password"], 16)
|
||||
}
|
||||
|
||||
func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||
payload := NewWorksmobileUserPatchPayload(WorksmobileUserPayload{
|
||||
DomainID: 1001,
|
||||
Email: "phone-canonical@samaneng.com",
|
||||
CellPhone: "+82+821062836786",
|
||||
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
||||
})
|
||||
|
||||
require.Equal(t, "+821062836786", payload.CellPhone)
|
||||
}
|
||||
|
||||
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||
payload := NewWorksmobileSCIMUserPayload(WorksmobileUserPayload{
|
||||
Email: "phone-canonical@samaneng.com",
|
||||
CellPhone: "+82+821062836786",
|
||||
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
||||
})
|
||||
|
||||
require.Len(t, payload.PhoneNumbers, 1)
|
||||
require.Equal(t, "+821062836786", payload.PhoneNumbers[0].Value)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
@@ -155,6 +177,7 @@ func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(t *testing.
|
||||
passwordConfig := payload["passwordConfig"].(map[string]any)
|
||||
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
|
||||
require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"])
|
||||
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
|
||||
@@ -225,6 +248,84 @@ func TestWorksmobileHTTPClientRequestsJWTBearerAccessToken(t *testing.T) {
|
||||
require.Equal(t, float64(1710003600), payload["exp"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientAppliesRateLimitBeforeDirectoryAPICalls(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusCreated,
|
||||
body: `{}`,
|
||||
}
|
||||
limiter := &captureWorksmobileRateLimiter{}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
RateLimiter: limiter,
|
||||
}
|
||||
|
||||
err := client.CreateUser(context.Background(), WorksmobileUserPayload{
|
||||
Email: "tester@samaneng.com",
|
||||
PasswordConfig: WorksmobilePasswordConfig{PasswordCreationType: "ADMIN", Password: "Aa1!Aa1!Aa1!Aa1!"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"POST /v1.0/users"}, limiter.keys)
|
||||
require.Len(t, transport.requests, 1)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientAppliesRateLimitBeforeOAuthTokenCalls(t *testing.T) {
|
||||
privateKey := testRSAPrivateKeyPEM(t)
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"access_token":"directory-token-from-jwt","token_type":"Bearer","expires_in":3600}`,
|
||||
}
|
||||
limiter := &captureWorksmobileRateLimiter{}
|
||||
client := &WorksmobileHTTPClient{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
RateLimiter: limiter,
|
||||
now: func() time.Time { return time.Unix(1710000000, 0) },
|
||||
OAuthConfig: WorksmobileOAuthConfig{
|
||||
ClientID: "client-id-1",
|
||||
ClientSecret: "client-secret-1",
|
||||
ServiceAccount: "service-account-1",
|
||||
PrivateKey: privateKey,
|
||||
Scope: "directory",
|
||||
TokenURL: "https://auth.example.test/oauth2/v2.0/token",
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := client.requestDirectoryAccessToken(context.Background(), time.Unix(1710000000, 0))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"POST /oauth2/v2.0/token"}, limiter.keys)
|
||||
require.Len(t, transport.requests, 1)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientRateLimitKeyNormalizesResourceIDsAndDropsQuery(t *testing.T) {
|
||||
parsedURL, err := url.Parse("https://works.example.test/v1.0/users/user%40example.com/alias-emails/alias%40example.com?domainId=1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
"POST /v1.0/users/{id}/alias-emails/{id}",
|
||||
worksmobileRateLimitKey(http.MethodPost, parsedURL),
|
||||
)
|
||||
|
||||
parsedURL, err = url.Parse("https://works.example.test/scim/v2/Users/works-user-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "PATCH /scim/v2/Users/{id}", worksmobileRateLimitKey(http.MethodPatch, parsedURL))
|
||||
}
|
||||
|
||||
func TestNewWorksmobileHTTPClientDoesNotInstallRateLimiterByDefault(t *testing.T) {
|
||||
client := NewWorksmobileHTTPClientWithAuth("directory-token", "scim-token", WorksmobileOAuthConfig{})
|
||||
|
||||
require.Nil(t, client.RateLimiter)
|
||||
}
|
||||
|
||||
func TestNewWorksmobileAPIRateLimiterCreatesLimiterForWorkerUse(t *testing.T) {
|
||||
limiter := NewWorksmobileAPIRateLimiter(240, time.Minute)
|
||||
|
||||
require.NotNil(t, limiter)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientRequiresConfiguredAPIBaseURL(t *testing.T) {
|
||||
client := &WorksmobileHTTPClient{
|
||||
DirectoryToken: "directory-token-1",
|
||||
@@ -608,6 +709,18 @@ func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.
|
||||
require.Equal(t, []string{"tester@samaneng.com"}, client.suspendedUsers)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessOnceRecoversPanic(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{listReadyPanic: "list ready crashed"}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "worksmobile relay panic")
|
||||
require.Contains(t, err.Error(), "list ready crashed")
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
@@ -736,6 +849,65 @@ func TestWorksmobileRelayWorkerSkipsDispatchWhenJobClaimFails(t *testing.T) {
|
||||
require.Empty(t, client.createdOrgUnits)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerSkipsProcessingWhenLeaderLockIsNotHeld(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: "tester@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
worker.SetLeaderLock(&fakeWorksmobileRelayLeaderLock{held: false})
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, repo.listReadyCalls)
|
||||
require.Empty(t, repo.processingIDs)
|
||||
require.Empty(t, repo.processedIDs)
|
||||
require.Empty(t, client.createdUsers)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesWhenLeaderLockIsHeld(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: "tester@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
lock := &fakeWorksmobileRelayLeaderLock{held: true}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
worker.SetLeaderLock(lock)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, lock.ensureCalls)
|
||||
require.Equal(t, 1, repo.listReadyCalls)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
|
||||
jobs := []domain.WorksmobileOutbox{
|
||||
{
|
||||
@@ -1179,6 +1351,8 @@ type fakeWorksmobileOutboxRepo struct {
|
||||
payloadUpdates []domain.JSONMap
|
||||
deletedPendingTenantRootID string
|
||||
deletedPendingCount int
|
||||
listReadyCalls int
|
||||
listReadyPanic any
|
||||
markProcessingClaims map[string]bool
|
||||
processingIDs []string
|
||||
processedIDs []string
|
||||
@@ -1224,6 +1398,10 @@ func (f *fakeWorksmobileOutboxRepo) DeletePendingByTenantRoot(ctx context.Contex
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
|
||||
f.listReadyCalls++
|
||||
if f.listReadyPanic != nil {
|
||||
panic(f.listReadyPanic)
|
||||
}
|
||||
return f.ready, nil
|
||||
}
|
||||
|
||||
@@ -1282,6 +1460,25 @@ type captureResponse struct {
|
||||
body string
|
||||
}
|
||||
|
||||
type captureWorksmobileRateLimiter struct {
|
||||
keys []string
|
||||
}
|
||||
|
||||
func (l *captureWorksmobileRateLimiter) Wait(ctx context.Context, key string) error {
|
||||
l.keys = append(l.keys, key)
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
type fakeWorksmobileRelayLeaderLock struct {
|
||||
held bool
|
||||
ensureCalls int
|
||||
}
|
||||
|
||||
func (l *fakeWorksmobileRelayLeaderLock) EnsureLeadership(ctx context.Context) (bool, error) {
|
||||
l.ensureCalls++
|
||||
return l.held, ctx.Err()
|
||||
}
|
||||
|
||||
func (t *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.request = req
|
||||
t.requests = append(t.requests, req)
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
|
||||
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
|
||||
continue
|
||||
}
|
||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "")
|
||||
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, item)
|
||||
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
@@ -30,14 +31,14 @@ type WorksmobileOrgUnitPayload struct {
|
||||
type WorksmobileUserPayload struct {
|
||||
DomainID int64 `json:"domainId"`
|
||||
Email string `json:"email"`
|
||||
UserExternalKey string `json:"userExternalKey"`
|
||||
UserExternalKey string `json:"userExternalKey,omitempty"`
|
||||
UserName WorksmobileUserName `json:"userName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
PrivateEmail string `json:"privateEmail,omitempty"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
}
|
||||
@@ -47,8 +48,52 @@ type WorksmobileUserName struct {
|
||||
}
|
||||
|
||||
type WorksmobilePasswordConfig struct {
|
||||
PasswordCreationType string `json:"passwordCreationType"`
|
||||
Password string `json:"password"`
|
||||
PasswordCreationType string `json:"passwordCreationType"`
|
||||
Password string `json:"password"`
|
||||
ChangePasswordAtNextLogin *bool `json:"changePasswordAtNextLogin,omitempty"`
|
||||
}
|
||||
|
||||
func (c WorksmobilePasswordConfig) IsZero() bool {
|
||||
return strings.TrimSpace(c.PasswordCreationType) == "" &&
|
||||
strings.TrimSpace(c.Password) == "" &&
|
||||
c.ChangePasswordAtNextLogin == nil
|
||||
}
|
||||
|
||||
func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
|
||||
type payloadJSON struct {
|
||||
DomainID int64 `json:"domainId"`
|
||||
Email string `json:"email"`
|
||||
UserExternalKey string `json:"userExternalKey,omitempty"`
|
||||
UserName WorksmobileUserName `json:"userName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
PrivateEmail string `json:"privateEmail,omitempty"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
PasswordConfig *WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
}
|
||||
|
||||
var passwordConfig *WorksmobilePasswordConfig
|
||||
if !p.PasswordConfig.IsZero() {
|
||||
passwordConfig = &p.PasswordConfig
|
||||
}
|
||||
|
||||
return json.Marshal(payloadJSON{
|
||||
DomainID: p.DomainID,
|
||||
Email: p.Email,
|
||||
UserExternalKey: p.UserExternalKey,
|
||||
UserName: p.UserName,
|
||||
CellPhone: p.CellPhone,
|
||||
EmployeeNumber: p.EmployeeNumber,
|
||||
PrivateEmail: p.PrivateEmail,
|
||||
AliasEmails: p.AliasEmails,
|
||||
Locale: p.Locale,
|
||||
PasswordConfig: passwordConfig,
|
||||
Task: p.Task,
|
||||
Organizations: p.Organizations,
|
||||
})
|
||||
}
|
||||
|
||||
type WorksmobilePasswordResetPayload struct {
|
||||
@@ -184,15 +229,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
Email: strings.TrimSpace(user.Email),
|
||||
UserExternalKey: user.ID,
|
||||
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
|
||||
CellPhone: strings.TrimSpace(user.Phone),
|
||||
CellPhone: domain.NormalizePhoneNumber(user.Phone),
|
||||
EmployeeNumber: employeeNumber,
|
||||
Locale: "ko_KR",
|
||||
PasswordConfig: WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: GenerateWorksmobileInitialPassword(),
|
||||
},
|
||||
Task: task,
|
||||
Organizations: organizations,
|
||||
Task: task,
|
||||
Organizations: organizations,
|
||||
}
|
||||
payload.AliasEmails = BuildWorksmobileAliasEmails(user, tenant)
|
||||
return payload, nil
|
||||
@@ -205,12 +246,20 @@ type worksmobileAppointment struct {
|
||||
HasManager bool
|
||||
JobTitle string
|
||||
PositionID string
|
||||
Source string
|
||||
}
|
||||
|
||||
func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) ([]WorksmobileUserOrganization, string, error) {
|
||||
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
||||
if len(appointments) == 0 {
|
||||
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
|
||||
} else if !worksmobileAppointmentsContainTenant(appointments, tenant.ID) && !worksmobileAppointmentsHavePrimary(appointments) {
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: tenant.ID,
|
||||
IsPrimary: true,
|
||||
JobTitle: strings.TrimSpace(user.JobTitle),
|
||||
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
|
||||
}}, appointments...)
|
||||
}
|
||||
accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID)
|
||||
accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant)
|
||||
@@ -235,6 +284,17 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
}
|
||||
if isWorksmobileDomainRootTenant(appointmentTenant) {
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && task == "" {
|
||||
task = strings.TrimSpace(appointment.JobTitle)
|
||||
}
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
}
|
||||
if err := ValidateWorksmobileExternalKey(appointmentTenant.ID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -276,7 +336,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
seen[appointment.TenantID] = true
|
||||
}
|
||||
if len(organizations) == 0 {
|
||||
return nil, "", errors.New("no valid worksmobile organization")
|
||||
return nil, task, nil
|
||||
}
|
||||
if !worksmobileOrganizationsHavePrimary(organizations) {
|
||||
organizations[0].Primary = true
|
||||
@@ -288,6 +348,28 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
return organizations, task, nil
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment, tenantID string) bool {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" {
|
||||
return false
|
||||
}
|
||||
for _, appointment := range appointments {
|
||||
if strings.TrimSpace(appointment.TenantID) == tenantID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
|
||||
for _, appointment := range appointments {
|
||||
if appointment.IsPrimary {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool {
|
||||
for _, appointment := range appointments {
|
||||
tenant, ok := tenantByID[appointment.TenantID]
|
||||
@@ -302,6 +384,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
envKey := worksmobileTenantDomainIDEnvKey(tenant)
|
||||
for _, candidate := range appointments {
|
||||
if strings.TrimSpace(candidate.TenantID) == "" || strings.TrimSpace(candidate.TenantID) == tenant.ID {
|
||||
continue
|
||||
}
|
||||
candidateTenant, ok := tenantByID[candidate.TenantID]
|
||||
if !ok || isWorksmobileDomainRootTenant(candidateTenant) {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantDomainIDEnvKey(worksmobileDomainClassificationTenant(candidateTenant, tenantByID)) == envKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool {
|
||||
for _, organization := range organizations {
|
||||
if organization.Primary {
|
||||
@@ -327,6 +429,7 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
|
||||
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
|
||||
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
|
||||
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
|
||||
Source: metadataString(domain.JSONMap(item), "assignmentSource", "source"),
|
||||
}
|
||||
if isManager, ok := metadataOptionalBool(domain.JSONMap(item), "isManager", "lead", "isLead"); ok {
|
||||
appointment.IsManager = isManager
|
||||
@@ -416,7 +519,7 @@ func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, e
|
||||
func GenerateWorksmobileInitialPassword() string {
|
||||
digits := "0123456789"
|
||||
letters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
symbols := "!@#$%^&*()-_=+[]{}"
|
||||
symbols := "!@#$%"
|
||||
all := digits + letters + symbols
|
||||
|
||||
password := []byte{
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *te
|
||||
|
||||
func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootTenantID := "11111111-1111-1111-1111-111111111111"
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
@@ -98,9 +99,17 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
ID: tenantID,
|
||||
Slug: "sales",
|
||||
Name: "Sales",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootTenantID,
|
||||
}
|
||||
rootTenant := domain.Tenant{
|
||||
ID: rootTenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
rootConfig := domain.JSONMap{
|
||||
@@ -111,7 +120,15 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, rootConfig)
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
tenant,
|
||||
map[string]domain.Tenant{
|
||||
rootTenantID: rootTenant,
|
||||
tenantID: tenant,
|
||||
},
|
||||
rootConfig,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
@@ -124,17 +141,76 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
require.Empty(t, payload.PrivateEmail)
|
||||
require.Empty(t, payload.AliasEmails)
|
||||
require.Equal(t, "ko_KR", payload.Locale)
|
||||
require.Equal(t, "ADMIN", payload.PasswordConfig.PasswordCreationType)
|
||||
require.Len(t, payload.PasswordConfig.Password, 16)
|
||||
require.True(t, containsAny(payload.PasswordConfig.Password, "0123456789"))
|
||||
require.True(t, containsAny(payload.PasswordConfig.Password, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"))
|
||||
require.True(t, containsAny(payload.PasswordConfig.Password, "!@#$%^&*()-_=+[]{}"))
|
||||
require.Empty(t, payload.PasswordConfig.PasswordCreationType)
|
||||
require.Empty(t, payload.PasswordConfig.Password)
|
||||
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:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadDeduplicatesKoreanCountryCodeInCellPhone(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "john1@samaneng.com",
|
||||
Name: "John Doe",
|
||||
Phone: "+82 +821091917771",
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "+821091917771", payload.CellPhone)
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONOmitsEmptyPasswordConfig(t *testing.T) {
|
||||
data, err := json.Marshal(WorksmobileUserPayload{
|
||||
DomainID: 1001,
|
||||
Email: "target@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
UserName: WorksmobileUserName{LastName: "Target"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "passwordConfig")
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadOmitsOrganizationsForSamanRootTenant(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "root-user@samaneng.com",
|
||||
Name: "Root User",
|
||||
JobTitle: "Advisor",
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Equal(t, "root-user@samaneng.com", payload.Email)
|
||||
require.Equal(t, "Advisor", payload.Task)
|
||||
require.Empty(t, payload.Organizations)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
@@ -168,6 +244,8 @@ func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *te
|
||||
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
samanRootID := "11111111-1111-1111-1111-111111111111"
|
||||
hanmacRootID := "22222222-2222-2222-2222-222222222222"
|
||||
primaryTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
secondaryTenantID := "55555555-5555-5555-5555-555555555555"
|
||||
user := domain.User{
|
||||
@@ -195,23 +273,41 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
},
|
||||
},
|
||||
}
|
||||
primaryTenant := domain.Tenant{
|
||||
ID: primaryTenantID,
|
||||
samanRoot := domain.Tenant{
|
||||
ID: samanRootID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
secondaryTenant := domain.Tenant{
|
||||
ID: secondaryTenantID,
|
||||
hanmacRoot := domain.Tenant{
|
||||
ID: hanmacRootID,
|
||||
Slug: "hanmac",
|
||||
Name: "Hanmac",
|
||||
Name: "한맥기술",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
|
||||
}
|
||||
primaryTenant := domain.Tenant{
|
||||
ID: primaryTenantID,
|
||||
Slug: "saman-sales",
|
||||
Name: "Saman Sales",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &samanRootID,
|
||||
}
|
||||
secondaryTenant := domain.Tenant{
|
||||
ID: secondaryTenantID,
|
||||
Slug: "hanmac-sales",
|
||||
Name: "Hanmac Sales",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &hanmacRootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
primaryTenant,
|
||||
map[string]domain.Tenant{
|
||||
samanRootID: samanRoot,
|
||||
hanmacRootID: hanmacRoot,
|
||||
primaryTenantID: primaryTenant,
|
||||
secondaryTenantID: secondaryTenant,
|
||||
},
|
||||
@@ -234,9 +330,66 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootTenantID := "9caf62e1-297d-4e8f-870b-61780998bbeb"
|
||||
primaryTenantID := "1edc196d-020c-4519-9ec4-3d23b99076e6"
|
||||
user := domain.User{
|
||||
ID: "64231465-d5c0-4085-b4a2-603b90834f86",
|
||||
Email: "evenlee@samaneng.com",
|
||||
Name: "이용운",
|
||||
JobTitle: "부사장",
|
||||
TenantID: &primaryTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": rootTenantID,
|
||||
"tenantSlug": "saman",
|
||||
"tenantName": "삼안",
|
||||
"assignmentSource": "email_domain",
|
||||
"sourceDomain": "samaneng.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{
|
||||
ID: rootTenantID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
primaryTenant := domain.Tenant{
|
||||
ID: primaryTenantID,
|
||||
Slug: "asset-management",
|
||||
Name: "자산관리",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootTenantID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
primaryTenant,
|
||||
map[string]domain.Tenant{
|
||||
rootTenantID: rootTenant,
|
||||
primaryTenantID: primaryTenant,
|
||||
},
|
||||
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.Len(t, payload.Organizations[0].OrgUnits, 1)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronRepresentativeIsGPDTDC(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
samanRootID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
firstTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
secondTenantID := "55555555-5555-5555-5555-555555555555"
|
||||
@@ -265,12 +418,20 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
Slug: "gpdtdc",
|
||||
Name: "총괄기획&기술개발센터",
|
||||
}
|
||||
firstTenant := domain.Tenant{
|
||||
ID: firstTenantID,
|
||||
Slug: "rnd-saman",
|
||||
Name: "삼안기술개발센터",
|
||||
samanRoot := domain.Tenant{
|
||||
ID: samanRootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
firstTenant := domain.Tenant{
|
||||
ID: firstTenantID,
|
||||
Slug: "rnd-center",
|
||||
Name: "삼안기술개발센터",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &samanRootID,
|
||||
}
|
||||
secondTenant := domain.Tenant{
|
||||
ID: secondTenantID,
|
||||
Slug: "tdc",
|
||||
@@ -282,6 +443,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
user,
|
||||
gpdtdcTenant,
|
||||
map[string]domain.Tenant{
|
||||
samanRootID: samanRoot,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
firstTenantID: firstTenant,
|
||||
secondTenantID: secondTenant,
|
||||
@@ -354,17 +516,12 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, int64(1003), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email)
|
||||
require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[0].Email)
|
||||
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email)
|
||||
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
|
||||
|
||||
79
backend/internal/service/worksmobile_relay_leader_lock.go
Normal file
79
backend/internal/service/worksmobile_relay_leader_lock.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
const (
|
||||
worksmobileRelayLeaderLockKey = "baron:worksmobile:relay:leader"
|
||||
worksmobileRelayLeaderLockTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
const worksmobileRelayLeaderRenewScript = `
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("EXPIRE", KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
type WorksmobileRedisRelayLeaderLock struct {
|
||||
client *redis.Client
|
||||
key string
|
||||
ttl time.Duration
|
||||
ownerID string
|
||||
}
|
||||
|
||||
func NewWorksmobileRedisRelayLeaderLock(redisService *RedisService) *WorksmobileRedisRelayLeaderLock {
|
||||
if redisService == nil || redisService.Client == nil {
|
||||
return nil
|
||||
}
|
||||
return &WorksmobileRedisRelayLeaderLock{
|
||||
client: redisService.Client,
|
||||
key: worksmobileRelayLeaderLockKey,
|
||||
ttl: worksmobileRelayLeaderLockTTL,
|
||||
ownerID: newWorksmobileRelayLeaderOwnerID(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *WorksmobileRedisRelayLeaderLock) EnsureLeadership(ctx context.Context) (bool, error) {
|
||||
if l == nil || l.client == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
acquired, err := l.client.SetNX(ctx, l.key, l.ownerID, l.ttl).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if acquired {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ttlSeconds := int64(l.ttl / time.Second)
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 30
|
||||
}
|
||||
result, err := l.client.Eval(ctx, worksmobileRelayLeaderRenewScript, []string{l.key}, l.ownerID, ttlSeconds).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
func newWorksmobileRelayLeaderOwnerID() string {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown-host"
|
||||
}
|
||||
randomBytes := make([]byte, 8)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return fmt.Sprintf("%s:%d:%d", hostname, os.Getpid(), time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%s:%d:%s", hostname, os.Getpid(), hex.EncodeToString(randomBytes))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -15,10 +16,15 @@ import (
|
||||
type WorksmobileRelayWorker struct {
|
||||
repo repository.WorksmobileOutboxRepository
|
||||
client WorksmobileDirectoryClient
|
||||
leaderLock WorksmobileRelayLeaderLock
|
||||
interval time.Duration
|
||||
batchLimit int
|
||||
}
|
||||
|
||||
type WorksmobileRelayLeaderLock interface {
|
||||
EnsureLeadership(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
func NewWorksmobileRelayWorker(repo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *WorksmobileRelayWorker {
|
||||
return &WorksmobileRelayWorker{
|
||||
repo: repo,
|
||||
@@ -28,6 +34,17 @@ func NewWorksmobileRelayWorker(repo repository.WorksmobileOutboxRepository, clie
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorksmobileRelayWorker) SetLeaderLock(lock WorksmobileRelayLeaderLock) {
|
||||
w.leaderLock = lock
|
||||
}
|
||||
|
||||
func (w *WorksmobileRelayWorker) SetBatchLimit(limit int) {
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
w.batchLimit = limit
|
||||
}
|
||||
|
||||
func (w *WorksmobileRelayWorker) Start(ctx context.Context) {
|
||||
if w.repo == nil || w.client == nil {
|
||||
slog.Warn("Worksmobile relay worker disabled")
|
||||
@@ -49,7 +66,23 @@ func (w *WorksmobileRelayWorker) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
|
||||
func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) (err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("worksmobile relay panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
if w.leaderLock != nil {
|
||||
isLeader, err := w.leaderLock.EnsureLeadership(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isLeader {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
jobs, err := w.repo.ListReady(ctx, w.batchLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -109,15 +142,20 @@ 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 {
|
||||
return err
|
||||
return fmt.Errorf("worksmobile user upsert failed: %w", err)
|
||||
}
|
||||
for _, aliasEmail := range aliasEmails {
|
||||
if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("worksmobile user alias add failed: %w", err)
|
||||
}
|
||||
}
|
||||
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
|
||||
if err := w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true); err != nil {
|
||||
if isWorksmobileSCIMTokenNotConfiguredError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("worksmobile user set active failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case domain.WorksmobileActionDelete:
|
||||
@@ -142,6 +180,10 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
}
|
||||
}
|
||||
|
||||
func isWorksmobileSCIMTokenNotConfiguredError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "worksmobile scim token is not configured")
|
||||
}
|
||||
|
||||
func sortWorksmobileReadyJobs(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
|
||||
sorted := append([]domain.WorksmobileOutbox(nil), jobs...)
|
||||
depthByID := worksmobileOrgUnitDepths(sorted)
|
||||
|
||||
@@ -31,7 +31,7 @@ type WorksmobileAdminService interface {
|
||||
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
|
||||
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID, initialPassword string) (*domain.WorksmobileOutbox, error)
|
||||
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
|
||||
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
|
||||
DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error)
|
||||
@@ -510,7 +510,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID, initialPassword string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -556,6 +556,13 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initialPassword = strings.TrimSpace(initialPassword)
|
||||
if initialPassword != "" {
|
||||
payload.PasswordConfig = WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: initialPassword,
|
||||
}
|
||||
}
|
||||
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1167,10 +1174,12 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
|
||||
|
||||
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap {
|
||||
outboxPayload := domain.JSONMap{
|
||||
"request": payload,
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": payload.Email,
|
||||
"initialPassword": payload.PasswordConfig.Password,
|
||||
"request": payload,
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": payload.Email,
|
||||
}
|
||||
if password := strings.TrimSpace(payload.PasswordConfig.Password); password != "" {
|
||||
outboxPayload["initialPassword"] = password
|
||||
}
|
||||
if len(statuses) > 0 {
|
||||
if status := strings.TrimSpace(statuses[0]); status != "" {
|
||||
@@ -1428,7 +1437,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
excludedLocalIDs := map[string]bool{}
|
||||
result := make([]WorksmobileComparisonItem, 0)
|
||||
for _, user := range localUsers {
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
if user.DeletedAt.Valid || !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
excludedLocalIDs[user.ID] = true
|
||||
if remote, ok := remoteByExternalID[user.ID]; ok {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
@@ -1556,12 +1565,6 @@ func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser,
|
||||
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1571,22 +1574,24 @@ func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteU
|
||||
if localPhone == "" && remotePhone == "" {
|
||||
return false
|
||||
}
|
||||
return localPhone != remotePhone
|
||||
if localPhone != remotePhone {
|
||||
return true
|
||||
}
|
||||
return localPhone != "" && worksmobilePhoneHasDuplicateKoreanCountryCode(remote.CellPhone)
|
||||
}
|
||||
|
||||
func normalizeWorksmobilePhoneForCompare(value string) string {
|
||||
normalized := strings.TrimSpace(value)
|
||||
normalized = strings.NewReplacer("-", "", " ", "", "(", "", ")", "").Replace(normalized)
|
||||
if normalized == "" {
|
||||
return ""
|
||||
return domain.NormalizePhoneNumber(value)
|
||||
}
|
||||
|
||||
func worksmobilePhoneHasDuplicateKoreanCountryCode(value string) bool {
|
||||
digits := strings.Builder{}
|
||||
for _, r := range strings.TrimSpace(value) {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
return strings.HasPrefix(digits.String(), "8282")
|
||||
}
|
||||
|
||||
func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testin
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
|
||||
|
||||
require.Nil(t, item)
|
||||
require.Error(t, err)
|
||||
@@ -90,7 +90,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -135,7 +135,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1")
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1", "InputPass1!")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -144,6 +144,53 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
|
||||
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
|
||||
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
|
||||
require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"])
|
||||
require.Equal(t, "InputPass1!", outboxRepo.created[0].Payload["initialPassword"])
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
|
||||
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, request.PasswordConfig.Password)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||
@@ -382,7 +429,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
@@ -1548,6 +1595,48 @@ func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
|
||||
require.Empty(t, comparison.Users)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison(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,
|
||||
}
|
||||
deleted := domain.User{
|
||||
ID: "deleted-user",
|
||||
Email: "deleted@samaneng.com",
|
||||
Name: "Deleted",
|
||||
TenantID: &companyID,
|
||||
Status: domain.UserStatusActive,
|
||||
DeletedAt: gorm.DeletedAt{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
|
||||
&fakeWorksmobileUserRepo{byTenant: []domain.User{deleted}},
|
||||
&fakeWorksmobileOutboxRepo{},
|
||||
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
|
||||
ID: "works-deleted",
|
||||
ExternalID: deleted.ID,
|
||||
Email: deleted.Email,
|
||||
}}},
|
||||
)
|
||||
|
||||
comparison, err := service.GetComparison(context.Background(), rootID, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, comparison.Users)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
@@ -1760,14 +1849,14 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
|
||||
require.NoError(t, service.EnqueueTenantUpsertIfInScope(context.Background(), excludedOrg))
|
||||
require.NoError(t, service.EnqueueTenantDeleteIfInScope(context.Background(), excludedOrg))
|
||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "")
|
||||
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)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
|
||||
tenantID := "tenant-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-manager",
|
||||
@@ -1803,10 +1892,10 @@ func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
|
||||
primaryTenantID := "tenant-company"
|
||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||
user := domain.User{
|
||||
@@ -1853,10 +1942,10 @@ func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testin
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
@@ -1916,7 +2005,7 @@ func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
|
||||
@@ -1952,6 +2041,35 @@ func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-saman"
|
||||
user := domain.User{
|
||||
ID: "user-phone-canonical",
|
||||
Email: "phone-canonical@samaneng.com",
|
||||
Name: "Phone Canonical User",
|
||||
Phone: "+821062836786",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-phone-canonical",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
CellPhone: "+82+821062836786",
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
type fakeWorksmobileTenantService struct {
|
||||
tenants map[string]domain.Tenant
|
||||
list []domain.Tenant
|
||||
|
||||
Reference in New Issue
Block a user