1
0
forked from baron/baron-sso

kratos SSOT 재설계

This commit is contained in:
2026-06-12 18:36:18 +09:00
parent b96c8100e0
commit 8e9d015443
39 changed files with 3960 additions and 501 deletions

View File

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