forked from baron/baron-sso
merge: integrate origin dev into dev
Includes Worksmobile SSOT sync comparison updates, UUID import conflict resolution, and Playwright route mock stabilization.
This commit is contained in:
@@ -36,13 +36,13 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Aud interface{} `json:"aud"`
|
||||
Iat int64 `json:"iat"`
|
||||
Jti string `json:"jti"`
|
||||
Sid string `json:"sid"`
|
||||
Events map[string]interface{} `json:"events"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Aud any `json:"aud"`
|
||||
Iat int64 `json:"iat"`
|
||||
Jti string `json:"jti"`
|
||||
Sid string `json:"sid"`
|
||||
Events map[string]any `json:"events"`
|
||||
}
|
||||
require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims))
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) {
|
||||
switch aud := claims.Aud.(type) {
|
||||
case string:
|
||||
assert.Equal(t, "client-1", aud)
|
||||
case []interface{}:
|
||||
case []any:
|
||||
assert.Len(t, aud, 1)
|
||||
assert.Equal(t, "client-1", aud[0])
|
||||
default:
|
||||
|
||||
@@ -65,21 +65,21 @@ func (s *DeveloperService) ListRequests(ctx context.Context, userID, status stri
|
||||
}
|
||||
|
||||
func (s *DeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": domain.DeveloperRequestStatusApproved,
|
||||
"admin_notes": adminNotes,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": domain.DeveloperRequestStatusRejected,
|
||||
"admin_notes": adminNotes,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": domain.DeveloperRequestStatusCancelled,
|
||||
"admin_notes": adminNotes,
|
||||
}).Error
|
||||
|
||||
@@ -309,7 +309,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
|
||||
updated := *previous
|
||||
updated.JWKSURI = jwksURI
|
||||
updated.LastCheckedAt = &now
|
||||
updated.ExpiresAt = ptrTime(now.Add(s.TTL))
|
||||
updated.ExpiresAt = new(now.Add(s.TTL))
|
||||
updated.NextRetryAt = nil
|
||||
updated.LastRefreshStatus = "success"
|
||||
updated.LastError = ""
|
||||
@@ -339,7 +339,7 @@ func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client dom
|
||||
ClientID: client.ClientID,
|
||||
JWKSURI: jwksURI,
|
||||
CachedAt: &now,
|
||||
ExpiresAt: ptrTime(now.Add(s.TTL)),
|
||||
ExpiresAt: new(now.Add(s.TTL)),
|
||||
LastCheckedAt: &now,
|
||||
NextRetryAt: nil,
|
||||
LastSuccessfulVerificationAt: previousLastVerification(previous),
|
||||
@@ -379,7 +379,7 @@ func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClie
|
||||
state.ConsecutiveFailures = previous.ConsecutiveFailures + 1
|
||||
}
|
||||
if s.shouldBackoff(state.ConsecutiveFailures) {
|
||||
state.NextRetryAt = ptrTime(now.Add(s.failureBackoffDuration()))
|
||||
state.NextRetryAt = new(now.Add(s.failureBackoffDuration()))
|
||||
}
|
||||
_ = s.SaveState(client.ClientID, state)
|
||||
return &state
|
||||
@@ -480,8 +480,9 @@ func previousLastVerification(previous *domain.HeadlessJWKSCacheState) *time.Tim
|
||||
return previous.LastSuccessfulVerificationAt
|
||||
}
|
||||
|
||||
//go:fix inline
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
return new(value)
|
||||
}
|
||||
|
||||
func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) {
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_UsesCachedJWKSWhenFresh(t *t
|
||||
CachedKids: []string{"cached-key"},
|
||||
CachedAt: &now,
|
||||
LastCheckedAt: &now,
|
||||
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
|
||||
ExpiresAt: new(now.Add(30 * time.Minute)),
|
||||
LastRefreshStatus: "success",
|
||||
ConsecutiveFailures: 0,
|
||||
})
|
||||
@@ -114,7 +114,7 @@ func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *t
|
||||
CachedKids: []string{"stale-key"},
|
||||
CachedAt: &now,
|
||||
LastCheckedAt: &now,
|
||||
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
|
||||
ExpiresAt: new(now.Add(30 * time.Minute)),
|
||||
LastRefreshStatus: "success",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -181,7 +181,7 @@ func TestHeadlessJWKSCacheService_ShouldPrefetch_SkipsUntilNextRetryAt(t *testin
|
||||
ClientID: "client-headless",
|
||||
LastRefreshStatus: "failure",
|
||||
ConsecutiveFailures: 3,
|
||||
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)),
|
||||
NextRetryAt: new(now.Add(10 * time.Minute)),
|
||||
}
|
||||
|
||||
assert.False(t, cacheService.ShouldPrefetch(state, now))
|
||||
@@ -216,7 +216,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_SkipsBackoffTargets(t *testing.T) {
|
||||
JWKSURI: clients[1].HeadlessJWKSURI(),
|
||||
LastRefreshStatus: "failure",
|
||||
ConsecutiveFailures: 3,
|
||||
NextRetryAt: ptrTestTime(now.Add(10 * time.Minute)),
|
||||
NextRetryAt: new(now.Add(10 * time.Minute)),
|
||||
}))
|
||||
|
||||
fetchCounts := map[string]int{}
|
||||
@@ -278,7 +278,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_RetriesAfterBackoffAndClearsFailureStat
|
||||
LastRefreshStatus: "failure",
|
||||
LastError: "previous failure",
|
||||
ConsecutiveFailures: 3,
|
||||
NextRetryAt: ptrTestTime(time.Now().Add(-time.Minute)),
|
||||
NextRetryAt: new(time.Now().Add(-time.Minute)),
|
||||
}))
|
||||
|
||||
fetchCount := 0
|
||||
@@ -353,7 +353,7 @@ func TestHeadlessJWKSCacheWorker_RunOnce_MixedClients(t *testing.T) {
|
||||
JWKSURI: skipClient.HeadlessJWKSURI(),
|
||||
LastRefreshStatus: "failure",
|
||||
ConsecutiveFailures: 3,
|
||||
NextRetryAt: ptrTestTime(time.Now().Add(10 * time.Minute)),
|
||||
NextRetryAt: new(time.Now().Add(10 * time.Minute)),
|
||||
}))
|
||||
|
||||
fetchCounts := map[string]int{}
|
||||
@@ -416,8 +416,9 @@ func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.
|
||||
return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}}
|
||||
}
|
||||
|
||||
//go:fix inline
|
||||
func ptrTestTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
return new(value)
|
||||
}
|
||||
|
||||
func newTestHeadlessClient(clientID, jwksURI string) domain.HydraClient {
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
|
||||
|
||||
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
|
||||
// JSON Patch format
|
||||
payload := []map[string]interface{}{
|
||||
payload := []map[string]any{
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/metadata/status",
|
||||
@@ -396,7 +396,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"error": "access_denied",
|
||||
"error_description": "The user decided to reject the consent request.",
|
||||
}
|
||||
@@ -438,7 +438,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"error": error,
|
||||
"error_description": errorDescription,
|
||||
}
|
||||
@@ -513,7 +513,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"grant_scope": grantInfo.RequestedScope,
|
||||
"grant_audience": grantInfo.RequestedAudience,
|
||||
"remember": true,
|
||||
@@ -564,7 +564,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"subject": subject,
|
||||
"remember": true,
|
||||
"remember_for": 2592000,
|
||||
@@ -600,13 +600,13 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
||||
}
|
||||
|
||||
type HydraIntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Subject string `json:"sub"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Ext map[string]interface{} `json:"ext"`
|
||||
Active bool `json:"active"`
|
||||
Subject string `json:"sub"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Ext map[string]any `json:"ext"`
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestHydraAdminService_PatchClientStatus(t *testing.T) {
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type"))
|
||||
|
||||
var payload []map[string]interface{}
|
||||
var payload []map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||
assert.Equal(t, "replace", payload[0]["op"])
|
||||
assert.Equal(t, "/metadata/status", payload[0]["path"])
|
||||
@@ -273,7 +273,7 @@ func TestHydraAdminService_AcceptLoginRequest(t *testing.T) {
|
||||
assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path)
|
||||
assert.Equal(t, challenge, r.URL.Query().Get("login_challenge"))
|
||||
|
||||
var body map[string]interface{}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
assert.Equal(t, subject, body["subject"])
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
|
||||
maxRetries := 5
|
||||
backoff := 200 * time.Millisecond
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
for i := range maxRetries {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err == nil {
|
||||
@@ -143,7 +143,7 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
|
||||
|
||||
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"namespace": namespace,
|
||||
"object": object,
|
||||
"relation": relation,
|
||||
@@ -156,7 +156,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
||||
maxRetries := 5
|
||||
backoff := 200 * time.Millisecond
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
for i := range maxRetries {
|
||||
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -197,7 +197,7 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
|
||||
maxRetries := 5
|
||||
backoff := 200 * time.Millisecond
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
for i := range maxRetries {
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err == nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestKetoService_CreateRelation(t *testing.T) {
|
||||
assert.Equal(t, "/admin/relation-tuples", r.URL.Path)
|
||||
assert.Equal(t, "PUT", r.Method)
|
||||
|
||||
var body map[string]interface{}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
assert.Equal(t, "tenants", body["namespace"])
|
||||
assert.Equal(t, "tenant1", body["object"])
|
||||
|
||||
@@ -17,15 +17,14 @@ import (
|
||||
)
|
||||
|
||||
type KratosIdentity struct {
|
||||
ID string `json:"id"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
|
||||
MetadataPublic interface{} `json:"metadata_public,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
Traits map[string]any `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
MetadataAdmin any `json:"metadata_admin,omitempty"`
|
||||
MetadataPublic any `json:"metadata_public,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type KratosSessionDevice struct {
|
||||
@@ -36,9 +35,9 @@ type KratosSessionDevice struct {
|
||||
type KratosSession struct {
|
||||
ID string `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
AuthenticatedAt time.Time `json:"authenticated_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
AuthenticatedAt time.Time `json:"authenticated_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
Identity *KratosIdentity `json:"identity,omitempty"`
|
||||
Devices []KratosSessionDevice `json:"devices,omitempty"`
|
||||
}
|
||||
@@ -47,7 +46,7 @@ type KratosAdminService interface {
|
||||
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
||||
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
||||
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
|
||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
|
||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error)
|
||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||
DeleteIdentity(ctx context.Context, identityID string) error
|
||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||
@@ -99,13 +98,66 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
|
||||
}
|
||||
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
|
||||
// 1. Try credentials_identifier (Email/LoginID/Phone)
|
||||
id, err := s.searchIdentities(ctx, endpoint, "credentials_identifier", identifier)
|
||||
if err == nil && id != "" {
|
||||
// VERIFY: Kratos sometimes ignores unknown query params and returns the first identity.
|
||||
if s.verifyIdentityMatch(ctx, id, identifier) {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
identity, err := s.GetIdentity(ctx, identifier)
|
||||
if err == nil && identity != nil {
|
||||
return identity.ID, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) verifyIdentityMatch(ctx context.Context, id, identifier string) bool {
|
||||
identity, err := s.GetIdentity(ctx, id)
|
||||
if err != nil || identity == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Exact ID match
|
||||
if strings.EqualFold(identity.ID, identifier) {
|
||||
return true
|
||||
}
|
||||
// Check traits (Email, CustomLoginIDs)
|
||||
if email, ok := identity.Traits["email"].(string); ok && strings.EqualFold(email, identifier) {
|
||||
return true
|
||||
}
|
||||
if phone, ok := identity.Traits["phone_number"].(string); ok && strings.EqualFold(phone, identifier) {
|
||||
return true
|
||||
}
|
||||
if lids, ok := identity.Traits["custom_login_ids"].([]any); ok {
|
||||
for _, lid := range lids {
|
||||
if s, ok := lid.(string); ok && strings.EqualFold(s, identifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if lids, ok := identity.Traits["custom_login_ids"].([]string); ok {
|
||||
for _, lid := range lids {
|
||||
if strings.EqualFold(lid, identifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) searchIdentities(ctx context.Context, endpoint, key, value string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := req.URL.Query()
|
||||
query.Set("credentials_identifier", identifier)
|
||||
query.Set(key, value)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
@@ -119,7 +171,7 @@ func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("kratos admin search by %s failed status=%d body=%s", key, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []struct {
|
||||
@@ -162,8 +214,8 @@ func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string)
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
payload := map[string]interface{}{
|
||||
func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
}
|
||||
@@ -211,12 +263,12 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]string{
|
||||
"hashed_password": hashedPassword,
|
||||
},
|
||||
@@ -235,10 +287,6 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
if identity.MetadataPublic != nil {
|
||||
payload["metadata_public"] = identity.MetadataPublic
|
||||
}
|
||||
if identity.ExternalID != "" {
|
||||
payload["external_id"] = identity.ExternalID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
||||
@@ -263,8 +311,11 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
if user == nil {
|
||||
return "", fmt.Errorf("kratos admin: user payload is nil")
|
||||
}
|
||||
if strings.TrimSpace(user.ID) != "" {
|
||||
return "", fmt.Errorf("kratos admin: requested identity id import is disabled; use backup/restore")
|
||||
}
|
||||
|
||||
traits := map[string]interface{}{
|
||||
traits := map[string]any{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
}
|
||||
@@ -278,11 +329,11 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
traits[k] = v
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]string{
|
||||
"password": password,
|
||||
},
|
||||
@@ -290,10 +341,6 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
},
|
||||
"state": "active",
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||
payload["id"] = requestedID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
@@ -319,10 +366,6 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||
return "", fmt.Errorf("kratos admin: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||
}
|
||||
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
|
||||
return args.Get(0).(*KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]any, state string) (*KratosIdentity, error) {
|
||||
args := m.Called(ctx, identityID, traits, state)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
||||
@@ -57,8 +57,10 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
if user.Email == "" || password == "" {
|
||||
return "", fmt.Errorf("ory provider: email and password are required")
|
||||
}
|
||||
if strings.TrimSpace(user.ID) != "" {
|
||||
return "", fmt.Errorf("ory provider: requested identity id import is disabled; use backup/restore")
|
||||
}
|
||||
|
||||
// 중복 확인
|
||||
existingID, err := o.findIdentityID(user.Email)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
||||
@@ -102,7 +104,7 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
}
|
||||
}
|
||||
|
||||
traits := map[string]interface{}{
|
||||
traits := map[string]any{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
}
|
||||
@@ -123,21 +125,18 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
traits[k] = v
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]string{
|
||||
"password": password,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" {
|
||||
payload["id"] = requestedID
|
||||
}
|
||||
verifiable := []map[string]interface{}{
|
||||
verifiable := []map[string]any{
|
||||
{
|
||||
"value": user.Email,
|
||||
"verified": true,
|
||||
@@ -145,14 +144,14 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
},
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
verifiable = append(verifiable, map[string]interface{}{
|
||||
verifiable = append(verifiable, map[string]any{
|
||||
"value": user.PhoneNumber,
|
||||
"verified": true,
|
||||
"via": "sms",
|
||||
})
|
||||
}
|
||||
payload["verifiable_addresses"] = verifiable
|
||||
payload["recovery_addresses"] = []map[string]interface{}{
|
||||
payload["recovery_addresses"] = []map[string]any{
|
||||
{
|
||||
"value": user.Email,
|
||||
"via": "email",
|
||||
@@ -182,10 +181,6 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
|
||||
}
|
||||
if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID {
|
||||
return "", fmt.Errorf("ory provider: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID)
|
||||
}
|
||||
|
||||
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
|
||||
return created.ID, nil
|
||||
}
|
||||
@@ -460,12 +455,12 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
||||
existingIndex = idx
|
||||
}
|
||||
}
|
||||
ops := make([]map[string]interface{}, 0, 2)
|
||||
ops := make([]map[string]any, 0, 2)
|
||||
if !exists {
|
||||
ops = append(ops, map[string]interface{}{
|
||||
ops = append(ops, map[string]any{
|
||||
"op": "add",
|
||||
"path": "/verifiable_addresses/-",
|
||||
"value": map[string]interface{}{
|
||||
"value": map[string]any{
|
||||
"value": loginID,
|
||||
"via": via,
|
||||
"verified": true,
|
||||
@@ -475,14 +470,14 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
||||
} else {
|
||||
addr := identity.VerifiableAddresses[existingIndex]
|
||||
if !addr.Verified {
|
||||
ops = append(ops, map[string]interface{}{
|
||||
ops = append(ops, map[string]any{
|
||||
"op": "replace",
|
||||
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
|
||||
"value": true,
|
||||
})
|
||||
}
|
||||
if addr.Status != "" && addr.Status != "completed" {
|
||||
ops = append(ops, map[string]interface{}{
|
||||
ops = append(ops, map[string]any{
|
||||
"op": "replace",
|
||||
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
|
||||
"value": "completed",
|
||||
@@ -526,7 +521,7 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": fullIdentity.SchemaID,
|
||||
"traits": fullIdentity.Traits,
|
||||
"verifiable_addresses": addresses,
|
||||
@@ -567,12 +562,12 @@ type kratosRecoveryAddress struct {
|
||||
|
||||
type kratosIdentityFull struct {
|
||||
SchemaID string `json:"schema_id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
Traits map[string]any `json:"traits"`
|
||||
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
||||
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
|
||||
}
|
||||
|
||||
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
|
||||
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]any) error {
|
||||
body, _ := json.Marshal(ops)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -756,12 +751,12 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
||||
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]string{
|
||||
"hashed_password": hashedPassword,
|
||||
},
|
||||
@@ -780,10 +775,6 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
||||
if identity.MetadataPublic != nil {
|
||||
payload["metadata_public"] = identity.MetadataPublic
|
||||
}
|
||||
if identity.ExternalID != "" {
|
||||
payload["external_id"] = identity.ExternalID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -812,6 +803,18 @@ func getenv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// findIdentityByID: Kratos Admin API에서 ID(UUID)로 직접 조회
|
||||
func (o *OryProvider) findIdentityByID(id string) (string, error) {
|
||||
identity, err := o.getIdentity(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if identity != nil {
|
||||
return identity.ID, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
|
||||
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
||||
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
|
||||
@@ -843,7 +846,8 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
||||
}
|
||||
|
||||
var identities []struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Traits map[string]any `json:"traits"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||
return "", fmt.Errorf("decode response failed: %w", err)
|
||||
@@ -851,7 +855,30 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
||||
if len(identities) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return identities[0].ID, nil
|
||||
|
||||
// VERIFY: Double check traits to avoid Kratos ignoring the query param
|
||||
candidate := identities[0]
|
||||
if email, ok := candidate.Traits["email"].(string); ok && strings.EqualFold(email, loginID) {
|
||||
return candidate.ID, nil
|
||||
}
|
||||
if phone, ok := candidate.Traits["phone_number"].(string); ok && strings.EqualFold(phone, loginID) {
|
||||
return candidate.ID, nil
|
||||
}
|
||||
if lids, ok := candidate.Traits["custom_login_ids"].([]any); ok {
|
||||
for _, lid := range lids {
|
||||
if s, ok := lid.(string); ok && strings.EqualFold(s, loginID) {
|
||||
return candidate.ID, nil
|
||||
}
|
||||
}
|
||||
} else if lids, ok := candidate.Traits["custom_login_ids"].([]string); ok {
|
||||
for _, lid := range lids {
|
||||
if strings.EqualFold(lid, loginID) {
|
||||
return candidate.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
||||
|
||||
@@ -36,76 +36,6 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestCreateUserSendsRequestedIdentityID(t *testing.T) {
|
||||
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
if payload["id"] != requestedID {
|
||||
t.Fatalf("expected id=%s, got=%v", requestedID, payload["id"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": requestedID})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
id, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: requestedID,
|
||||
Email: "restore@test.com",
|
||||
Name: "Restore User",
|
||||
}, "Sup3rStr0ng!Pass#2026")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
if id != requestedID {
|
||||
t.Fatalf("expected %s, got %s", requestedID, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsRequestedIdentityIDMismatch(t *testing.T) {
|
||||
const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost:
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "generated-id"})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
_, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: requestedID,
|
||||
Email: "restore@test.com",
|
||||
Name: "Restore User",
|
||||
}, "Sup3rStr0ng!Pass#2026")
|
||||
if err == nil || !strings.Contains(err.Error(), "requested identity id was not preserved") {
|
||||
t.Fatalf("expected requested identity id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPassword_Success(t *testing.T) {
|
||||
const (
|
||||
loginID = "user@example.com"
|
||||
@@ -121,19 +51,24 @@ func TestUpdateUserPassword_Success(t *testing.T) {
|
||||
if got := q.Get("credentials_identifier"); got != loginID {
|
||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": identityID},
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": loginID,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/admin/identities/"+identityID {
|
||||
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": identityID,
|
||||
"schema_id": "default",
|
||||
"state": "active",
|
||||
"traits": map[string]interface{}{
|
||||
"traits": map[string]any{
|
||||
"email": loginID,
|
||||
},
|
||||
})
|
||||
@@ -191,17 +126,22 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||
if r.URL.Path == "/admin/identities" {
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": "abc"},
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"id": "abc",
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/admin/identities/abc" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "abc",
|
||||
"schema_id": "default",
|
||||
"state": "active",
|
||||
"traits": map[string]interface{}{
|
||||
"traits": map[string]any{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
})
|
||||
@@ -234,8 +174,13 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
|
||||
if values.Get("credentials_identifier") != loginID {
|
||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": "id-123"},
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"id": "id-123",
|
||||
"traits": map[string]any{
|
||||
"email": loginID,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -252,3 +197,30 @@ func TestFindIdentityID_QueryEncoding(t *testing.T) {
|
||||
t.Fatalf("expected id-123, got %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOryProvider_CreateUser_RejectsRequestedIdentityID(t *testing.T) {
|
||||
const (
|
||||
email = "newuser@test.com"
|
||||
name = "New User"
|
||||
customUuid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
password = "secret123456"
|
||||
)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
})
|
||||
|
||||
provider := &OryProvider{
|
||||
KratosAdminURL: "http://kratos-admin.local",
|
||||
HTTPClient: clientForHandler(handler),
|
||||
}
|
||||
|
||||
id, err := provider.CreateUser(&domain.BrokerUser{
|
||||
ID: customUuid,
|
||||
Email: email,
|
||||
Name: name,
|
||||
}, password)
|
||||
if err == nil || !strings.Contains(err.Error(), "requested identity id import is disabled") {
|
||||
t.Fatalf("expected requested identity id rejection, got id=%s err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Co
|
||||
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||
// 1. Create Client in Hydra
|
||||
if client.Metadata == nil {
|
||||
client.Metadata = make(map[string]interface{})
|
||||
client.Metadata = make(map[string]any)
|
||||
}
|
||||
client.Metadata["tenant_id"] = tenantID
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||
tenantID := "tenant-1"
|
||||
inputClient := domain.HydraClient{
|
||||
ClientName: "Test App",
|
||||
Metadata: map[string]interface{}{
|
||||
Metadata: map[string]any{
|
||||
"user_id": "creator-1",
|
||||
},
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraClient{
|
||||
ClientID: clientID,
|
||||
ClientName: "Hydra Name",
|
||||
Metadata: map[string]interface{}{
|
||||
Metadata: map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
},
|
||||
})
|
||||
@@ -180,7 +180,7 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) {
|
||||
_ = json.NewEncoder(w).Encode(domain.HydraClient{
|
||||
ClientID: clientID,
|
||||
Metadata: map[string]interface{}{
|
||||
Metadata: map[string]any{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": "creator-1",
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -82,7 +82,7 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := ioutil.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{})
|
||||
rawConfig, ok := g.Config["autoProvisioning"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -346,12 +346,12 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
|
||||
continue
|
||||
}
|
||||
|
||||
mapping, ok := rawConfig["mappingRules"].(map[string]interface{})
|
||||
mapping, ok := rawConfig["mappingRules"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
rule, ok := mapping[domainName].(map[string]interface{})
|
||||
rule, ok := mapping[domainName].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Local Mocks to avoid collisions ---
|
||||
@@ -185,6 +186,10 @@ func (m *MockUserRepoForTenant) FindTenantIDByLoginID(ctx context.Context, login
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) DB() *gorm.DB {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
|
||||
@@ -225,7 +225,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
if err == nil && identity != nil {
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = make(map[string]interface{})
|
||||
traits = make(map[string]any)
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
@@ -349,7 +349,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
||||
return user
|
||||
}
|
||||
|
||||
func userGroupTraitString(traits map[string]interface{}, key string) string {
|
||||
func userGroupTraitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -363,14 +363,14 @@ func userGroupTraitString(traits map[string]interface{}, key string) string {
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func userGroupTraitStringArray(traits map[string]interface{}, key string) []string {
|
||||
func userGroupTraitStringArray(traits map[string]any, key string) []string {
|
||||
if traits == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := traits[key].(type) {
|
||||
case []string:
|
||||
return value
|
||||
case []interface{}:
|
||||
case []any:
|
||||
items := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
|
||||
@@ -135,6 +135,10 @@ func (m *MockUserRepository) FindTenantIDByLoginID(ctx context.Context, loginID
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) DB() *gorm.DB {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MockKetoOutboxRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -250,7 +254,7 @@ func TestUserGroupService_AddMember(t *testing.T) {
|
||||
// Mock Kratos
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{"email": "user@test.com"},
|
||||
Traits: map[string]any{"email": "user@test.com"},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil)
|
||||
@@ -295,18 +299,18 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "User Test",
|
||||
},
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
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]interface{}{
|
||||
Traits: map[string]any{
|
||||
"email": "user@test.com",
|
||||
"name": "User Test",
|
||||
"tenant_id": tenantID,
|
||||
@@ -402,7 +406,7 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
|
||||
|
||||
mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{
|
||||
ID: "u1",
|
||||
Traits: map[string]interface{}{"name": "User One", "email": "user1@example.com"},
|
||||
Traits: map[string]any{"name": "User One", "email": "user1@example.com"},
|
||||
}, nil)
|
||||
|
||||
group, err := svc.Get(context.Background(), groupID)
|
||||
|
||||
@@ -110,7 +110,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
return user
|
||||
}
|
||||
|
||||
func kratosProjectionTraitString(traits map[string]interface{}, key string) string {
|
||||
func kratosProjectionTraitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -124,14 +124,14 @@ func kratosProjectionTraitString(traits map[string]interface{}, key string) stri
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func kratosProjectionTraitStringArray(traits map[string]interface{}, key string) []string {
|
||||
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
|
||||
if traits == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := traits[key].(type) {
|
||||
case []string:
|
||||
return value
|
||||
case []interface{}:
|
||||
case []any:
|
||||
items := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
|
||||
|
||||
@@ -48,12 +48,12 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
|
||||
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
|
||||
{
|
||||
ID: "00000000-0000-0000-0000-000000000101",
|
||||
Traits: map[string]interface{}{
|
||||
Traits: map[string]any{
|
||||
"email": "one@example.com",
|
||||
"name": "One",
|
||||
"phone_number": "+821012345678",
|
||||
"companyCode": "saman",
|
||||
"companyCodes": []interface{}{"saman", "group-a"},
|
||||
"companyCodes": []any{"saman", "group-a"},
|
||||
"tenant_id": tenantID,
|
||||
"department": "DX",
|
||||
"customAttr": "kept",
|
||||
@@ -101,7 +101,7 @@ func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
|
||||
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
||||
ID: "00000000-0000-0000-0000-000000000201",
|
||||
State: domain.UserStatusArchived,
|
||||
Traits: map[string]interface{}{
|
||||
Traits: map[string]any{
|
||||
"email": "archived@example.com",
|
||||
"name": "Archived User",
|
||||
},
|
||||
|
||||
@@ -778,7 +778,7 @@ type WorksmobileUserPatchPayload struct {
|
||||
DomainID int64 `json:"domainId"`
|
||||
Email string `json:"email,omitempty"`
|
||||
UserExternalKey string `json:"userExternalKey,omitempty"`
|
||||
UserName WorksmobileUserName `json:"userName,omitempty"`
|
||||
UserName WorksmobileUserName `json:"userName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
@@ -797,23 +797,26 @@ type WorksmobileOrgUnitPatchPayload struct {
|
||||
}
|
||||
|
||||
type WorksmobileRemoteUser struct {
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
Task string `json:"task"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
DomainName string `json:"domainName"`
|
||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
Task string `json:"task"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
DomainName string `json:"domainName"`
|
||||
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
||||
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
||||
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
||||
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type WorksmobileRemoteGroup struct {
|
||||
@@ -935,10 +938,18 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
UserName: email,
|
||||
Email: email,
|
||||
DisplayName: parseWorksmobileDirectoryUserName(resource),
|
||||
LevelID: parseWorksmobileUserLevelID(resource),
|
||||
LevelName: parseWorksmobileUserLevelName(resource),
|
||||
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
|
||||
Active: true,
|
||||
CellPhone: firstStringFromMap(resource, "cellPhone", "phoneNumber", "phone", "mobile", "mobilePhone"),
|
||||
EmployeeNumber: firstStringFromMap(
|
||||
resource,
|
||||
"employeeNumber",
|
||||
"employeeNo",
|
||||
"employeeId",
|
||||
"employeeID",
|
||||
),
|
||||
LevelID: parseWorksmobileUserLevelID(resource),
|
||||
LevelName: parseWorksmobileUserLevelName(resource),
|
||||
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
|
||||
Active: true,
|
||||
}
|
||||
if active, ok := resource["active"].(bool); ok {
|
||||
user.Active = active
|
||||
@@ -950,6 +961,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
|
||||
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
|
||||
user.OrgUnitManagers = parseWorksmobileOrgUnitManagers(resource)
|
||||
user.Organizations = parseWorksmobileUserOrganizations(resource)
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1072,6 +1084,84 @@ func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOr
|
||||
return worksmobileOrgUnitDetail{}
|
||||
}
|
||||
|
||||
func parseWorksmobileUserOrganizations(resource map[string]any) []WorksmobileUserOrganization {
|
||||
if organizations := parseWorksmobileUserOrganizationList(resource["organizations"]); len(organizations) > 0 {
|
||||
return organizations
|
||||
}
|
||||
for key, raw := range resource {
|
||||
if !strings.Contains(strings.ToLower(key), "works") {
|
||||
continue
|
||||
}
|
||||
if values, ok := raw.(map[string]any); ok {
|
||||
if organizations := parseWorksmobileUserOrganizationList(values["organizations"]); len(organizations) > 0 {
|
||||
return organizations
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseWorksmobileUserOrganizationList(raw any) []WorksmobileUserOrganization {
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
organizations := make([]WorksmobileUserOrganization, 0, len(values))
|
||||
for _, item := range values {
|
||||
organization, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
orgUnits := parseWorksmobileUserOrgUnitList(organization["orgUnits"])
|
||||
if len(orgUnits) == 0 {
|
||||
continue
|
||||
}
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: int64FromMap(organization, "domainId"),
|
||||
Email: firstStringFromMap(organization, "email"),
|
||||
Primary: boolFromMap(organization, "primary"),
|
||||
OrgUnits: orgUnits,
|
||||
})
|
||||
}
|
||||
return organizations
|
||||
}
|
||||
|
||||
func parseWorksmobileUserOrgUnitList(raw any) []WorksmobileUserOrgUnit {
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
orgUnits := make([]WorksmobileUserOrgUnit, 0, len(values))
|
||||
for _, item := range values {
|
||||
orgUnit, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := firstStringFromMap(orgUnit, "orgUnitExternalKey", "externalKey")
|
||||
if id != "" {
|
||||
id = "externalKey:" + id
|
||||
} else {
|
||||
id = firstStringFromMap(orgUnit, "orgUnitId", "id", "value")
|
||||
}
|
||||
if id == "" {
|
||||
if nested := parseWorksmobileUserOrgUnitList(orgUnit["orgUnits"]); len(nested) > 0 {
|
||||
orgUnits = append(orgUnits, nested...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
orgUnits = append(orgUnits, WorksmobileUserOrgUnit{
|
||||
OrgUnitID: id,
|
||||
Primary: boolFromMap(orgUnit, "primary"),
|
||||
PositionID: firstStringFromMap(orgUnit, "positionId"),
|
||||
IsManager: boolPointerFromMap(orgUnit, "isManager", "manager"),
|
||||
})
|
||||
if nested := parseWorksmobileUserOrgUnitList(orgUnit["orgUnits"]); len(nested) > 0 {
|
||||
orgUnits = append(orgUnits, nested...)
|
||||
}
|
||||
}
|
||||
return orgUnits
|
||||
}
|
||||
|
||||
func parseWorksmobileOrgUnitManagers(resource map[string]any) map[string]*bool {
|
||||
result := map[string]*bool{}
|
||||
collectWorksmobileOrgUnitManagers(resource["organizations"], result)
|
||||
@@ -1101,7 +1191,13 @@ func collectWorksmobileOrgUnitManagers(raw any, result map[string]*bool) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id := firstStringFromMap(orgUnit, "orgUnitId", "id", "value"); id != "" {
|
||||
id := firstStringFromMap(orgUnit, "orgUnitExternalKey", "externalKey")
|
||||
if id != "" {
|
||||
id = "externalKey:" + id
|
||||
} else {
|
||||
id = firstStringFromMap(orgUnit, "orgUnitId", "id", "value")
|
||||
}
|
||||
if id != "" {
|
||||
result[id] = boolPointerFromMap(orgUnit, "isManager", "manager")
|
||||
}
|
||||
collectWorksmobileOrgUnitManagers(orgUnit["organizations"], result)
|
||||
@@ -1194,6 +1290,19 @@ func boolFromMap(values map[string]any, key string) bool {
|
||||
return value
|
||||
}
|
||||
|
||||
func int64FromMap(values map[string]any, key string) int64 {
|
||||
switch value := values[key].(type) {
|
||||
case int:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case float64:
|
||||
return int64(value)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func boolPointerFromMap(values map[string]any, keys ...string) *bool {
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key].(bool); ok {
|
||||
|
||||
@@ -1033,8 +1033,10 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
|
||||
|
||||
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
|
||||
user := parseWorksmobileDirectoryUser(map[string]any{
|
||||
"userId": "works-user",
|
||||
"email": "tester@samaneng.com",
|
||||
"userId": "works-user",
|
||||
"email": "tester@samaneng.com",
|
||||
"cellPhone": "010-1234-5678",
|
||||
"employeeNumber": "EMP001",
|
||||
"userName": map[string]any{
|
||||
"lastName": "홍길동",
|
||||
},
|
||||
@@ -1062,6 +1064,17 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
|
||||
require.False(t, *user.OrgUnitManagers["externalKey:primary-org"])
|
||||
require.NotNil(t, user.OrgUnitManagers["externalKey:secondary-org"])
|
||||
require.True(t, *user.OrgUnitManagers["externalKey:secondary-org"])
|
||||
require.Equal(t, "010-1234-5678", user.CellPhone)
|
||||
require.Equal(t, "EMP001", user.EmployeeNumber)
|
||||
require.Equal(t, []WorksmobileUserOrganization{
|
||||
{
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:primary-org", Primary: true, IsManager: boolPtr(false)},
|
||||
{OrgUnitID: "externalKey:secondary-org", Primary: false, IsManager: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
}, user.Organizations)
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||
@@ -1075,6 +1088,10 @@ func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) {
|
||||
require.Equal(t, "tech-dev-center", group.MailLocalPart)
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
type fakeWorksmobileOutboxRepo struct {
|
||||
recent []domain.WorksmobileOutbox
|
||||
ready []domain.WorksmobileOutbox
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -1266,7 +1267,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
if !matched {
|
||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||
}
|
||||
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote)
|
||||
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote, localTenants)
|
||||
if matched && !includeMatched && !needsUpdate {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
@@ -1364,7 +1365,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
||||
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||
return true
|
||||
}
|
||||
@@ -1374,12 +1375,179 @@ func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser)
|
||||
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserPhoneNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants) {
|
||||
return true
|
||||
}
|
||||
if worksmobileUserManagerNeedsUpdate(user, remote) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
|
||||
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
|
||||
if localPhone == "" && remotePhone == "" {
|
||||
return false
|
||||
}
|
||||
return localPhone != remotePhone
|
||||
}
|
||||
|
||||
func normalizeWorksmobilePhoneForCompare(value string) string {
|
||||
normalized := strings.TrimSpace(value)
|
||||
normalized = strings.NewReplacer("-", "", " ", "", "(", "", ")", "").Replace(normalized)
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localEmployeeNumber := strings.TrimSpace(metadataEmployeeNumber(user.Metadata))
|
||||
remoteEmployeeNumber := strings.TrimSpace(remote.EmployeeNumber)
|
||||
if localEmployeeNumber == "" && remoteEmployeeNumber == "" {
|
||||
return false
|
||||
}
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
tenantID := strings.TrimSpace(*user.TenantID)
|
||||
if tenantID == "" {
|
||||
return false
|
||||
}
|
||||
tenant, ok := localTenants[tenantID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !worksmobileUserOrganizationsEqual(expected.Organizations, remote.Organizations)
|
||||
}
|
||||
|
||||
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
|
||||
for _, tenant := range localTenants {
|
||||
if strings.TrimSpace(tenant.Slug) == HanmacFamilyTenantSlug {
|
||||
return tenant.Config
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type worksmobileComparableOrgUnit struct {
|
||||
organizationPrimary bool
|
||||
organizationEmail string
|
||||
unitPrimary bool
|
||||
positionID string
|
||||
comparePosition bool
|
||||
manager *bool
|
||||
compareManager bool
|
||||
}
|
||||
|
||||
func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, remote []WorksmobileUserOrganization) bool {
|
||||
expectedUnits := flattenExpectedWorksmobileUserOrganizations(expected)
|
||||
remoteUnits := flattenRemoteWorksmobileUserOrganizations(remote)
|
||||
if len(expectedUnits) != len(remoteUnits) {
|
||||
return false
|
||||
}
|
||||
for key, expectedUnit := range expectedUnits {
|
||||
remoteUnit, ok := remoteUnits[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
if expectedUnit.comparePosition && strings.TrimSpace(expectedUnit.positionID) != strings.TrimSpace(remoteUnit.positionID) {
|
||||
return false
|
||||
}
|
||||
if expectedUnit.compareManager && !worksmobileBoolPointersEqual(expectedUnit.manager, remoteUnit.manager) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
|
||||
result := map[string]worksmobileComparableOrgUnit{}
|
||||
for _, organization := range organizations {
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = worksmobileComparableOrgUnit{
|
||||
organizationPrimary: organization.Primary,
|
||||
organizationEmail: strings.TrimSpace(organization.Email),
|
||||
unitPrimary: orgUnit.Primary,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
|
||||
manager: orgUnit.IsManager,
|
||||
compareManager: orgUnit.IsManager != nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
|
||||
result := map[string]worksmobileComparableOrgUnit{}
|
||||
for _, organization := range organizations {
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = worksmobileComparableOrgUnit{
|
||||
organizationPrimary: organization.Primary,
|
||||
organizationEmail: strings.TrimSpace(organization.Email),
|
||||
unitPrimary: orgUnit.Primary,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
manager: orgUnit.IsManager,
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileComparableOrgUnitKey(domainID int64, orgUnitID string) string {
|
||||
orgUnitID = strings.TrimSpace(orgUnitID)
|
||||
if domainID == 0 || orgUnitID == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d:%s", domainID, orgUnitID)
|
||||
}
|
||||
|
||||
func worksmobileBoolPointersEqual(left, right *bool) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
|
||||
localManagers := worksmobileUserExplicitOrgUnitManagers(user)
|
||||
if len(localManagers) == 0 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testing.T) {
|
||||
@@ -1476,6 +1477,102 @@ func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testin
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
primaryTenantID := "tenant-saman"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
secondaryTenantID := "tenant-gpdtdc-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-secondary-org",
|
||||
Email: "secondary-org@samaneng.com",
|
||||
Name: "Secondary Org User",
|
||||
TenantID: &secondaryTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": primaryTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondaryTenantID,
|
||||
"isPrimary": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
remotePrimaryManager := false
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-secondary-org",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + primaryTenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
|
||||
PrimaryOrgUnitIsManager: &remotePrimaryManager,
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
primaryTenantID: {ID: primaryTenantID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
|
||||
tenantID := "tenant-saman"
|
||||
user := domain.User{
|
||||
ID: "user-phone-employee",
|
||||
Email: "phone-employee@samaneng.com",
|
||||
Name: "Phone Employee User",
|
||||
Phone: "010-1234-5678",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"employeeNumber": "EMP001",
|
||||
},
|
||||
}
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-phone-employee",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
CellPhone: "+821099998888",
|
||||
EmployeeNumber: "EMP999",
|
||||
}},
|
||||
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
|
||||
@@ -1597,3 +1694,7 @@ func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID st
|
||||
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) DB() *gorm.DB {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user