forked from baron/baron-sso
kratos SSOT 재설계
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -120,6 +123,339 @@ func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecreatePendingWorksmobileUsersFromSnapshotCreatesOnlyMatchedUsers(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
tenantID := "22222222-2222-2222-2222-222222222222"
|
||||
userID := "33333333-3333-3333-3333-333333333333"
|
||||
client := &fakeWorksmobilePendingRecreateClient{}
|
||||
output := &strings.Builder{}
|
||||
writer := csv.NewWriter(output)
|
||||
|
||||
counts, err := recreatePendingWorksmobileUsersFromSnapshot(
|
||||
context.Background(),
|
||||
[]service.WorksmobileRemoteUser{
|
||||
{Email: "matched@samaneng.com", ID: "works-1", ExternalID: userID, DisplayName: "Matched"},
|
||||
{Email: "missing@samaneng.com", ID: "works-2", ExternalID: "44444444-4444-4444-4444-444444444444", DisplayName: "Missing"},
|
||||
},
|
||||
func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) {
|
||||
if remote.ExternalID != userID {
|
||||
return domain.User{}, false
|
||||
}
|
||||
return domain.User{
|
||||
ID: userID,
|
||||
Email: "matched@samaneng.com",
|
||||
Name: "Matched User",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}, true
|
||||
},
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany},
|
||||
tenantID: {ID: tenantID, Slug: "team", Name: "Team", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
},
|
||||
nil,
|
||||
"hanmac-family2026",
|
||||
0,
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
||||
}
|
||||
if counts.OK != 1 || counts.Skipped != 1 || counts.Errors != 0 {
|
||||
t.Fatalf("counts=%+v, want ok=1 skipped=1 errors=0", counts)
|
||||
}
|
||||
if len(client.patchedUsers) != 1 || client.patchedUsers[0].identifier != "matched@samaneng.com" {
|
||||
t.Fatalf("patched users=%v", client.patchedUsers)
|
||||
}
|
||||
if !strings.Contains(client.patchedUsers[0].payload.Email, ".old") {
|
||||
t.Fatalf("tombstone email=%q", client.patchedUsers[0].payload.Email)
|
||||
}
|
||||
if len(client.patchedUsers[0].payload.AliasEmails) != 0 {
|
||||
t.Fatalf("tombstone alias emails were not cleared: %v", client.patchedUsers[0].payload.AliasEmails)
|
||||
}
|
||||
if len(client.patchedUsers[0].payload.Organizations) == 0 || client.patchedUsers[0].payload.Organizations[0].Email != client.patchedUsers[0].payload.Email {
|
||||
t.Fatalf("tombstone organization email was not updated: %+v", client.patchedUsers[0].payload.Organizations)
|
||||
}
|
||||
if len(client.deletedUsers) != 1 || client.deletedUsers[0] != client.patchedUsers[0].payload.Email {
|
||||
t.Fatalf("deleted users=%v", client.deletedUsers)
|
||||
}
|
||||
if len(client.createdUsers) != 1 {
|
||||
t.Fatalf("created users=%d, want 1", len(client.createdUsers))
|
||||
}
|
||||
if client.createdUsers[0].PasswordConfig.Password != "hanmac-family2026" {
|
||||
t.Fatal("initial password was not applied to recreated user")
|
||||
}
|
||||
if strings.Contains(output.String(), "missing@samaneng.com") && !strings.Contains(output.String(), "baron user not found") {
|
||||
t.Fatalf("missing user skip reason was not written: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecreatePendingWorksmobileUsersFromSnapshotRollsBackWhenCreateFails(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
tenantID := "22222222-2222-2222-2222-222222222222"
|
||||
userID := "33333333-3333-3333-3333-333333333333"
|
||||
client := &fakeWorksmobilePendingRecreateClient{createErr: errors.New("create failed")}
|
||||
output := &strings.Builder{}
|
||||
writer := csv.NewWriter(output)
|
||||
|
||||
counts, err := recreatePendingWorksmobileUsersFromSnapshot(
|
||||
context.Background(),
|
||||
[]service.WorksmobileRemoteUser{{Email: "matched@samaneng.com", ID: "works-1", ExternalID: userID}},
|
||||
func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) {
|
||||
return domain.User{
|
||||
ID: userID,
|
||||
Email: "matched@samaneng.com",
|
||||
Name: "Matched User",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}, true
|
||||
},
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany},
|
||||
tenantID: {ID: tenantID, Slug: "team", Name: "Team", Type: domain.TenantTypeOrganization, ParentID: &rootID},
|
||||
},
|
||||
nil,
|
||||
"hanmac-family2026",
|
||||
0,
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
|
||||
}
|
||||
if counts.OK != 0 || counts.Errors != 1 {
|
||||
t.Fatalf("counts=%+v, want ok=0 errors=1", counts)
|
||||
}
|
||||
if len(client.patchedUsers) != 2 {
|
||||
t.Fatalf("patched users=%v", client.patchedUsers)
|
||||
}
|
||||
if client.patchedUsers[1].payload.Email != "matched@samaneng.com" {
|
||||
t.Fatalf("rollback email=%q, want matched@samaneng.com", client.patchedUsers[1].payload.Email)
|
||||
}
|
||||
if !strings.Contains(output.String(), "create failed") || !strings.Contains(output.String(), "ok") {
|
||||
t.Fatalf("rollback result was not written: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportHanmacWorksmobileUsersFromRowsSkipsExistingRemoteLocalPart(t *testing.T) {
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "300286336")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
companyID := "22222222-2222-2222-2222-222222222222"
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
client := &fakeWorksmobilePendingRecreateClient{}
|
||||
store := &fakeHanmacWorksmobileUserStore{}
|
||||
output := &strings.Builder{}
|
||||
writer := csv.NewWriter(output)
|
||||
|
||||
counts, err := importHanmacWorksmobileUsersFromRows(
|
||||
context.Background(),
|
||||
[]hanmacWorksmobileImportRow{{
|
||||
Email: "new@hanmaceng.co.kr",
|
||||
Name: "New User",
|
||||
Role: "user",
|
||||
TenantSlug: "infra-structures",
|
||||
EmployeeID: "M25001",
|
||||
SubEmail: "legacy@hanmaceng.co.kr",
|
||||
}},
|
||||
domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
map[string]domain.Tenant{
|
||||
"infra-structures": {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
companyID: {ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||
tenantID: {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
[]service.WorksmobileRemoteUser{{
|
||||
Email: "owner@hanmaceng.co.kr",
|
||||
AliasEmails: []string{"legacy@hanmaceng.co.kr"},
|
||||
}},
|
||||
nil,
|
||||
store,
|
||||
"hanmac-family2026",
|
||||
0,
|
||||
true,
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||
}
|
||||
if counts.OK != 0 || counts.Skipped != 1 || counts.Errors != 0 {
|
||||
t.Fatalf("counts=%+v, want ok=0 skipped=1 errors=0", counts)
|
||||
}
|
||||
if len(store.saved) != 0 {
|
||||
t.Fatalf("saved users=%d, want 0", len(store.saved))
|
||||
}
|
||||
if len(client.createdUsers) != 0 {
|
||||
t.Fatalf("created Worksmobile users=%d, want 0", len(client.createdUsers))
|
||||
}
|
||||
if !strings.Contains(output.String(), "legacy") || !strings.Contains(output.String(), "local-part already exists") {
|
||||
t.Fatalf("result did not include conflict reason: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportHanmacWorksmobileUsersFromRowsSavesBaronUserAndCreatesWorksmobileUser(t *testing.T) {
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "300286336")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
companyID := "22222222-2222-2222-2222-222222222222"
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
client := &fakeWorksmobilePendingRecreateClient{}
|
||||
store := &fakeHanmacWorksmobileUserStore{}
|
||||
output := &strings.Builder{}
|
||||
writer := csv.NewWriter(output)
|
||||
|
||||
counts, err := importHanmacWorksmobileUsersFromRows(
|
||||
context.Background(),
|
||||
[]hanmacWorksmobileImportRow{{
|
||||
Email: "new@hanmaceng.co.kr",
|
||||
Name: "New User",
|
||||
Phone: "010-1234-5678",
|
||||
Role: "user",
|
||||
TenantSlug: "infra-structures",
|
||||
Grade: "과장",
|
||||
EmployeeID: "M25001",
|
||||
SubEmail: "new.alias@hanmaceng.co.kr",
|
||||
}},
|
||||
domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
map[string]domain.Tenant{
|
||||
"infra-structures": {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
companyID: {ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||
tenantID: {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
store,
|
||||
"hanmac-family2026",
|
||||
0,
|
||||
true,
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||
}
|
||||
if counts.OK != 1 || counts.Skipped != 0 || counts.Errors != 0 || counts.BaronCreated != 1 {
|
||||
t.Fatalf("counts=%+v, want ok=1 baronCreated=1", counts)
|
||||
}
|
||||
if len(store.saved) != 1 {
|
||||
t.Fatalf("saved users=%d, want 1", len(store.saved))
|
||||
}
|
||||
if store.saved[0].TenantID == nil || *store.saved[0].TenantID != tenantID {
|
||||
t.Fatalf("saved tenant=%v, want %s", store.saved[0].TenantID, tenantID)
|
||||
}
|
||||
if store.saved[0].Metadata["employee_id"] != "M25001" || store.saved[0].Metadata["sub_email"] != "new.alias@hanmaceng.co.kr" {
|
||||
t.Fatalf("metadata=%v", store.saved[0].Metadata)
|
||||
}
|
||||
if len(client.createdUsers) != 1 {
|
||||
t.Fatalf("created Worksmobile users=%d, want 1", len(client.createdUsers))
|
||||
}
|
||||
if client.createdUsers[0].Email != "new@hanmaceng.co.kr" {
|
||||
t.Fatalf("created email=%q", client.createdUsers[0].Email)
|
||||
}
|
||||
if client.createdUsers[0].PasswordConfig.Password != "hanmac-family2026" {
|
||||
t.Fatal("initial password was not applied")
|
||||
}
|
||||
if !strings.Contains(strings.Join(client.createdUsers[0].AliasEmails, ","), "new.alias@hanmaceng.co.kr") {
|
||||
t.Fatalf("alias emails=%v", client.createdUsers[0].AliasEmails)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportHanmacWorksmobileUsersFromRowsKeepsExternalSubEmailOutOfWorksmobileAliases(t *testing.T) {
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "300286336")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
companyID := "22222222-2222-2222-2222-222222222222"
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
client := &fakeWorksmobilePendingRecreateClient{}
|
||||
store := &fakeHanmacWorksmobileUserStore{}
|
||||
output := &strings.Builder{}
|
||||
writer := csv.NewWriter(output)
|
||||
|
||||
counts, err := importHanmacWorksmobileUsersFromRows(
|
||||
context.Background(),
|
||||
[]hanmacWorksmobileImportRow{{
|
||||
Email: "external-alias@hanmaceng.co.kr",
|
||||
Name: "External Alias",
|
||||
Role: "user",
|
||||
TenantSlug: "infra-structures",
|
||||
EmployeeID: "M25002",
|
||||
SubEmail: "external@gmail.com",
|
||||
}},
|
||||
domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
map[string]domain.Tenant{
|
||||
"infra-structures": {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
|
||||
companyID: {ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||
tenantID: {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
store,
|
||||
"hanmac-family2026",
|
||||
0,
|
||||
true,
|
||||
writer,
|
||||
client,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
|
||||
}
|
||||
if counts.OK != 1 || counts.Errors != 0 || counts.Skipped != 0 {
|
||||
t.Fatalf("counts=%+v, want ok=1", counts)
|
||||
}
|
||||
if store.saved[0].Metadata["sub_email"] != nil {
|
||||
t.Fatalf("external sub_email should not be stored as Worksmobile alias metadata: %v", store.saved[0].Metadata)
|
||||
}
|
||||
if store.saved[0].Metadata["external_sub_email"] != "external@gmail.com" {
|
||||
t.Fatalf("external_sub_email=%v", store.saved[0].Metadata["external_sub_email"])
|
||||
}
|
||||
if strings.Contains(strings.Join(client.createdUsers[0].AliasEmails, ","), "external@gmail.com") {
|
||||
t.Fatalf("external sub email was sent as alias: %v", client.createdUsers[0].AliasEmails)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAdminctlWorksmobileOrgUnitPayloadClearsDomainRootParent(t *testing.T) {
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "300286336")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
companyID := "22222222-2222-2222-2222-222222222222"
|
||||
orgID := "33333333-3333-3333-3333-333333333333"
|
||||
root := domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}
|
||||
company := domain.Tenant{ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID}
|
||||
org := domain.Tenant{ID: orgID, Slug: "management-support", Name: "경영지원부", Type: domain.TenantTypeOrganization, ParentID: &companyID}
|
||||
|
||||
payload, err := buildAdminctlWorksmobileOrgUnitPayload(org, root, map[string]domain.Tenant{
|
||||
rootID: root,
|
||||
companyID: company,
|
||||
orgID: org,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("buildAdminctlWorksmobileOrgUnitPayload returned error: %v", err)
|
||||
}
|
||||
if payload.DomainID != 300286336 {
|
||||
t.Fatalf("domainID=%d, want 300286336", payload.DomainID)
|
||||
}
|
||||
if payload.Email != "management-support@hanmaceng.co.kr" {
|
||||
t.Fatalf("email=%q, want management-support@hanmaceng.co.kr", payload.Email)
|
||||
}
|
||||
if payload.ParentOrgUnitID != "" {
|
||||
t.Fatalf("parentOrgUnitID=%q, want empty for domain-root child", payload.ParentOrgUnitID)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWorksmobilePhoneAuditClient struct {
|
||||
users []service.WorksmobileRemoteUser
|
||||
patches []fakeWorksmobilePhonePatch
|
||||
@@ -138,3 +474,100 @@ func (f *fakeWorksmobilePhoneAuditClient) PatchUser(ctx context.Context, identif
|
||||
f.patches = append(f.patches, fakeWorksmobilePhonePatch{identifier: identifier, payload: payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeWorksmobilePendingRecreateClient struct {
|
||||
createdUsers []service.WorksmobileUserPayload
|
||||
deletedUsers []string
|
||||
undeletedUsers []string
|
||||
patchedUsers []fakeWorksmobilePendingRecreatePatch
|
||||
createErr error
|
||||
}
|
||||
|
||||
type fakeWorksmobilePendingRecreatePatch struct {
|
||||
identifier string
|
||||
payload service.WorksmobileUserPatchPayload
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) CreateOrgUnit(ctx context.Context, payload service.WorksmobileOrgUnitPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) UpsertOrgUnit(ctx context.Context, payload service.WorksmobileOrgUnitPayload, matchLocalPart string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) CreateUser(ctx context.Context, payload service.WorksmobileUserPayload) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
f.createdUsers = append(f.createdUsers, payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) UpsertUser(ctx context.Context, payload service.WorksmobileUserPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) DeleteUser(ctx context.Context, userID string) error {
|
||||
f.deletedUsers = append(f.deletedUsers, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error {
|
||||
f.patchedUsers = append(f.patchedUsers, fakeWorksmobilePendingRecreatePatch{identifier: identifier, payload: payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) SetUserActive(ctx context.Context, userID string, active bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) ListUsers(ctx context.Context) ([]service.WorksmobileRemoteUser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) ListGroups(ctx context.Context) ([]service.WorksmobileRemoteGroup, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobilePendingRecreateClient) UndeleteUser(ctx context.Context, userID string) error {
|
||||
f.undeletedUsers = append(f.undeletedUsers, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeHanmacWorksmobileUserStore struct {
|
||||
users map[string]domain.User
|
||||
saved []domain.User
|
||||
}
|
||||
|
||||
func (f *fakeHanmacWorksmobileUserStore) FindByEmail(ctx context.Context, email string) (domain.User, bool, error) {
|
||||
if f.users == nil {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
user, ok := f.users[strings.ToLower(strings.TrimSpace(email))]
|
||||
return user, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeHanmacWorksmobileUserStore) Save(ctx context.Context, user *domain.User) (bool, error) {
|
||||
created := true
|
||||
if f.users == nil {
|
||||
f.users = map[string]domain.User{}
|
||||
} else if _, ok := f.users[strings.ToLower(strings.TrimSpace(user.Email))]; ok {
|
||||
created = false
|
||||
}
|
||||
f.users[strings.ToLower(strings.TrimSpace(user.Email))] = *user
|
||||
f.saved = append(f.saved, *user)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -395,6 +395,16 @@ func main() {
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||
userHandler.UserProjectionRepo = userProjectionRepo
|
||||
userHandler.IdentityCache = redisService
|
||||
go func() {
|
||||
startedAt := time.Now()
|
||||
count, err := userHandler.WarmIdentityMirror(context.Background())
|
||||
if err != nil {
|
||||
slog.Warn("Identity mirror warmup failed", "error", err, "latency", time.Since(startedAt).String())
|
||||
return
|
||||
}
|
||||
slog.Info("Identity mirror warmup completed", "identities", count, "latency", time.Since(startedAt).String())
|
||||
}()
|
||||
tenantHandler.SetWorksmobileSyncer(worksmobileService)
|
||||
userHandler.SetWorksmobileSyncer(worksmobileService)
|
||||
worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService)
|
||||
|
||||
@@ -5,6 +5,7 @@ import "time"
|
||||
type IdentityCacheStatus struct {
|
||||
Status string `json:"status"`
|
||||
RedisReady bool `json:"redisReady"`
|
||||
MirrorVersion string `json:"mirrorVersion,omitempty"`
|
||||
ObservedCount int64 `json:"observedCount"`
|
||||
KeyCount int64 `json:"keyCount"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
|
||||
@@ -307,13 +307,6 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
}
|
||||
projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
cacheStatus := domain.IdentityCacheStatus{
|
||||
Status: "unavailable",
|
||||
@@ -321,6 +314,7 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
LastError: "identity cache service unavailable",
|
||||
}
|
||||
if h.IdentityCache != nil {
|
||||
var err error
|
||||
cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -328,8 +322,7 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"userProjection": projectionStatus,
|
||||
"identityCache": cacheStatus,
|
||||
"identityCache": cacheStatus,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t
|
||||
require.Equal(t, int64(152), body.ProjectedUsers)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t *testing.T) {
|
||||
func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) {
|
||||
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
|
||||
cache := &fakeIdentityCacheAdmin{
|
||||
status: domain.IdentityCacheStatus{
|
||||
@@ -222,15 +222,6 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
LastSyncedAt: &syncedAt,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
IdentityCache: cache,
|
||||
}
|
||||
app := fiber.New()
|
||||
@@ -246,11 +237,11 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
UserProjection domain.UserProjectionStatus `json:"userProjection"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
UserProjection *domain.UserProjectionStatus `json:"userProjection,omitempty"`
|
||||
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(152), body.UserProjection.ProjectedUsers)
|
||||
require.Nil(t, body.UserProjection)
|
||||
require.True(t, body.IdentityCache.RedisReady)
|
||||
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
|
||||
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OryProviderAPI defines the subset of Ory Provider used by UserHandler
|
||||
@@ -44,6 +43,7 @@ type UserHandler struct {
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
UserGroupRepo repository.UserGroupRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
IdentityCache domain.RedisRepository
|
||||
Worksmobile service.WorksmobileSyncer
|
||||
}
|
||||
|
||||
@@ -589,6 +589,24 @@ func profileTenantAccessKeys(profile *domain.UserProfileResponse) map[string]boo
|
||||
return allowed
|
||||
}
|
||||
|
||||
func identityMirrorKey(identityID string) string {
|
||||
return "identity:mirror:" + strings.TrimSpace(identityID)
|
||||
}
|
||||
|
||||
type identityMirrorLister interface {
|
||||
ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error)
|
||||
}
|
||||
|
||||
type identityMirrorStatusReader interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
}
|
||||
|
||||
type identityMirrorFlusher interface {
|
||||
FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error)
|
||||
}
|
||||
|
||||
const identityMirrorVersion = "kratos-full-pagination-v1"
|
||||
|
||||
func profileCanAccessTenant(profile *domain.UserProfileResponse, tenantID, tenantSlug string) bool {
|
||||
allowed := profileTenantAccessKeys(profile)
|
||||
if id := strings.ToLower(strings.TrimSpace(tenantID)); id != "" && allowed[id] {
|
||||
@@ -654,6 +672,26 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string
|
||||
return timestamp, identity.ID
|
||||
}
|
||||
|
||||
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
|
||||
if searchLower == "" {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(identity.ID), searchLower) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "email")), searchLower) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
|
||||
return true
|
||||
}
|
||||
rawTraits, err := json.Marshal(identity.Traits)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(rawTraits)), searchLower)
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
// [New] Get requester profile from middleware
|
||||
var requesterRole string
|
||||
@@ -745,161 +783,96 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if h.UserRepo != nil {
|
||||
var tenantIDs []string
|
||||
if tenantSlug != "" && targetTenantID == "" {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
|
||||
if requesterRole != domain.RoleSuperAdmin && tenantSlug != "" && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
|
||||
identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context())
|
||||
if err != nil {
|
||||
slog.Warn("Identity mirror unavailable for user list", "error", err)
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
|
||||
}
|
||||
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
searchLower := strings.ToLower(search)
|
||||
|
||||
for _, identity := range identities {
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
|
||||
// Tenant Admin & Member filtering
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
hasAccess := manageableSlugs[tID]
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" {
|
||||
if targetTenantID == "" {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
if requesterRole != domain.RoleSuperAdmin && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
}
|
||||
tenantIDs = append(tenantIDs, targetTenantID)
|
||||
} else if requesterRole != domain.RoleSuperAdmin {
|
||||
for key := range manageableSlugs {
|
||||
if _, err := uuid.Parse(key); err == nil {
|
||||
tenantIDs = append(tenantIDs, key)
|
||||
}
|
||||
}
|
||||
if len(tenantIDs) == 0 {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: 0,
|
||||
Cursor: cursorRaw,
|
||||
})
|
||||
matches := tID == targetTenantID
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, cursorRaw)
|
||||
if !identityMatchesSearch(identity, searchLower) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
|
||||
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
|
||||
total := int64(len(filtered))
|
||||
nextCursor := ""
|
||||
var pageIdentities []service.KratosIdentity
|
||||
if cursorRaw != "" {
|
||||
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list users")
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
items := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, h.mapLocalUserSummary(c.Context(), user))
|
||||
offset = 0
|
||||
} else {
|
||||
if offset > len(filtered) {
|
||||
offset = len(filtered)
|
||||
}
|
||||
if cursorRaw != "" {
|
||||
offset = 0
|
||||
end := min(offset+limit, len(filtered))
|
||||
pageIdentities = filtered[offset:end]
|
||||
if total > int64(end) && len(pageIdentities) > 0 {
|
||||
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
||||
nextCursor = pagination.Encode(lastTimestamp, lastID)
|
||||
}
|
||||
return c.JSON(userListResponse{
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
items := make([]userSummary, 0, len(pageIdentities))
|
||||
for _, identity := range pageIdentities {
|
||||
summary := h.mapIdentitySummary(c.Context(), identity)
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
identities, err := h.KratosAdmin.ListIdentities(c.Context())
|
||||
if err == nil {
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
searchLower := strings.ToLower(search)
|
||||
|
||||
for _, identity := range identities {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
|
||||
// Tenant Admin & Member filtering
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
hasAccess := manageableSlugs[tID]
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" {
|
||||
matches := tID == targetTenantID
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Search filtering
|
||||
if search != "" {
|
||||
matchesSearch := strings.Contains(email, searchLower) ||
|
||||
strings.Contains(name, searchLower)
|
||||
|
||||
if !matchesSearch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
|
||||
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
|
||||
total := int64(len(filtered))
|
||||
nextCursor := ""
|
||||
var pageIdentities []service.KratosIdentity
|
||||
if cursorRaw != "" {
|
||||
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
offset = 0
|
||||
} else {
|
||||
if offset > len(filtered) {
|
||||
offset = len(filtered)
|
||||
}
|
||||
end := min(offset+limit, len(filtered))
|
||||
pageIdentities = filtered[offset:end]
|
||||
if total > int64(end) && len(pageIdentities) > 0 {
|
||||
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
||||
nextCursor = pagination.Encode(lastTimestamp, lastID)
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, len(pageIdentities))
|
||||
for _, identity := range pageIdentities {
|
||||
summary := h.mapIdentitySummary(c.Context(), identity)
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
|
||||
// This ensures that member counts (which use local DB) eventually match reality
|
||||
if h.UserRepo != nil {
|
||||
go func(ids []service.KratosIdentity) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, identity := range ids {
|
||||
localUser := h.mapToLocalUser(identity)
|
||||
_ = h.UserRepo.Update(ctx, localUser)
|
||||
}
|
||||
}(filtered)
|
||||
}
|
||||
|
||||
return c.JSON(userListResponse{
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
slog.Warn("Kratos unavailable for user list", "error", err)
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
|
||||
return c.JSON(userListResponse{
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
@@ -912,26 +885,30 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
||||
}
|
||||
|
||||
if identity := h.getIdentityFromMirror(userID); identity != nil {
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester != nil && requester.Role != domain.RoleSuperAdmin {
|
||||
allowedKeys := profileTenantAccessKeys(requester)
|
||||
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
|
||||
}
|
||||
}
|
||||
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil || identity == nil {
|
||||
// [FIX] Support fixed UUID lookup fallback
|
||||
id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
|
||||
if searchErr == nil && id != "" {
|
||||
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
}
|
||||
|
||||
if err != nil || identity == nil {
|
||||
// Second Fallback: By Email from local DB
|
||||
if h.UserRepo != nil {
|
||||
local, _ := h.UserRepo.FindByID(c.Context(), userID)
|
||||
if local != nil && local.Email != "" {
|
||||
id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
|
||||
if id != "" {
|
||||
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
}
|
||||
}
|
||||
if cached := h.getIdentityFromMirror(id); cached != nil {
|
||||
identity = cached
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if searchErr == nil && id != "" && (err != nil || identity == nil) {
|
||||
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
}
|
||||
|
||||
if err != nil || identity == nil {
|
||||
if identity == nil {
|
||||
@@ -940,6 +917,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
h.storeIdentityMirror(*identity)
|
||||
|
||||
// [New] Check access scope
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
@@ -953,6 +931,149 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
|
||||
}
|
||||
|
||||
func (h *UserHandler) getIdentityFromMirror(identityID string) *service.KratosIdentity {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := h.IdentityCache.Get(identityMirrorKey(identityID))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var identity service.KratosIdentity
|
||||
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(identity.ID) == "" {
|
||||
return nil
|
||||
}
|
||||
return &identity
|
||||
}
|
||||
|
||||
func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
if h != nil && h.IdentityCache != nil {
|
||||
if lister, ok := h.IdentityCache.(identityMirrorLister); ok {
|
||||
identities, err := lister.ListIdentityMirrors(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.identityMirrorReady(ctx, len(identities)) {
|
||||
return identities, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil, errors.New("identity mirror is empty and kratos admin service is unavailable")
|
||||
}
|
||||
return h.rebuildIdentityMirror(ctx)
|
||||
}
|
||||
|
||||
func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) {
|
||||
identities, err := h.rebuildIdentityMirror(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(identities), nil
|
||||
}
|
||||
|
||||
func (h *UserHandler) rebuildIdentityMirror(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil, errors.New("kratos admin service is unavailable")
|
||||
}
|
||||
identities, err := h.KratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.flushIdentityMirror(ctx)
|
||||
for _, identity := range identities {
|
||||
h.storeIdentityMirror(identity)
|
||||
}
|
||||
h.markIdentityMirrorReady(len(identities))
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int) bool {
|
||||
if h == nil || h.IdentityCache == nil || identityCount == 0 {
|
||||
return false
|
||||
}
|
||||
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
status, err := reader.GetIdentityCacheStatus(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return status.RedisReady &&
|
||||
status.Status == "ready" &&
|
||||
status.MirrorVersion == identityMirrorVersion &&
|
||||
status.ObservedCount == int64(identityCount)
|
||||
}
|
||||
|
||||
func (h *UserHandler) flushIdentityMirror(ctx context.Context) {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return
|
||||
}
|
||||
flusher, ok := h.IdentityCache.(identityMirrorFlusher)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, _ = flusher.FlushIdentityCache(ctx)
|
||||
}
|
||||
|
||||
func (h *UserHandler) markIdentityMirrorReady(identityCount int) {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
status := domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
MirrorVersion: identityMirrorVersion,
|
||||
ObservedCount: int64(identityCount),
|
||||
LastRefreshedAt: &now,
|
||||
UpdatedAt: &now,
|
||||
}
|
||||
raw, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = h.IdentityCache.Set("identity:mirror:state", string(raw), 0)
|
||||
}
|
||||
|
||||
func (h *UserHandler) invalidateIdentityMirrorState() {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return
|
||||
}
|
||||
_ = h.IdentityCache.Delete("identity:mirror:state")
|
||||
}
|
||||
|
||||
func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) {
|
||||
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
|
||||
}
|
||||
|
||||
func (h *UserHandler) updateIdentityMirrorEntry(identity service.KratosIdentity) {
|
||||
h.storeIdentityMirror(identity)
|
||||
h.invalidateIdentityMirrorState()
|
||||
}
|
||||
|
||||
func (h *UserHandler) deleteIdentityMirrorEntry(identityID string) {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return
|
||||
}
|
||||
identityID = strings.TrimSpace(identityID)
|
||||
if identityID != "" {
|
||||
_ = h.IdentityCache.Delete(identityMirrorKey(identityID))
|
||||
}
|
||||
h.invalidateIdentityMirrorState()
|
||||
}
|
||||
|
||||
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if h.OryProvider == nil || h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
@@ -1171,8 +1292,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if identity == nil {
|
||||
h.invalidateIdentityMirrorState()
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||
}
|
||||
h.updateIdentityMirrorEntry(*identity)
|
||||
|
||||
// [New] Local DB Sync - Ensure user exists in read-model
|
||||
if h.UserRepo != nil {
|
||||
@@ -1672,6 +1795,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
} else {
|
||||
resultStatus = "created"
|
||||
h.invalidateIdentityMirrorState()
|
||||
slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID)
|
||||
}
|
||||
}
|
||||
@@ -2160,6 +2284,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||
continue
|
||||
}
|
||||
h.updateIdentityMirrorEntry(*updated)
|
||||
|
||||
// Sync to local DB
|
||||
if h.UserRepo != nil {
|
||||
@@ -2267,6 +2392,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||
continue
|
||||
}
|
||||
h.deleteIdentityMirrorEntry(id)
|
||||
if h.Worksmobile != nil {
|
||||
localUser := h.mapToLocalUser(*identity)
|
||||
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
|
||||
@@ -2635,6 +2761,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.updateIdentityMirrorEntry(*updated)
|
||||
|
||||
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
|
||||
if h.UserRepo != nil {
|
||||
@@ -2807,6 +2934,10 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
h.deleteIdentityMirrorEntry(userID)
|
||||
if actualKratosID != userID {
|
||||
h.deleteIdentityMirrorEntry(actualKratosID)
|
||||
}
|
||||
slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID)
|
||||
|
||||
if h.Worksmobile != nil && identity != nil {
|
||||
@@ -3003,16 +3134,6 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
traits := identity.Traits
|
||||
role := roleFromTraits(traits)
|
||||
|
||||
// [FIX] Prioritize Local DB ID (the fixed UUID from user)
|
||||
finalID := identity.ID
|
||||
email := extractTraitString(traits, "email")
|
||||
if h.UserRepo != nil && email != "" {
|
||||
// 1. Try finding by email first as it's a strong identifier
|
||||
if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil {
|
||||
finalID = local.ID
|
||||
}
|
||||
}
|
||||
|
||||
tenantID := extractTraitString(traits, "tenant_id")
|
||||
tenantSlug := ""
|
||||
var tenantSummary *domain.Tenant
|
||||
@@ -3038,7 +3159,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
}
|
||||
|
||||
summary := userSummary{
|
||||
ID: finalID,
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
LoginID: resolvePasswordLoginID(traits),
|
||||
CustomLoginIDs: customLoginIDs,
|
||||
|
||||
46
backend/internal/handler/user_handler_live_test.go
Normal file
46
backend/internal/handler/user_handler_live_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUserHandler_LiveWarmIdentityMirrorLatency(t *testing.T) {
|
||||
if os.Getenv("BARON_LIVE_IDENTITY_MIRROR_TEST") != "1" {
|
||||
t.Skip("set BARON_LIVE_IDENTITY_MIRROR_TEST=1 to run against local Kratos and Redis")
|
||||
}
|
||||
|
||||
redisService, err := service.NewRedisService()
|
||||
if err != nil {
|
||||
t.Fatalf("connect redis: %v", err)
|
||||
}
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
handler := &UserHandler{
|
||||
KratosAdmin: kratosAdmin,
|
||||
IdentityCache: redisService,
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
count, err := handler.WarmIdentityMirror(context.Background())
|
||||
elapsed := time.Since(startedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("warm identity mirror: %v", err)
|
||||
}
|
||||
|
||||
maxMillis := int64(2000)
|
||||
if raw := os.Getenv("BARON_LIVE_IDENTITY_MIRROR_MAX_MS"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
t.Fatalf("invalid BARON_LIVE_IDENTITY_MIRROR_MAX_MS=%q", raw)
|
||||
}
|
||||
maxMillis = parsed
|
||||
}
|
||||
t.Logf("identity mirror warmup identities=%d elapsed=%s max=%dms", count, elapsed, maxMillis)
|
||||
if elapsed > time.Duration(maxMillis)*time.Millisecond {
|
||||
t.Fatalf("identity mirror warmup took %s, over %dms", elapsed, maxMillis)
|
||||
}
|
||||
}
|
||||
@@ -981,15 +981,88 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
type identityMirrorRedisStub struct {
|
||||
mockRedisRepo
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
identities := make([]service.KratosIdentity, 0, len(s.data))
|
||||
for key, raw := range s.data {
|
||||
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
|
||||
continue
|
||||
}
|
||||
var identity service.KratosIdentity
|
||||
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(identity.ID) == "" {
|
||||
continue
|
||||
}
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
raw := s.data["identity:mirror:state"]
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return domain.IdentityCacheStatus{RedisReady: true, Status: "empty"}, nil
|
||||
}
|
||||
var status domain.IdentityCacheStatus
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return domain.IdentityCacheStatus{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
var deleted int64
|
||||
for key := range s.data {
|
||||
if strings.HasPrefix(key, "identity:mirror:") || strings.HasPrefix(key, "identity:index:") {
|
||||
delete(s.data, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: deleted,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC)
|
||||
mirrorIdentity := service.KratosIdentity{
|
||||
ID: "mirror-user-1",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "mirror1@example.com",
|
||||
"name": "Mirror One",
|
||||
},
|
||||
}
|
||||
rawMirrorIdentity, err := json.Marshal(mirrorIdentity)
|
||||
require.NoError(t, err)
|
||||
state := domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
MirrorVersion: identityMirrorVersion,
|
||||
ObservedCount: 1,
|
||||
}
|
||||
rawState, err := json.Marshal(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
|
||||
"identity:mirror:state": string(rawState),
|
||||
}}},
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -1000,19 +1073,6 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{}, errors.New("kratos down")).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 10, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), "", nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
@@ -1023,19 +1083,21 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(1), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
mockRepo.AssertExpectations(t)
|
||||
require.Equal(t, "mirror-user-1", res.Items[0].ID)
|
||||
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *testing.T) {
|
||||
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
|
||||
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -1046,27 +1108,11 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
kratosIdentities := make([]service.KratosIdentity, 250)
|
||||
for i := range kratosIdentities {
|
||||
kratosIdentities[i] = service.KratosIdentity{
|
||||
ID: "kratos-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt.Add(-time.Duration(i) * time.Second),
|
||||
Traits: map[string]any{"email": "kratos@example.com", "name": "Kratos"},
|
||||
}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 50, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(2114), "next-local-cursor", nil)
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -1076,11 +1122,162 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2114), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
require.Equal(t, "next-local-cursor", res.NextCursor)
|
||||
mockRepo.AssertExpectations(t)
|
||||
require.Equal(t, int64(2), res.Total)
|
||||
require.Len(t, res.Items, 2)
|
||||
require.Equal(t, "kratos-user-1", res.Items[0].ID)
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
|
||||
var status domain.IdentityCacheStatus
|
||||
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
|
||||
require.Equal(t, "ready", status.Status)
|
||||
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
|
||||
require.Equal(t, int64(2), status.ObservedCount)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
|
||||
}}}
|
||||
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
|
||||
identities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Second), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(identities, nil).Once()
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
count, err := h.WarmIdentityMirror(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, count)
|
||||
require.Empty(t, redis.data[identityMirrorKey("stale-user")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
|
||||
var status domain.IdentityCacheStatus
|
||||
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
|
||||
require.Equal(t, "ready", status.Status)
|
||||
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
|
||||
require.Equal(t, int64(2), status.ObservedCount)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 55, 0, 0, time.UTC)
|
||||
legacyIdentity := service.KratosIdentity{
|
||||
ID: "legacy-partial-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "legacy@example.com",
|
||||
"name": "Legacy Partial",
|
||||
},
|
||||
}
|
||||
rawLegacyIdentity, err := json.Marshal(legacyIdentity)
|
||||
require.NoError(t, err)
|
||||
legacyState := domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
ObservedCount: 1,
|
||||
}
|
||||
rawLegacyState, err := json.Marshal(legacyState)
|
||||
require.NoError(t, err)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
|
||||
"identity:mirror:state": string(rawLegacyState),
|
||||
}}}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2), res.Total)
|
||||
var status domain.IdentityCacheStatus
|
||||
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
|
||||
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 50, 0, 0, time.UTC)
|
||||
partialIdentity := service.KratosIdentity{
|
||||
ID: "partial-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "partial@example.com",
|
||||
"name": "Partial",
|
||||
},
|
||||
}
|
||||
rawPartialIdentity, err := json.Marshal(partialIdentity)
|
||||
require.NoError(t, err)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
|
||||
}}}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2), res.Total)
|
||||
require.Empty(t, redis.data[identityMirrorKey("partial-user")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
@@ -1117,6 +1314,86 @@ func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
require.Equal(t, int64(3), res.Total)
|
||||
}
|
||||
|
||||
func TestUserHandler_GetUserUsesIdentityMirrorBeforeKratos(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
createdAt := time.Date(2026, 6, 12, 8, 20, 0, 0, time.UTC)
|
||||
userID := "2b7fd276-b25f-45ef-b691-ea9d72e701e1"
|
||||
identity := service.KratosIdentity{
|
||||
ID: userID,
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "mirror-user@example.com",
|
||||
"name": "Mirror User",
|
||||
},
|
||||
}
|
||||
rawIdentity, err := json.Marshal(identity)
|
||||
require.NoError(t, err)
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(userID): string(rawIdentity),
|
||||
}}
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users/:id", h.GetUser)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/"+userID, nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got userSummary
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
require.Equal(t, userID, got.ID)
|
||||
require.Equal(t, "mirror-user@example.com", got.Email)
|
||||
require.Equal(t, "Mirror User", got.Name)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "FindIdentityIDByIdentifier", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
|
||||
}}
|
||||
h := &UserHandler{IdentityCache: redis}
|
||||
identity := service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
Traits: map[string]any{
|
||||
"email": "user1@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
h.updateIdentityMirrorEntry(identity)
|
||||
|
||||
require.Empty(t, redis.data["identity:mirror:state"])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("user-1")])
|
||||
}
|
||||
|
||||
func TestUserHandler_DeleteIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
|
||||
identityMirrorKey("u-1"): `{"id":"u-1"}`,
|
||||
}}
|
||||
h := &UserHandler{IdentityCache: redis}
|
||||
|
||||
h.deleteIdentityMirrorEntry("u-1")
|
||||
|
||||
require.Empty(t, redis.data["identity:mirror:state"])
|
||||
require.Empty(t, redis.data[identityMirrorKey("u-1")])
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
|
||||
@@ -213,5 +213,19 @@ func worksmobileGuardError(c *fiber.Ctx, err error, operation string, attrs ...a
|
||||
if strings.Contains(err.Error(), "hanmac-family root") {
|
||||
return errorJSON(c, fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
if worksmobileBadRequestError(err) {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
func worksmobileBadRequestError(err error) bool {
|
||||
message := err.Error()
|
||||
return strings.Contains(message, "target user tenant is excluded from Worksmobile sync") ||
|
||||
strings.Contains(message, "target user is outside hanmac-family subtree") ||
|
||||
strings.Contains(message, "target user has no tenant") ||
|
||||
strings.Contains(message, "target user status is excluded from Worksmobile sync") ||
|
||||
strings.Contains(message, "target tenant is excluded from Worksmobile sync") ||
|
||||
strings.Contains(message, "target tenant is not a worksmobile orgunit tenant") ||
|
||||
strings.Contains(message, "target orgunit is outside hanmac-family subtree")
|
||||
}
|
||||
|
||||
@@ -195,6 +195,19 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
|
||||
require.Contains(t, logs.String(), "works user sync failed")
|
||||
}
|
||||
|
||||
func TestWorksmobileHandlerReturnsBadRequestForOutOfScopeUserSync(t *testing.T) {
|
||||
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
|
||||
syncUserErr: errors.New("target user tenant is excluded from Worksmobile sync"),
|
||||
})
|
||||
app := fiber.New()
|
||||
app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
type fakeWorksmobileAdminService struct {
|
||||
overview service.WorksmobileTenantOverview
|
||||
credentials []service.WorksmobileInitialPasswordCredential
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
@@ -199,6 +200,39 @@ func (s *RedisService) FlushIdentityCache(ctx context.Context) (domain.IdentityC
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identities := make([]KratosIdentity, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if key == "identity:mirror:state" || !strings.HasPrefix(key, "identity:mirror:") {
|
||||
continue
|
||||
}
|
||||
raw, err := s.Client.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var identity KratosIdentity
|
||||
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(identity.ID) == "" {
|
||||
continue
|
||||
}
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -434,6 +434,9 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if c.directoryAuthConfigured() && strings.Contains(userID, "@") {
|
||||
return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(userID), nil)
|
||||
}
|
||||
remote, err := c.FindUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -450,6 +453,14 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e
|
||||
return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ForceDeleteUser(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.MethodDelete, "/v1.0/users/"+url.PathEscape(userID)+"/forcedelete", nil)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string, active bool) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
@@ -465,7 +476,18 @@ func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string
|
||||
if remote == nil {
|
||||
return nil
|
||||
}
|
||||
return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{
|
||||
return c.SetSCIMUserActiveByID(ctx, remote.ID, active)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) SetSCIMUserActiveByID(ctx context.Context, scimID string, active bool) error {
|
||||
scimID = strings.TrimSpace(scimID)
|
||||
if scimID == "" {
|
||||
return fmt.Errorf("worksmobile scim user id is required")
|
||||
}
|
||||
if strings.TrimSpace(c.SCIMToken) == "" {
|
||||
return fmt.Errorf("worksmobile scim token is not configured")
|
||||
}
|
||||
return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(scimID), map[string]any{
|
||||
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
"Operations": []map[string]any{
|
||||
{
|
||||
@@ -926,6 +948,7 @@ type WorksmobileRemoteUser struct {
|
||||
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
AccountStatus string `json:"accountStatus,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
IsAwaiting bool `json:"isAwaiting"`
|
||||
IsPending bool `json:"isPending"`
|
||||
@@ -1010,12 +1033,21 @@ func worksmobileSCIMPreferredLanguage(locale string) string {
|
||||
}
|
||||
|
||||
func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser {
|
||||
active := boolFromMap(resource, "active")
|
||||
user := WorksmobileRemoteUser{
|
||||
ID: stringFromMap(resource, "id"),
|
||||
ExternalID: stringFromMap(resource, "externalId"),
|
||||
UserName: stringFromMap(resource, "userName"),
|
||||
DisplayName: stringFromMap(resource, "displayName"),
|
||||
Active: boolFromMap(resource, "active"),
|
||||
AccountStatus: normalizeWorksmobileAccountStatus(
|
||||
firstStringFromMap(resource, "accountStatus", "status", "userStatus"),
|
||||
active,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Active: active,
|
||||
}
|
||||
if emails, ok := resource["emails"].([]any); ok {
|
||||
for _, raw := range emails {
|
||||
@@ -1077,6 +1109,14 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
user.IsPending = boolFromMap(resource, "isPending")
|
||||
user.IsSuspended = boolFromMap(resource, "isSuspended")
|
||||
user.IsDeleted = boolFromMap(resource, "isDeleted")
|
||||
user.AccountStatus = normalizeWorksmobileAccountStatus(
|
||||
firstStringFromMap(resource, "accountStatus", "status", "userStatus", "loginStatus"),
|
||||
user.Active,
|
||||
user.IsAwaiting,
|
||||
user.IsPending,
|
||||
user.IsSuspended,
|
||||
user.IsDeleted,
|
||||
)
|
||||
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
|
||||
user.PrimaryOrgUnitID = primaryOrgUnit.ID
|
||||
user.PrimaryOrgUnitName = primaryOrgUnit.Name
|
||||
@@ -1088,6 +1128,37 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
return user
|
||||
}
|
||||
|
||||
func normalizeWorksmobileAccountStatus(raw string, active bool, awaiting bool, pending bool, suspended bool, deleted bool) string {
|
||||
status := strings.ToLower(strings.TrimSpace(raw))
|
||||
status = strings.ReplaceAll(status, "-", "_")
|
||||
status = strings.ReplaceAll(status, " ", "_")
|
||||
switch status {
|
||||
case "deleted", "delete", "removed":
|
||||
return "deleted"
|
||||
case "suspended", "suspend", "blocked", "disabled":
|
||||
return "suspended"
|
||||
case "invited", "invite", "awaiting", "pending", "waiting", "not_activated", "unactivated":
|
||||
return "invited"
|
||||
case "inactive", "deactivated", "false":
|
||||
return "inactive"
|
||||
case "active", "enabled", "true":
|
||||
return "active"
|
||||
}
|
||||
if deleted {
|
||||
return "deleted"
|
||||
}
|
||||
if suspended {
|
||||
return "suspended"
|
||||
}
|
||||
if awaiting || pending {
|
||||
return "invited"
|
||||
}
|
||||
if !active {
|
||||
return "inactive"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup {
|
||||
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
|
||||
return WorksmobileRemoteGroup{
|
||||
|
||||
@@ -64,6 +64,26 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
|
||||
require.Len(t, passwordConfig["password"], 16)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
statusCode: http.StatusOK,
|
||||
body: `{}`,
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.DeleteUser(context.Background(), "target@samaneng.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 1)
|
||||
require.Equal(t, http.MethodDelete, transport.requests[0].Method)
|
||||
require.Equal(t, "/v1.0/users/target@samaneng.com", transport.requests[0].URL.Path)
|
||||
require.Equal(t, "Bearer directory-token-1", transport.requests[0].Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||
payload := NewWorksmobileUserPatchPayload(WorksmobileUserPayload{
|
||||
DomainID: 1001,
|
||||
@@ -975,6 +995,27 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) {
|
||||
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIncludesWorksAccountStatus(t *testing.T) {
|
||||
localUsers := []domain.User{
|
||||
{ID: "user-1", Email: "suspended@samaneng.com", Name: "Suspended"},
|
||||
}
|
||||
remoteUsers := []WorksmobileRemoteUser{
|
||||
{
|
||||
ID: "works-1",
|
||||
ExternalID: "user-1",
|
||||
Email: "suspended@samaneng.com",
|
||||
DisplayName: "Suspended",
|
||||
Active: false,
|
||||
IsSuspended: true,
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "suspended", items[0].WorksmobileAccountStatus)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) {
|
||||
localUsers := []domain.User{
|
||||
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -106,6 +108,8 @@ type WorksmobileComparisonItem struct {
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
@@ -116,6 +120,9 @@ type WorksmobileComparisonItem struct {
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
@@ -571,7 +578,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: action,
|
||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
}
|
||||
item.Payload["displayName"] = strings.TrimSpace(user.Name)
|
||||
@@ -587,6 +594,10 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func worksmobileUserSyncDedupeKey(action, userID string) string {
|
||||
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
|
||||
root, err := s.hanmacRoot(ctx, tenantID)
|
||||
if err != nil {
|
||||
@@ -880,7 +891,7 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: action,
|
||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
})
|
||||
}
|
||||
@@ -1461,6 +1472,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
BaronID: user.ID,
|
||||
BaronName: user.Name,
|
||||
BaronEmail: user.Email,
|
||||
BaronPhone: user.Phone,
|
||||
BaronEmployeeNumber: metadataEmployeeNumber(user.Metadata),
|
||||
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
|
||||
BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
|
||||
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
||||
@@ -1483,6 +1496,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
item.ExternalKey = remote.ExternalID
|
||||
item.WorksmobileName = remote.DisplayName
|
||||
item.WorksmobileEmail = remote.Email
|
||||
item.WorksmobilePhone = remote.CellPhone
|
||||
item.WorksmobileEmployeeNumber = remote.EmployeeNumber
|
||||
item.WorksmobileAccountStatus = worksmobileRemoteAccountStatus(remote)
|
||||
item.WorksmobileLevelID = remote.LevelID
|
||||
item.WorksmobileLevelName = remote.LevelName
|
||||
item.WorksmobileTask = remote.Task
|
||||
@@ -1511,6 +1527,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
ExternalKey: remote.ExternalID,
|
||||
WorksmobileName: remote.DisplayName,
|
||||
WorksmobileEmail: remote.Email,
|
||||
WorksmobilePhone: remote.CellPhone,
|
||||
WorksmobileEmployeeNumber: remote.EmployeeNumber,
|
||||
WorksmobileAccountStatus: worksmobileRemoteAccountStatus(remote),
|
||||
WorksmobileLevelID: remote.LevelID,
|
||||
WorksmobileLevelName: remote.LevelName,
|
||||
WorksmobileTask: remote.Task,
|
||||
@@ -1532,6 +1551,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
ExternalKey: remote.ExternalID,
|
||||
WorksmobileName: remote.DisplayName,
|
||||
WorksmobileEmail: remote.Email,
|
||||
WorksmobilePhone: remote.CellPhone,
|
||||
WorksmobileEmployeeNumber: remote.EmployeeNumber,
|
||||
WorksmobileAccountStatus: worksmobileRemoteAccountStatus(remote),
|
||||
WorksmobileLevelID: remote.LevelID,
|
||||
WorksmobileLevelName: remote.LevelName,
|
||||
WorksmobileTask: remote.Task,
|
||||
@@ -1549,6 +1571,17 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
|
||||
return normalizeWorksmobileAccountStatus(
|
||||
remote.AccountStatus,
|
||||
remote.Active,
|
||||
remote.IsAwaiting,
|
||||
remote.IsPending,
|
||||
remote.IsSuspended,
|
||||
remote.IsDeleted,
|
||||
)
|
||||
}
|
||||
|
||||
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
|
||||
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
|
||||
return true
|
||||
|
||||
@@ -204,6 +204,88 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T)
|
||||
require.Empty(t, request.PasswordConfig.Password)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(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,
|
||||
)
|
||||
|
||||
first, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
|
||||
require.NoError(t, err)
|
||||
second, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
require.NotNil(t, second)
|
||||
require.Len(t, outboxRepo.created, 2)
|
||||
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), target))
|
||||
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), target))
|
||||
|
||||
require.Len(t, outboxRepo.created, 2)
|
||||
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
@@ -2050,6 +2132,10 @@ func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Equal(t, user.Phone, items[0].BaronPhone)
|
||||
require.Equal(t, "+821099998888", items[0].WorksmobilePhone)
|
||||
require.Equal(t, "EMP001", items[0].BaronEmployeeNumber)
|
||||
require.Equal(t, "EMP999", items[0].WorksmobileEmployeeNumber)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user