1
0
forked from baron/baron-sso

worksmobile 연동 & ory stack 26.2.0으로 업그레이드

This commit is contained in:
2026-05-06 09:30:00 +09:00
parent 3dcdd97882
commit 2495fcb13d
74 changed files with 8698 additions and 212 deletions

View File

@@ -0,0 +1,176 @@
package main
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"flag"
"fmt"
"log"
"log/slog"
"os"
"strings"
"time"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
type createSuperAdminConfig struct {
Email string
Password string
Name string
UpdatePassword bool
}
func main() {
loadEnv()
logger.Init(logger.Config{
ServiceName: "baron-sso-adminctl",
Environment: getenv("APP_ENV", getenv("GO_ENV", "dev")),
LevelOverride: getenv("BACKEND_LOG_LEVEL", ""),
})
if len(os.Args) < 2 {
printUsage()
os.Exit(2)
}
switch os.Args[1] {
case "create-super-admin":
if err := runCreateSuperAdmin(os.Args[2:]); err != nil {
slog.Error("create-super-admin failed", "error", err)
os.Exit(1)
}
default:
printUsage()
os.Exit(2)
}
}
func runCreateSuperAdmin(args []string) error {
config, err := resolveCreateSuperAdminConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
if err := bootstrap.Run(db); err != nil {
return err
}
provider, err := idp.InitializeProvider()
if err != nil {
return err
}
if provider == nil {
return fmt.Errorf("idp provider is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := bootstrap.EnsureSuperAdmin(
ctx,
service.NewKratosAdminService(),
bootstrap.NewGormSuperAdminStore(db, repository.NewKetoOutboxRepository(db)),
bootstrap.EnsureSuperAdminOptions{
Email: config.Email,
Password: config.Password,
Name: config.Name,
Source: "adminctl",
UpdatePassword: config.UpdatePassword,
},
)
if err != nil {
return err
}
fmt.Printf("super admin ensured: email=%s identity_id=%s user_id=%s identity_created=%t local_created=%t local_updated=%t password_updated=%t keto_relation_queued=%t\n",
result.Email,
result.IdentityID,
result.LocalUserID,
result.IdentityCreated,
result.LocalUserCreated,
result.LocalUserUpdated,
result.PasswordUpdated,
result.KetoRelationQueued,
)
return nil
}
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := createSuperAdminConfig{}
fs.StringVar(&config.Email, "email", getenv("ADMIN_EMAIL", ""), "admin email")
fs.StringVar(&config.Password, "password", getenv("ADMIN_PASSWORD", ""), "admin password")
fs.StringVar(&config.Name, "name", getenv("ADMIN_NAME", "System Admin"), "admin display name")
fs.BoolVar(&config.UpdatePassword, "update-password", false, "update password when identity already exists")
if err := fs.Parse(args); err != nil {
return config, err
}
config.Email = strings.TrimSpace(config.Email)
config.Name = strings.TrimSpace(config.Name)
if config.Email == "" {
return config, fmt.Errorf("admin email is required; pass --email or set ADMIN_EMAIL")
}
if strings.TrimSpace(config.Password) == "" {
return config, fmt.Errorf("admin password is required; pass --password or set ADMIN_PASSWORD")
}
if config.Name == "" {
config.Name = "System Admin"
}
return config, nil
}
func openDB() (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
getenv("DB_HOST", "localhost"),
getenv("DB_USER", "baron"),
getenv("DB_PASSWORD", "password"),
getenv("DB_NAME", "baron_sso"),
getenv("DB_PORT", "5432"),
)
return gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormLogger.Config{
SlowThreshold: time.Second,
LogLevel: gormLogger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
),
})
}
func loadEnv() {
_ = godotenv.Load(".env")
_ = godotenv.Load("../.env")
_ = godotenv.Load("../../.env")
}
func getenv(key string, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
}

View File

@@ -0,0 +1,62 @@
package main
import "testing"
func TestResolveCreateSuperAdminConfigUsesEnvDefaults(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "admin@example.com")
t.Setenv("ADMIN_PASSWORD", "Password!123")
t.Setenv("ADMIN_NAME", "Env Admin")
config, err := resolveCreateSuperAdminConfig([]string{})
if err != nil {
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
}
if config.Email != "admin@example.com" {
t.Fatalf("email = %q", config.Email)
}
if config.Password != "Password!123" {
t.Fatal("password was not read from ADMIN_PASSWORD")
}
if config.Name != "Env Admin" {
t.Fatalf("name = %q", config.Name)
}
}
func TestResolveCreateSuperAdminConfigAllowsFlagOverrides(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "admin@example.com")
t.Setenv("ADMIN_PASSWORD", "Password!123")
t.Setenv("ADMIN_NAME", "Env Admin")
config, err := resolveCreateSuperAdminConfig([]string{
"--email", "flag@example.com",
"--password", "FlagPassword!123",
"--name", "Flag Admin",
"--update-password",
})
if err != nil {
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
}
if config.Email != "flag@example.com" {
t.Fatalf("email = %q", config.Email)
}
if config.Password != "FlagPassword!123" {
t.Fatal("password flag was not used")
}
if config.Name != "Flag Admin" {
t.Fatalf("name = %q", config.Name)
}
if !config.UpdatePassword {
t.Fatal("update password flag was not set")
}
}
func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "")
t.Setenv("ADMIN_PASSWORD", "")
if _, err := resolveCreateSuperAdminConfig([]string{}); err == nil {
t.Fatal("expected error")
}
}

View File

@@ -16,6 +16,7 @@ import (
"log/slog"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -39,6 +40,34 @@ func getEnv(key, fallback string) string {
return fallback
}
func getEnvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) {
if path := strings.TrimSpace(getEnv(fileKey, "")); path != "" {
value, err := readEnvFileValue(path)
if err != nil {
return "", err
}
return value, nil
}
return getEnv(valueKey, fallback), nil
}
func readEnvFileValue(path string) (string, error) {
candidates := []string{path}
if !filepath.IsAbs(path) {
candidates = append(candidates, filepath.Join("..", path), filepath.Join("..", "..", path))
}
var lastErr error
for _, candidate := range candidates {
data, err := os.ReadFile(candidate)
if err == nil {
return string(data), nil
}
lastErr = err
}
return "", fmt.Errorf("read secret file %q: %w", path, lastErr)
}
func normalizeDocsPrefix(prefix string) string {
trimmed := strings.TrimSpace(prefix)
if trimmed == "" || trimmed == "/" {
@@ -268,11 +297,32 @@ func main() {
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
sharedLinkRepo := repository.NewSharedLinkRepository(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
if err != nil {
slog.Error("Worksmobile private key file could not be loaded", "error", err)
os.Exit(1)
}
worksmobileClient := service.NewWorksmobileHTTPClientWithAuth(
getEnv("WORKS_ADMIN_ACCESS_TOKEN", getEnv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN", "")),
getEnv("SAMAN_SCIM_LONGLIVE_TOKEN", ""),
service.WorksmobileOAuthConfig{
ClientID: getEnv("WORKS_ADMIN_OAUTH_CLIENT_ID", ""),
ClientSecret: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SECRET", ""),
ServiceAccount: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""),
PrivateKey: worksmobilePrivateKey,
Scope: getEnv("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
},
)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
go worksmobileRelayWorker.Start(context.Background())
slog.Info("✅ Worksmobile Relay Worker started")
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
@@ -301,6 +351,9 @@ func main() {
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
tenantHandler.SetWorksmobileSyncer(worksmobileService)
userHandler.SetWorksmobileSyncer(worksmobileService)
worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService)
apiKeyHandler := handler.NewApiKeyHandler(db)
// 3. Initialize Fiber
@@ -532,6 +585,7 @@ func main() {
// Public Tenant Registration
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
api.Get("/admin/worksmobile/oauth/callback", worksmobileHandler.OAuthCallback)
// Tenant Context Middleware (identifies tenant from Host header)
api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{
@@ -643,6 +697,14 @@ func main() {
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV)
admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
org := admin.Group("/tenants/:tenantId/organization")
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)

View File

@@ -0,0 +1,39 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestGetEnvFileOrValueReadsSecretFile(t *testing.T) {
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "inline-value")
secretPath := filepath.Join(t.TempDir(), "worksmobile-private-key.pem")
want := "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----\n"
if err := os.WriteFile(secretPath, []byte(want), 0o600); err != nil {
t.Fatalf("failed to write secret file: %v", err)
}
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", secretPath)
got, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
if err != nil {
t.Fatalf("getEnvFileOrValue returned error: %v", err)
}
if got != want {
t.Fatalf("secret value = %q, want file content", got)
}
}
func TestGetEnvFileOrValueFallsBackToRawEnv(t *testing.T) {
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "inline-value")
t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "")
got, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
if err != nil {
t.Fatalf("getEnvFileOrValue returned error: %v", err)
}
if got != "inline-value" {
t.Fatalf("secret value = %q, want raw env value", got)
}
}

View File

@@ -0,0 +1,204 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"errors"
"fmt"
"net/mail"
"strings"
"time"
"gorm.io/gorm"
)
type SuperAdminIdentityAdmin interface {
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
}
type SuperAdminStore interface {
FindUserByEmail(ctx context.Context, email string) (*domain.User, error)
CreateUser(ctx context.Context, user *domain.User) error
UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error)
EnqueueSuperAdminRelation(ctx context.Context, userID string) error
}
type EnsureSuperAdminOptions struct {
Email string
Password string
Name string
Source string
UpdatePassword bool
}
type EnsureSuperAdminResult struct {
Email string
IdentityID string
LocalUserID string
IdentityCreated bool
PasswordUpdated bool
LocalUserCreated bool
LocalUserUpdated bool
KetoRelationQueued bool
}
func EnsureSuperAdmin(ctx context.Context, identityAdmin SuperAdminIdentityAdmin, store SuperAdminStore, opts EnsureSuperAdminOptions) (EnsureSuperAdminResult, error) {
email := strings.ToLower(strings.TrimSpace(opts.Email))
name := strings.TrimSpace(opts.Name)
if name == "" {
name = "System Admin"
}
source := strings.TrimSpace(opts.Source)
if source == "" {
source = "admin_cli"
}
result := EnsureSuperAdminResult{Email: email}
if _, err := mail.ParseAddress(email); err != nil {
return result, fmt.Errorf("invalid admin email: %w", err)
}
if identityAdmin == nil {
return result, errors.New("identity admin is required")
}
if store == nil {
return result, errors.New("super admin store is required")
}
identityID, err := identityAdmin.FindIdentityIDByIdentifier(ctx, email)
if err != nil {
return result, fmt.Errorf("find admin identity: %w", err)
}
if identityID == "" {
if strings.TrimSpace(opts.Password) == "" {
return result, errors.New("admin password is required to create identity")
}
identityID, err = identityAdmin.CreateUser(ctx, buildSuperAdminBrokerUser(email, name), opts.Password)
if err != nil {
return result, fmt.Errorf("create admin identity: %w", err)
}
result.IdentityCreated = true
} else if opts.UpdatePassword {
if strings.TrimSpace(opts.Password) == "" {
return result, errors.New("admin password is required to update identity password")
}
if err := identityAdmin.UpdateIdentityPassword(ctx, identityID, opts.Password); err != nil {
return result, fmt.Errorf("update admin identity password: %w", err)
}
result.PasswordUpdated = true
}
result.IdentityID = identityID
user, err := store.FindUserByEmail(ctx, email)
if err != nil {
return result, fmt.Errorf("find local admin user: %w", err)
}
if user == nil {
if identityID == "" {
return result, errors.New("identity id is required to create local admin user")
}
user = &domain.User{
ID: identityID,
Email: email,
Name: name,
Role: domain.RoleSuperAdmin,
Status: domain.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: domain.JSONMap{
"source": source,
},
}
if err := store.CreateUser(ctx, user); err != nil {
return result, fmt.Errorf("create local admin user: %w", err)
}
result.LocalUserCreated = true
} else if domain.NormalizeRole(user.Role) != domain.RoleSuperAdmin || user.Status != domain.UserStatusActive || (name != "" && user.Name != name) {
user, err = store.UpdateUserSuperAdmin(ctx, user.ID, name)
if err != nil {
return result, fmt.Errorf("update local admin user: %w", err)
}
result.LocalUserUpdated = true
}
result.LocalUserID = user.ID
if err := store.EnqueueSuperAdminRelation(ctx, user.ID); err != nil {
return result, fmt.Errorf("enqueue super admin keto relation: %w", err)
}
result.KetoRelationQueued = true
return result, nil
}
func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
return &domain.BrokerUser{
Email: email,
Name: name,
PhoneNumber: "",
Attributes: map[string]interface{}{
"department": "Admin",
"affiliationType": "internal",
"companyCode": "",
"grade": "admin",
"role": domain.RoleSuperAdmin,
},
}
}
type gormSuperAdminStore struct {
db *gorm.DB
outbox repository.KetoOutboxRepository
}
func NewGormSuperAdminStore(db *gorm.DB, outbox repository.KetoOutboxRepository) SuperAdminStore {
return &gormSuperAdminStore{db: db, outbox: outbox}
}
func (s *gormSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
return s.db.WithContext(ctx).Create(user).Error
}
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
updates := map[string]interface{}{
"role": domain.RoleSuperAdmin,
"status": domain.UserStatusActive,
"updated_at": time.Now(),
}
if strings.TrimSpace(name) != "" {
updates["name"] = strings.TrimSpace(name)
}
if err := s.db.WithContext(ctx).Model(&domain.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
return nil, err
}
var user domain.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (s *gormSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
if s.outbox == nil {
return nil
}
return s.outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}

View File

@@ -0,0 +1,159 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
)
func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) {
ctx := context.Background()
identityAdmin := &fakeSuperAdminIdentityAdmin{createdID: "identity-1"}
store := &fakeSuperAdminStore{}
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
Email: "new-admin@example.com",
Password: "Password!123",
Name: "New Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}
if !result.IdentityCreated {
t.Fatal("identity must be created")
}
if !result.LocalUserCreated {
t.Fatal("local user must be created")
}
if result.IdentityID != "identity-1" {
t.Fatalf("identity ID = %q, want identity-1", result.IdentityID)
}
if store.user == nil {
t.Fatal("local user was not stored")
}
if store.user.Email != "new-admin@example.com" {
t.Fatalf("local user email = %q", store.user.Email)
}
if store.user.Role != domain.RoleSuperAdmin {
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
}
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:identity-1" {
t.Fatalf("keto subjects = %#v, want User:identity-1", store.ketoSubjects)
}
if identityAdmin.createdUser == nil || identityAdmin.createdUser.Attributes["role"] != domain.RoleSuperAdmin {
t.Fatalf("created identity attributes = %#v", identityAdmin.createdUser)
}
}
func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
ctx := context.Background()
identityAdmin := &fakeSuperAdminIdentityAdmin{existingID: "identity-1"}
store := &fakeSuperAdminStore{
user: &domain.User{
ID: "local-user-1",
Email: "existing@example.com",
Name: "Existing",
Role: domain.RoleUser,
Status: domain.UserStatusInactive,
},
}
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
Email: "existing@example.com",
Password: "Password!123",
Name: "Existing Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}
if result.IdentityCreated {
t.Fatal("existing identity must not be recreated")
}
if !result.LocalUserUpdated {
t.Fatal("local user must be promoted")
}
if store.user.Role != domain.RoleSuperAdmin {
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
}
if store.user.Status != domain.UserStatusActive {
t.Fatalf("local user status = %q, want %q", store.user.Status, domain.UserStatusActive)
}
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:local-user-1" {
t.Fatalf("keto subjects = %#v, want User:local-user-1", store.ketoSubjects)
}
}
func TestEnsureSuperAdminRequiresPasswordForNewIdentity(t *testing.T) {
_, err := EnsureSuperAdmin(context.Background(), &fakeSuperAdminIdentityAdmin{}, &fakeSuperAdminStore{}, EnsureSuperAdminOptions{
Email: "new-admin@example.com",
Name: "New Admin",
})
if err == nil {
t.Fatal("expected error")
}
}
type fakeSuperAdminIdentityAdmin struct {
existingID string
createdID string
createdUser *domain.BrokerUser
createdSecret string
}
func (f *fakeSuperAdminIdentityAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
return f.existingID, nil
}
func (f *fakeSuperAdminIdentityAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
if f.createdID == "" {
return "", errors.New("created id is not configured")
}
f.createdUser = user
f.createdSecret = password
return f.createdID, nil
}
func (f *fakeSuperAdminIdentityAdmin) UpdateIdentityPassword(ctx context.Context, identityID string, newPassword string) error {
return nil
}
type fakeSuperAdminStore struct {
user *domain.User
ketoSubjects []string
}
func (f *fakeSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
if f.user == nil {
return nil, nil
}
return f.user, nil
}
func (f *fakeSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
copied := *user
f.user = &copied
return nil
}
func (f *fakeSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
if f.user == nil {
return nil, errors.New("user not found")
}
f.user.Role = domain.RoleSuperAdmin
f.user.Status = domain.UserStatusActive
if name != "" {
f.user.Name = name
}
return f.user, nil
}
func (f *fakeSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
f.ketoSubjects = append(f.ketoSubjects, "User:"+userID)
return nil
}

View File

@@ -45,6 +45,8 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ClientSecret{},
&domain.ClientConsent{},
&domain.KetoOutbox{},
&domain.WorksmobileOutbox{},
&domain.WorksmobileResourceMapping{},
&domain.SharedLink{},
&domain.DeveloperRequest{},
&domain.RPUserMetadata{},

View File

@@ -288,6 +288,8 @@ func normalizeSeedTenantType(value string) string {
return domain.TenantTypeCompany
case domain.TenantTypeCompanyGroup:
return domain.TenantTypeCompanyGroup
case domain.TenantTypeOrganization:
return domain.TenantTypeOrganization
case domain.TenantTypeUserGroup:
return domain.TenantTypeUserGroup
default:

View File

@@ -7,7 +7,7 @@ import (
"testing"
)
func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
@@ -17,12 +17,69 @@ func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
name string
slug string
tenantType string
parentSlug string
domains []string
}{
{
name: "한맥가족",
slug: "hanmac-family",
tenantType: domain.TenantTypeCompanyGroup,
},
{
name: "삼안",
slug: "saman",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"samaneng.com"},
},
{
name: "한맥기술",
slug: "hanmac",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"hanmaceng.co.kr"},
},
{
name: "총괄기획&기술개발센터",
slug: "gpdtdc",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"baroncs.co.kr"},
},
{
name: "바론그룹",
slug: "baron-group",
tenantType: domain.TenantTypeCompanyGroup,
parentSlug: "hanmac-family",
},
{
name: "(주)장헌",
slug: "jangheon",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"jangheon.com"},
},
{
name: "장헌산업",
slug: "jangheon-sanup",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"jangheon.co.kr"},
},
{
name: "한라산업개발",
slug: "hanlla",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"hanllasanup.co.kr"},
},
{
name: "(주)피티씨",
slug: "ptc",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"pre-cast.co.kr"},
},
{
name: "Personal",
slug: "personal",
@@ -45,15 +102,23 @@ func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
if got.Type != want.tenantType {
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
}
if got.ParentSlug != "" {
t.Fatalf("tenant[%d] parent slug = %q, want empty root tenant", i, got.ParentSlug)
if got.ParentSlug != want.parentSlug {
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, got.ParentSlug, want.parentSlug)
}
if len(got.Domains) != len(want.domains) {
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
}
for j, wantDomain := range want.domains {
if got.Domains[j] != wantDomain {
t.Fatalf("tenant[%d] domain[%d] = %q, want %q", i, j, got.Domains[j], wantDomain)
}
}
}
}
for _, tenant := range configs {
if tenant.Slug == "system" || tenant.Slug == "hanmac" || tenant.Slug == "saman" {
t.Fatalf("tenant %q must be configured by import, not seed CSV", tenant.Slug)
}
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
}
}

View File

@@ -20,13 +20,14 @@ const (
TenantTypePersonal = "PERSONAL"
TenantTypeCompany = "COMPANY"
TenantTypeCompanyGroup = "COMPANY_GROUP"
TenantTypeOrganization = "ORGANIZATION"
TenantTypeUserGroup = "USER_GROUP"
)
// Tenant represents a tenant model stored in PostgreSQL.
type Tenant struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`

View File

@@ -17,6 +17,14 @@ const (
RoleUser = "user" // 일반 사용자
)
// User statuses
const (
UserStatusActive = "active"
UserStatusInactive = "inactive"
UserStatusSuspended = "suspended"
UserStatusLeaveOfAbsence = "leave_of_absence"
)
// NormalizeRole maps legacy/synonym role values to canonical role keys.
func NormalizeRole(role string) string {
normalized := strings.ToLower(strings.TrimSpace(role))

View File

@@ -0,0 +1,72 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
WorksmobileOutboxStatusPending = "pending"
WorksmobileOutboxStatusProcessing = "processing"
WorksmobileOutboxStatusProcessed = "processed"
WorksmobileOutboxStatusFailed = "failed"
)
const (
WorksmobileResourceOrgUnit = "ORGUNIT"
WorksmobileResourceUser = "USER"
)
const (
WorksmobileActionUpsert = "UPSERT"
WorksmobileActionDelete = "DELETE"
WorksmobileActionDryRun = "DRY_RUN"
WorksmobileActionSuspend = "SUSPEND"
)
type WorksmobileOutbox struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ResourceType string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceType"`
ResourceID string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceId"`
Action string `gorm:"not null" json:"action"`
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
Status string `gorm:"default:'pending';index" json:"status"`
RetryCount int `gorm:"default:0" json:"retryCount"`
LastError string `json:"lastError,omitempty"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
ProcessedAt *time.Time `json:"processedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (w *WorksmobileOutbox) BeforeCreate(tx *gorm.DB) error {
if w.ID == "" {
w.ID = uuid.NewString()
}
if w.Status == "" {
w.Status = WorksmobileOutboxStatusPending
}
return nil
}
type WorksmobileResourceMapping struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
BaronResourceType string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceType"`
BaronResourceID string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceId"`
ExternalKey string `gorm:"not null;uniqueIndex" json:"externalKey"`
WorksmobileResourceID string `json:"worksmobileResourceId,omitempty"`
DomainID int64 `json:"domainId"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (w *WorksmobileResourceMapping) BeforeCreate(tx *gorm.DB) error {
if w.ID == "" {
w.ID = uuid.NewString()
}
return nil
}

View File

@@ -27,6 +27,7 @@ type TenantHandler struct {
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
}
func seedTenantDeleteError(c *fiber.Ctx) error {
@@ -58,6 +59,10 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor
}
}
func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
h.Worksmobile = syncer
}
type tenantSummary struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -393,6 +398,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
if updated {
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
result.Updated++
if h.Worksmobile != nil {
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
}
continue
}
}
@@ -410,6 +418,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
}
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
result.Created++
if h.Worksmobile != nil {
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
}
}
return c.JSON(result)
@@ -1042,6 +1053,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
if len(normalizedDomains) > 0 {
summary.Domains = normalizedDomains
}
if h.Worksmobile != nil {
if refreshed := h.DB.Preload("Domains").First(tenant, "id = ?", tenant.ID); refreshed.Error == nil {
if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant); err != nil {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant sync: %v\n", err)
}
}
}
return c.Status(fiber.StatusCreated).JSON(summary)
}
@@ -1188,6 +1206,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
// Refetch to get updated relations
h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID)
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), tenant); err != nil {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
}
}
return c.JSON(mapTenantSummary(tenant))
}
@@ -1222,6 +1245,11 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
if err := h.DB.Delete(&tenant).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueTenantDeleteIfInScope(c.Context(), tenant); err != nil {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
}
}
return c.SendStatus(fiber.StatusNoContent)
}
@@ -1581,7 +1609,7 @@ func normalizeTenantStatus(value string) string {
func normalizeTenantType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
switch value {
case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup:
case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeOrganization, domain.TenantTypeUserGroup:
return value
default:
return ""

View File

@@ -383,7 +383,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,USER_GROUP,parent-slug,child-slug,,\n"))
_, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,ORGANIZATION,parent-slug,child-slug,,\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
@@ -405,7 +405,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
mock.Anything,
"Child Tenant",
"child-slug",
domain.TenantTypeUserGroup,
domain.TenantTypeOrganization,
"",
[]string{},
mock.MatchedBy(func(got *string) bool {
@@ -426,6 +426,10 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
mockSvc.AssertExpectations(t)
}
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}
func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
records, err := parseTenantCSVRecords(strings.NewReader(
"name,type,parent_tenant_slug,slug,memo,email_domain\n" +

View File

@@ -36,6 +36,7 @@ type UserHandler struct {
UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
Worksmobile service.WorksmobileSyncer
}
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
@@ -51,6 +52,97 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
}
}
func (h *UserHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
h.Worksmobile = syncer
}
func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[string]any, primaryTenantID string, primaryTenantName string, primaryTenantIsOwner *bool) map[string]any {
if metadata == nil {
metadata = map[string]any{}
}
if len(appointments) > 0 {
values := make([]any, 0, len(appointments))
for _, appointment := range appointments {
values = append(values, appointment)
}
metadata["additionalAppointments"] = values
}
if strings.TrimSpace(primaryTenantID) != "" {
metadata["primaryTenantId"] = strings.TrimSpace(primaryTenantID)
}
if strings.TrimSpace(primaryTenantName) != "" {
metadata["primaryTenantName"] = strings.TrimSpace(primaryTenantName)
}
if primaryTenantIsOwner != nil {
metadata["primaryTenantIsOwner"] = *primaryTenantIsOwner
}
return metadata
}
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
if value := strings.TrimSpace(primaryTenantID); value != "" {
return value
}
if value := normalizeMetadataString(metadata["primaryTenantId"]); value != "" {
return value
}
for _, appointment := range appointments {
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary {
if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
return value
}
}
}
if len(appointments) > 0 {
return normalizeMetadataString(appointments[0]["tenantId"])
}
if raw, ok := metadata["additionalAppointments"].([]any); ok {
for _, item := range raw {
appointment, ok := item.(map[string]any)
if !ok {
continue
}
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary {
if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
return value
}
}
}
if len(raw) > 0 {
if appointment, ok := raw[0].(map[string]any); ok {
return normalizeMetadataString(appointment["tenantId"])
}
}
}
return ""
}
func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch v := value.(type) {
case bool:
return v, true
case string:
normalized := strings.ToLower(strings.TrimSpace(v))
if normalized == "true" || normalized == "1" || normalized == "yes" {
return true, true
}
if normalized == "false" || normalized == "0" || normalized == "no" {
return false, true
}
case float64:
return v != 0, true
case int:
return v != 0, true
}
}
return false, false
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -331,21 +423,26 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
var req struct {
Email string `json:"email"`
LoginID string `json:"loginId"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
Email string `json:"email"`
LoginID string `json:"loginId"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
PrimaryTenantID string `json:"primaryTenantId"`
PrimaryTenantName string `json:"primaryTenantName"`
PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"`
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
email := strings.TrimSpace(req.Email)
if email == "" {
@@ -411,6 +508,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// [Resolve TenantID and Custom Login IDs before Kratos creation]
var tenantID string
if req.CompanyCode == "" && h.TenantService != nil {
if primaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments); primaryTenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), primaryTenantID); err == nil && tenant != nil {
tenantID = tenant.ID
req.CompanyCode = tenant.Slug
}
}
}
if req.CompanyCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
tenantID = tenant.ID
@@ -421,6 +526,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
attributes["role"] = role
attributes["companyCode"] = req.CompanyCode
if tenantID != "" {
attributes["tenant_id"] = tenantID
}
@@ -495,6 +601,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err)
}
}
// Update User Login IDs in local DB
for i := range loginIDRecords {
@@ -796,6 +907,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err)
}
}
// Update User Login IDs in local DB
for i := range loginIDRecords {
@@ -1170,6 +1286,11 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
_ = h.UserRepo.Update(c.Context(), localUser)
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err)
}
}
// [Keto Sync]
if h.KetoOutboxRepo != nil {
@@ -1244,6 +1365,12 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
if h.Worksmobile != nil {
localUser := h.mapToLocalUser(*identity)
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile bulk user delete", "userID", id, "error", err)
}
}
// Local DB Sync
if h.UserRepo != nil {
@@ -1298,21 +1425,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
var req struct {
LoginID *string `json:"loginId"`
Password *string `json:"password"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
LoginID *string `json:"loginId"`
Password *string `json:"password"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
PrimaryTenantID string `json:"primaryTenantId"`
PrimaryTenantName string `json:"primaryTenantName"`
PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"`
AdditionalAppointments []map[string]any `json:"additionalAppointments"`
Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
// [New] Tenant Admin restriction: Cannot change companyCode
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
@@ -1510,11 +1642,19 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
if h.UserRepo != nil {
updatedLocalUser := h.mapToLocalUser(*updated)
if req.Status != nil {
updatedLocalUser.Status = normalizeStatus(*req.Status)
}
ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err)
}
}
// Update User Login IDs in local DB
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
@@ -1628,9 +1768,15 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety")
}
var identity *service.KratosIdentity
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin || h.Worksmobile != nil {
found, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil {
identity = found
}
}
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if identity != nil {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
@@ -1641,6 +1787,12 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if h.Worksmobile != nil && identity != nil {
localUser := h.mapToLocalUser(*identity)
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("[UserHandler] Failed to enqueue Worksmobile user delete", "userID", userID, "error", err)
}
}
// [Keto] Cleanup relations via Outbox
if h.KetoOutboxRepo != nil {
@@ -2041,11 +2193,17 @@ func formatTime(value time.Time) string {
func normalizeStatus(state string) string {
state = strings.ToLower(strings.TrimSpace(state))
if state == "inactive" || state == "blocked" || state == "active" {
if state == "blocked" {
return domain.UserStatusInactive
}
if state == domain.UserStatusInactive ||
state == domain.UserStatusSuspended ||
state == domain.UserStatusLeaveOfAbsence ||
state == domain.UserStatusActive {
return state
}
if state == "" {
return "active"
return domain.UserStatusActive
}
return state
}
@@ -2056,10 +2214,15 @@ func normalizeKratosState(status *string) string {
}
value := strings.ToLower(strings.TrimSpace(*status))
if value == "blocked" {
return "inactive"
return domain.UserStatusInactive
}
if value == "active" || value == "inactive" {
return value
if value == domain.UserStatusActive {
return domain.UserStatusActive
}
if value == domain.UserStatusInactive ||
value == domain.UserStatusSuspended ||
value == domain.UserStatusLeaveOfAbsence {
return domain.UserStatusInactive
}
return ""
}

View File

@@ -97,6 +97,27 @@ func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
return args.Get(0).(*domain.PasswordPolicy), args.Error(1)
}
type fakeUserHandlerWorksmobileSyncer struct {
upserts []domain.User
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
f.upserts = append(f.upserts, user)
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
return nil
}
type MockTenantServiceForUser struct {
mock.Mock
service.TenantService
@@ -576,7 +597,9 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
func TestUserHandler_BulkUpdateUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
h := &UserHandler{KratosAdmin: mockKratos}
mockRepo := new(MockUserRepoForHandler)
worksmobile := &fakeUserHandlerWorksmobileSyncer{}
h := &UserHandler{KratosAdmin: mockKratos, UserRepo: mockRepo, Worksmobile: worksmobile}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
@@ -585,10 +608,18 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
t.Run("Success - Update Role and Status", func(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com"}, State: "active",
ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active",
}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{
ID: "u-1",
Traits: map[string]interface{}{
"email": "u1@test.com",
"name": "Bulk User",
"tenant_id": "tenant-1",
},
State: "inactive",
}, nil).Once()
status := "inactive"
payload := map[string]interface{}{
@@ -606,6 +637,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]interface{})
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
})
}
@@ -1033,6 +1067,74 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
})
}
func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
worksmobile := &fakeUserHandlerWorksmobileSyncer{}
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
UserRepo: mockRepo,
Worksmobile: worksmobile,
}
app.Post("/users", h.CreateUser)
tenantID := "33333333-3333-3333-3333-333333333333"
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "saman",
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "saman").Return(&domain.Tenant{
ID: tenantID,
Slug: "saman",
}, nil)
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["tenant_id"] == tenantID &&
user.Attributes["companyCode"] == "saman" &&
user.Attributes["additionalAppointments"] != nil
}), mock.Anything).Return("u-appointment", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
ID: "u-appointment",
Traits: map[string]interface{}{
"email": "new@samaneng.com",
"name": "Appointment User",
"companyCode": "saman",
"tenant_id": tenantID,
"additionalAppointments": []interface{}{
map[string]interface{}{"tenantId": tenantID, "tenantSlug": "saman"},
},
},
State: "active",
}, nil).Once()
payload := map[string]interface{}{
"email": "new@samaneng.com",
"name": "Appointment User",
"additionalAppointments": []map[string]interface{}{
{"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"},
},
"metadata": map[string]interface{}{
"userType": "hanmac",
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 201, resp.StatusCode)
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID)
mockOry.AssertExpectations(t)
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
return "", nil
}

View File

@@ -0,0 +1,132 @@
package handler
import (
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/csv"
"errors"
"log/slog"
"strings"
"github.com/gofiber/fiber/v2"
)
type WorksmobileHandler struct {
Service service.WorksmobileAdminService
}
func NewWorksmobileHandler(svc service.WorksmobileAdminService) *WorksmobileHandler {
return &WorksmobileHandler{Service: svc}
}
func (h *WorksmobileHandler) GetOverview(c *fiber.Ctx) error {
overview, err := h.Service.GetTenantOverview(c.Context(), strings.TrimSpace(c.Params("tenantId")))
if err != nil {
return worksmobileGuardError(c, err, "get_overview")
}
if !worksmobileOverviewAllowed(overview) {
return errorJSON(c, fiber.StatusNotFound, "worksmobile is only available for hanmac-family root tenant")
}
return c.JSON(overview)
}
func (h *WorksmobileHandler) GetComparison(c *fiber.Ctx) error {
includeMatched := strings.EqualFold(strings.TrimSpace(c.Query("includeMatched")), "true")
comparison, err := h.Service.GetComparison(c.Context(), strings.TrimSpace(c.Params("tenantId")), includeMatched)
if err != nil {
return worksmobileGuardError(c, err, "get_comparison")
}
return c.JSON(comparison)
}
func (h *WorksmobileHandler) OAuthCallback(c *fiber.Ctx) error {
return c.Type("html").SendString("<!doctype html><html><body>Worksmobile OAuth callback reachable</body></html>")
}
func (h *WorksmobileHandler) BackfillDryRun(c *fiber.Ctx) error {
result, err := h.Service.EnqueueBackfillDryRun(c.Context(), strings.TrimSpace(c.Params("tenantId")))
if err != nil {
return worksmobileGuardError(c, err, "backfill_dry_run")
}
return c.JSON(result)
}
func (h *WorksmobileHandler) SyncOrgUnit(c *fiber.Ctx) error {
orgUnitID := strings.TrimSpace(c.Params("orgUnitId"))
job, err := h.Service.EnqueueOrgUnitSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), orgUnitID)
if err != nil {
return worksmobileGuardError(c, err, "sync_orgunit", "org_unit_id", orgUnitID)
}
return c.Status(fiber.StatusAccepted).JSON(job)
}
func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId"))
job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID)
if err != nil {
return worksmobileGuardError(c, err, "sync_user", "user_id", userID)
}
return c.Status(fiber.StatusAccepted).JSON(job)
}
func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
jobID := strings.TrimSpace(c.Params("jobId"))
job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID)
if err != nil {
return worksmobileGuardError(c, err, "retry_job", "job_id", jobID)
}
return c.JSON(job)
}
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")))
if err != nil {
return worksmobileGuardError(c, err, "download_initial_passwords")
}
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
for _, credential := range credentials {
if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
c.Set(fiber.HeaderContentType, "text/csv; charset=utf-8")
c.Set(fiber.HeaderContentDisposition, `attachment; filename="worksmobile_initial_passwords.csv"`)
return c.Send(buf.Bytes())
}
func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool {
return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil
}
func worksmobileGuardError(c *fiber.Ctx, err error, operation string, attrs ...any) error {
if err == nil {
return nil
}
logAttrs := []any{
"operation", operation,
"tenant_id", strings.TrimSpace(c.Params("tenantId")),
"path", c.Path(),
"error", err,
}
logAttrs = append(logAttrs, attrs...)
if errors.Is(err, context.Canceled) {
slog.Warn("worksmobile admin operation failed", logAttrs...)
return errorJSON(c, fiber.StatusRequestTimeout, err.Error())
}
slog.Error("worksmobile admin operation failed", logAttrs...)
if strings.Contains(err.Error(), "hanmac-family root") {
return errorJSON(c, fiber.StatusNotFound, err.Error())
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}

View File

@@ -0,0 +1,128 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
func TestWorksmobileHandlerRejectsNonHanmacTenant(t *testing.T) {
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
overview: service.WorksmobileTenantOverview{
Tenant: domain.Tenant{ID: "tenant-1", Slug: "other"},
},
})
app := fiber.New()
app.Get("/tenants/:tenantId/worksmobile", h.GetOverview)
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/tenant-1/worksmobile", nil))
require.NoError(t, err)
require.Equal(t, fiber.StatusNotFound, resp.StatusCode)
}
func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) {
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
overview: service.WorksmobileTenantOverview{
Tenant: domain.Tenant{ID: "hanmac-id", Slug: "hanmac-family"},
Config: service.WorksmobileConfigSummary{
Enabled: true,
},
},
})
app := fiber.New()
app.Get("/tenants/:tenantId/worksmobile", h.GetOverview)
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile", nil))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
}
func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) {
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
credentials: []service.WorksmobileInitialPasswordCredential{
{Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"},
},
})
app := fiber.New()
app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV)
resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv", nil))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "email,initialPassword,status,lastError")
require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,")
}
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
var logs bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil)))
t.Cleanup(func() {
slog.SetDefault(previous)
})
h := NewWorksmobileHandler(&fakeWorksmobileAdminService{
syncUserErr: errors.New("works user sync failed"),
})
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.StatusInternalServerError, resp.StatusCode)
require.Contains(t, logs.String(), "worksmobile admin operation failed")
require.Contains(t, logs.String(), "sync_user")
require.Contains(t, logs.String(), "works user sync failed")
}
type fakeWorksmobileAdminService struct {
overview service.WorksmobileTenantOverview
credentials []service.WorksmobileInitialPasswordCredential
syncUserErr error
}
func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) {
return f.overview, nil
}
func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (service.WorksmobileComparison, error) {
return service.WorksmobileComparison{}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) {
return service.WorksmobileBackfillDryRun{}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
return &domain.WorksmobileOutbox{ID: "job-orgunit", ResourceID: orgUnitID}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
if f.syncUserErr != nil {
return nil, f.syncUserErr
}
return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil
}
func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
return &domain.WorksmobileOutbox{ID: jobID}, nil
}
func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) {
return f.credentials, nil
}

View File

@@ -0,0 +1,114 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
MarkRetry(ctx context.Context, id string) error
MarkProcessing(ctx context.Context, id string) error
MarkProcessed(ctx context.Context, id string) error
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
}
type worksmobileOutboxRepository struct {
db *gorm.DB
}
func NewWorksmobileOutboxRepository(db *gorm.DB) WorksmobileOutboxRepository {
return &worksmobileOutboxRepository{db: db}
}
func (r *worksmobileOutboxRepository) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
if item.Payload == nil {
item.Payload = domain.JSONMap{}
}
if item.Status == "" {
item.Status = domain.WorksmobileOutboxStatusPending
}
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dedupe_key"}},
DoUpdates: clause.Assignments(map[string]any{
"payload": item.Payload,
"status": domain.WorksmobileOutboxStatusPending,
"last_error": "",
"next_attempt_at": nil,
"updated_at": time.Now(),
}),
}).Create(item).Error
}
func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 1000 {
limit = 50
}
var rows []domain.WorksmobileOutbox
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var rows []domain.WorksmobileOutbox
err := r.db.WithContext(ctx).
Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.WorksmobileOutboxStatusPending, time.Now()).
Order("created_at asc").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (r *worksmobileOutboxRepository) FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error) {
var row domain.WorksmobileOutbox
if err := r.db.WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
return nil, err
}
return &row, nil
}
func (r *worksmobileOutboxRepository) MarkRetry(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.WorksmobileOutboxStatusPending,
"last_error": "",
"next_attempt_at": nil,
"updated_at": time.Now(),
}).Error
}
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
"status": domain.WorksmobileOutboxStatusProcessing,
"updated_at": time.Now(),
}).Error
}
func (r *worksmobileOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.WorksmobileOutboxStatusProcessed,
"last_error": "",
"processed_at": &now,
"updated_at": now,
}).Error
}
func (r *worksmobileOutboxRepository) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.WorksmobileOutboxStatusFailed,
"retry_count": gorm.Expr("retry_count + 1"),
"last_error": message,
"next_attempt_at": &nextAttemptAt,
"updated_at": time.Now(),
}).Error
}

View File

@@ -68,10 +68,10 @@ func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID
unitID := uuid.NewString()
// 1. Create Tenant (Type: USER_GROUP)
// 1. Create Tenant (Type: ORGANIZATION)
groupTenant := &domain.Tenant{
ID: unitID,
Type: domain.TenantTypeUserGroup,
Type: domain.TenantTypeOrganization,
ParentID: actualParentID,
Name: name,
Slug: fmt.Sprintf("ug-%s", unitID[:8]),

View File

@@ -202,7 +202,7 @@ func TestUserGroupService_Create(t *testing.T) {
// Mock Tenant creation (Polymorphic)
mockTenantRepo.On("Create", mock.Anything, mock.MatchedBy(func(ten *domain.Tenant) bool {
return ten.Type == domain.TenantTypeUserGroup && ten.Name == name && *ten.ParentID == parentID
return ten.Type == domain.TenantTypeOrganization && ten.Name == name && *ten.ParentID == parentID
})).Return(nil)
// Mock UserGroup creation

View File

@@ -0,0 +1,969 @@
package service
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const defaultWorksmobileAPIBaseURL = "https://www.worksapis.com"
const defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token"
const defaultWorksmobileOAuthScope = "directory"
type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error)
}
type WorksmobileHTTPClient struct {
BaseURL string
DirectoryToken string
SCIMToken string
HTTPClient *http.Client
OAuthConfig WorksmobileOAuthConfig
DomainIDs []int64
tokenCache worksmobileAccessTokenCache
now func() time.Time
}
type WorksmobileOAuthConfig struct {
ClientID string
ClientSecret string
ServiceAccount string
PrivateKey string
Scope string
TokenURL string
}
type worksmobileAccessTokenCache struct {
Token string
ExpiresAt time.Time
}
func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig {
c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`)
c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`)
c.ServiceAccount = strings.Trim(strings.TrimSpace(c.ServiceAccount), `"`)
c.PrivateKey = normalizeWorksmobilePrivateKey(c.PrivateKey)
c.Scope = strings.TrimSpace(c.Scope)
if c.Scope == "" {
c.Scope = defaultWorksmobileOAuthScope
}
c.TokenURL = strings.TrimSpace(c.TokenURL)
if c.TokenURL == "" {
c.TokenURL = defaultWorksmobileOAuthTokenURL
}
return c
}
func (c WorksmobileOAuthConfig) validate() error {
if strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" {
return fmt.Errorf("worksmobile directory token is not configured")
}
if strings.TrimSpace(c.ServiceAccount) == "" || strings.TrimSpace(c.PrivateKey) == "" {
return fmt.Errorf("worksmobile oauth service account is not configured")
}
return nil
}
func normalizeWorksmobilePrivateKey(value string) string {
value = strings.Trim(strings.TrimSpace(value), `"`)
value = strings.ReplaceAll(value, `\n`, "\n")
return value
}
func buildWorksmobileJWTAssertion(config WorksmobileOAuthConfig, now time.Time) (string, error) {
privateKey, err := parseWorksmobilePrivateKey(config.PrivateKey)
if err != nil {
return "", err
}
header := map[string]string{"alg": "RS256", "typ": "JWT"}
payload := map[string]any{
"iss": config.ClientID,
"sub": config.ServiceAccount,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
encodedHeader, err := encodeWorksmobileJWTPart(header)
if err != nil {
return "", err
}
encodedPayload, err := encodeWorksmobileJWTPart(payload)
if err != nil {
return "", err
}
signingInput := encodedHeader + "." + encodedPayload
sum := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:])
if err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func encodeWorksmobileJWTPart(value any) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func parseWorksmobilePrivateKey(value string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(normalizeWorksmobilePrivateKey(value)))
if block == nil {
return nil, fmt.Errorf("worksmobile private key is not a valid PEM block")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("worksmobile private key is not RSA")
}
return key, nil
}
type WorksmobileHTTPError struct {
StatusCode int
Body string
}
func (e WorksmobileHTTPError) Error() string {
return fmt.Sprintf("worksmobile api failed status=%d body=%s", e.StatusCode, e.Body)
}
func NewWorksmobileHTTPClient(scimToken string) *WorksmobileHTTPClient {
return &WorksmobileHTTPClient{
BaseURL: defaultWorksmobileAPIBaseURL,
SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`),
}
}
func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) *WorksmobileHTTPClient {
return &WorksmobileHTTPClient{
BaseURL: defaultWorksmobileAPIBaseURL,
DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`),
SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`),
}
}
func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, oauthConfig WorksmobileOAuthConfig) *WorksmobileHTTPClient {
return &WorksmobileHTTPClient{
BaseURL: defaultWorksmobileAPIBaseURL,
DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`),
SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`),
OAuthConfig: oauthConfig.normalized(),
DomainIDs: WorksmobileDomainIDsFromEnv(),
}
}
func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error {
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
}
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
err := c.CreateUser(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
identifier := strings.TrimSpace(payload.Email)
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
}
return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload))
}
return err
}
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return fmt.Errorf("worksmobile user identifier is required")
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
}
func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
if userID == "" {
return fmt.Errorf("worksmobile user id is required")
}
remote, err := c.FindUser(ctx, userID)
if err != nil {
return err
}
if remote == nil {
return nil
}
if c.directoryAuthConfigured() && remote.Email != "" {
err := c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(remote.Email), nil)
if err == nil || strings.TrimSpace(c.SCIMToken) == "" {
return err
}
}
return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil)
}
func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
users, err := c.ListUsers(ctx)
if err != nil {
return nil, err
}
identifier = strings.TrimSpace(identifier)
for _, user := range users {
if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) {
return &user, nil
}
}
return nil, nil
}
func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
users, err := c.listDirectoryUsers(ctx, c.DomainIDs)
if err == nil {
return users, nil
}
if strings.TrimSpace(c.SCIMToken) == "" {
return nil, err
}
}
var users []WorksmobileRemoteUser
err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) {
users = append(users, parseWorksmobileRemoteUser(resource))
})
return users, err
}
func (c *WorksmobileHTTPClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
groups, err := c.listDirectoryGroups(ctx, c.DomainIDs)
if err == nil {
return groups, nil
}
if strings.TrimSpace(c.SCIMToken) == "" {
return nil, err
}
}
var groups []WorksmobileRemoteGroup
err := c.listSCIM(ctx, "/scim/v2/Groups", func(resource map[string]any) {
groups = append(groups, parseWorksmobileRemoteGroup(resource))
})
return groups, err
}
func (c *WorksmobileHTTPClient) listSCIM(ctx context.Context, path string, consume func(map[string]any)) error {
startIndex := 1
count := 100
for {
var response struct {
TotalResults int `json:"totalResults"`
ItemsPerPage int `json:"itemsPerPage"`
Resources []map[string]any `json:"Resources"`
}
if err := c.getJSON(ctx, fmt.Sprintf("%s?startIndex=%d&count=%d", path, startIndex, count), &response); err != nil {
return err
}
for _, resource := range response.Resources {
consume(resource)
}
if len(response.Resources) == 0 || startIndex+len(response.Resources) > response.TotalResults {
return nil
}
startIndex += len(response.Resources)
}
}
func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target any) error {
token := strings.TrimSpace(c.SCIMToken)
if token == "" {
token = strings.TrimSpace(c.DirectoryToken)
}
if token == "" {
return fmt.Errorf("worksmobile read token is not configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path string, target any) error {
token, err := c.directoryAccessToken(ctx)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (c *WorksmobileHTTPClient) listDirectoryUsers(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteUser, error) {
users := make([]WorksmobileRemoteUser, 0)
for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) {
cursor := ""
for {
path := fmt.Sprintf("/v1.0/users?domainId=%d&count=100", domainID)
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
var response struct {
Users []map[string]any `json:"users"`
ResponseMetaData struct {
NextCursor string `json:"nextCursor"`
} `json:"responseMetaData"`
}
if err := c.getDirectoryJSON(ctx, path, &response); err != nil {
return nil, err
}
for _, raw := range response.Users {
user := parseWorksmobileDirectoryUser(raw)
user.DomainID = domainID
user.DomainName = WorksmobileDomainLabelForID(domainID)
users = append(users, user)
}
cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor)
if cursor == "" {
break
}
}
}
return users, nil
}
func (c *WorksmobileHTTPClient) listDirectoryGroups(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteGroup, error) {
groups := make([]WorksmobileRemoteGroup, 0)
for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) {
cursor := ""
for {
path := fmt.Sprintf("/v1.0/orgunits?domainId=%d&count=100", domainID)
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
var response struct {
OrgUnits []map[string]any `json:"orgUnits"`
ResponseMetaData struct {
NextCursor string `json:"nextCursor"`
} `json:"responseMetaData"`
}
if err := c.getDirectoryJSON(ctx, path, &response); err != nil {
return nil, err
}
for _, raw := range response.OrgUnits {
group := parseWorksmobileDirectoryGroup(raw)
group.DomainID = domainID
group.DomainName = WorksmobileDomainLabelForID(domainID)
groups = append(groups, group)
}
cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor)
if cursor == "" {
break
}
}
}
return groups, nil
}
func uniqueWorksmobileDomainIDs(domainIDs []int64) []int64 {
result := make([]int64, 0, len(domainIDs))
seen := map[int64]bool{}
for _, id := range domainIDs {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
return result
}
func (c *WorksmobileHTTPClient) sendJSON(ctx context.Context, method string, path string, payload any) error {
token := strings.TrimSpace(c.SCIMToken)
if token == "" {
return fmt.Errorf("worksmobile scim token is not configured")
}
return c.sendJSONWithToken(ctx, method, path, payload, token)
}
func (c *WorksmobileHTTPClient) sendDirectoryJSON(ctx context.Context, method string, path string, payload any) error {
token, err := c.directoryAccessToken(ctx)
if err != nil {
return err
}
return c.sendJSONWithToken(ctx, method, path, payload, token)
}
func (c *WorksmobileHTTPClient) directoryAccessToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(c.DirectoryToken); token != "" {
return token, nil
}
now := c.currentTime()
if c.tokenCache.Token != "" && now.Before(c.tokenCache.ExpiresAt) {
return c.tokenCache.Token, nil
}
token, expiresAt, err := c.requestDirectoryAccessToken(ctx, now)
if err != nil {
return "", err
}
c.tokenCache = worksmobileAccessTokenCache{Token: token, ExpiresAt: expiresAt}
return token, nil
}
func (c *WorksmobileHTTPClient) directoryAuthConfigured() bool {
if strings.TrimSpace(c.DirectoryToken) != "" {
return true
}
return c.OAuthConfig.normalized().validate() == nil
}
func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context, now time.Time) (string, time.Time, error) {
config := c.OAuthConfig.normalized()
if err := config.validate(); err != nil {
return "", time.Time{}, err
}
assertion, err := buildWorksmobileJWTAssertion(config, now)
if err != nil {
return "", time.Time{}, err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
form.Set("client_id", config.ClientID)
form.Set("client_secret", config.ClientSecret)
form.Set("scope", config.Scope)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", time.Time{}, WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
}
var tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn any `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
return "", time.Time{}, err
}
if strings.TrimSpace(tokenResponse.AccessToken) == "" {
return "", time.Time{}, fmt.Errorf("worksmobile token response is missing access_token")
}
expiresIn := worksmobileTokenExpiresIn(tokenResponse.ExpiresIn)
if expiresIn <= 0 {
expiresIn = 3600
}
return strings.TrimSpace(tokenResponse.AccessToken), now.Add(time.Duration(expiresIn-60) * time.Second), nil
}
func worksmobileTokenExpiresIn(raw any) int64 {
switch value := raw.(type) {
case float64:
return int64(value)
case int64:
return value
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
default:
return 0
}
}
func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method string, path string, payload any, token string) error {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL(), "/")+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
}
const worksmobileSCIMUserExtensionSchema = "urn:ietf:params:scim:schemas:extension:works:2.0:User"
type WorksmobileSCIMUserPayload struct {
Schemas []string `json:"schemas"`
UserName string `json:"userName"`
ExternalID string `json:"externalId"`
DisplayName string `json:"displayName"`
Name WorksmobileSCIMName `json:"name"`
Emails []WorksmobileSCIMEmail `json:"emails"`
PhoneNumbers []WorksmobileSCIMPhoneNumber `json:"phoneNumbers,omitempty"`
Password string `json:"password,omitempty"`
Active bool `json:"active"`
PreferredLanguage string `json:"preferredLanguage,omitempty"`
WorksExtension map[string]any `json:"urn:ietf:params:scim:schemas:extension:works:2.0:User,omitempty"`
}
type WorksmobileSCIMName struct {
FamilyName string `json:"familyName"`
}
type WorksmobileSCIMEmail struct {
Value string `json:"value"`
Primary bool `json:"primary"`
Type string `json:"type,omitempty"`
}
type WorksmobileSCIMPhoneNumber struct {
Value string `json:"value"`
Primary bool `json:"primary"`
Type string `json:"type,omitempty"`
}
type WorksmobileUserPatchPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"`
UserExternalKey string `json:"userExternalKey,omitempty"`
UserName WorksmobileUserName `json:"userName,omitempty"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
type WorksmobileRemoteUser struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
UserName string `json:"userName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
LevelID string `json:"levelId"`
LevelName string `json:"levelName"`
Task string `json:"task"`
DomainID int64 `json:"domainId"`
DomainName string `json:"domainName"`
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
Active bool `json:"active"`
}
type WorksmobileRemoteGroup struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
DisplayName string `json:"displayName"`
DomainID int64 `json:"domainId"`
DomainName string `json:"domainName"`
ParentID string `json:"parentId"`
ParentName string `json:"parentName"`
}
func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload {
name := strings.TrimSpace(payload.UserName.LastName)
if name == "" {
name = strings.TrimSpace(payload.Email)
}
result := WorksmobileSCIMUserPayload{
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User", worksmobileSCIMUserExtensionSchema},
UserName: strings.TrimSpace(payload.Email),
ExternalID: strings.TrimSpace(payload.UserExternalKey),
DisplayName: name,
Name: WorksmobileSCIMName{FamilyName: name},
Emails: []WorksmobileSCIMEmail{{Value: strings.TrimSpace(payload.Email), Primary: true, Type: "other"}},
Password: payload.PasswordConfig.Password,
Active: true,
PreferredLanguage: worksmobileSCIMPreferredLanguage(payload.Locale),
WorksExtension: map[string]any{
"employeeNumber": payload.EmployeeNumber,
"task": payload.Task,
},
}
if strings.TrimSpace(payload.CellPhone) != "" {
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}}
}
return result
}
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
return WorksmobileUserPatchPayload{
DomainID: payload.DomainID,
Email: strings.TrimSpace(payload.Email),
UserExternalKey: strings.TrimSpace(payload.UserExternalKey),
UserName: payload.UserName,
CellPhone: strings.TrimSpace(payload.CellPhone),
EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber),
AliasEmails: payload.AliasEmails,
Locale: strings.TrimSpace(payload.Locale),
Task: strings.TrimSpace(payload.Task),
Organizations: payload.Organizations,
}
}
func worksmobileSCIMPreferredLanguage(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
return ""
}
return strings.ReplaceAll(locale, "_", "-")
}
func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser {
user := WorksmobileRemoteUser{
ID: stringFromMap(resource, "id"),
ExternalID: stringFromMap(resource, "externalId"),
UserName: stringFromMap(resource, "userName"),
DisplayName: stringFromMap(resource, "displayName"),
Active: boolFromMap(resource, "active"),
}
if emails, ok := resource["emails"].([]any); ok {
for _, raw := range emails {
email, ok := raw.(map[string]any)
if !ok {
continue
}
if user.Email == "" || boolFromMap(email, "primary") {
user.Email = stringFromMap(email, "value")
}
}
}
if user.Email == "" && strings.Contains(user.UserName, "@") {
user.Email = user.UserName
}
user.PrimaryOrgUnitID, user.PrimaryOrgUnitName = parseWorksmobilePrimaryOrgUnit(resource)
return user
}
func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup {
group := WorksmobileRemoteGroup{
ID: stringFromMap(resource, "id"),
ExternalID: stringFromMap(resource, "externalId"),
DisplayName: stringFromMap(resource, "displayName"),
}
group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource)
return group
}
func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUser {
email := firstStringFromMap(resource, "email", "loginId", "userName")
user := WorksmobileRemoteUser{
ID: firstStringFromMap(resource, "userId", "id"),
ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"),
UserName: email,
Email: email,
DisplayName: parseWorksmobileDirectoryUserName(resource),
LevelID: parseWorksmobileUserLevelID(resource),
LevelName: parseWorksmobileUserLevelName(resource),
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
Active: true,
}
if active, ok := resource["active"].(bool); ok {
user.Active = active
}
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
user.PrimaryOrgUnitID = primaryOrgUnit.ID
user.PrimaryOrgUnitName = primaryOrgUnit.Name
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
return user
}
func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup {
return WorksmobileRemoteGroup{
ID: firstStringFromMap(resource, "orgUnitId", "id"),
ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"),
DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"),
ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"),
ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"),
}
}
func parseWorksmobileDirectoryUserName(resource map[string]any) string {
if value := firstStringFromMap(resource, "displayName", "name"); value != "" {
return value
}
if name, ok := resource["userName"].(map[string]any); ok {
if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" {
return value
}
if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" {
return value
}
}
if name, ok := resource["name"].(map[string]any); ok {
if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" {
return value
}
if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" {
return value
}
}
return ""
}
func joinWorksmobileNameParts(lastName, firstName string) string {
lastName = strings.TrimSpace(lastName)
firstName = strings.TrimSpace(firstName)
if lastName == "" {
return firstName
}
if firstName == "" {
return lastName
}
return lastName + firstName
}
func parseWorksmobileUserLevelID(resource map[string]any) string {
if value := firstStringFromMap(resource, "levelId"); value != "" {
return value
}
if level, ok := resource["level"].(map[string]any); ok {
return firstStringFromMap(level, "levelId", "id", "value")
}
return ""
}
func parseWorksmobileUserLevelName(resource map[string]any) string {
if value := firstStringFromMap(resource, "levelName"); value != "" {
return value
}
if level, ok := resource["level"].(map[string]any); ok {
return firstStringFromMap(level, "levelName", "displayName", "name")
}
return ""
}
type worksmobileOrgUnitDetail struct {
ID string
Name string
PositionID string
PositionName string
IsManager *bool
}
func (d worksmobileOrgUnitDetail) empty() bool {
return d.ID == "" && d.Name == "" && d.PositionID == "" && d.PositionName == "" && d.IsManager == nil
}
func parseWorksmobilePrimaryOrgUnit(resource map[string]any) (string, string) {
detail := parseWorksmobilePrimaryOrgUnitDetail(resource)
return detail.ID, detail.Name
}
func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOrgUnitDetail {
if detail := parseWorksmobileOrgUnitDetailList(resource["organizations"], true); !detail.empty() {
return detail
}
if detail := parseWorksmobileOrgUnitDetailList(resource["orgUnits"], true); !detail.empty() {
return detail
}
for key, raw := range resource {
if !strings.Contains(strings.ToLower(key), "works") {
continue
}
if values, ok := raw.(map[string]any); ok {
if detail := parseWorksmobileOrgUnitDetailList(values["organizations"], true); !detail.empty() {
return detail
}
if detail := parseWorksmobileOrgUnitDetailList(values["orgUnits"], true); !detail.empty() {
return detail
}
}
}
return worksmobileOrgUnitDetail{}
}
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
if id != "" || name != "" {
return id, name
}
for _, key := range []string{"parent", "parentOrgUnit"} {
if values, ok := resource[key].(map[string]any); ok {
id = firstStringFromMap(values, "id", "orgUnitId", "value")
name = firstStringFromMap(values, "displayName", "orgUnitName", "name")
if id != "" || name != "" {
return id, name
}
}
}
for key, raw := range resource {
if !strings.Contains(strings.ToLower(key), "works") {
continue
}
if values, ok := raw.(map[string]any); ok {
if id, name := parseWorksmobileParentOrgUnit(values); id != "" || name != "" {
return id, name
}
}
}
return "", ""
}
func parseWorksmobileOrgUnitList(raw any, preferPrimary bool) (string, string) {
detail := parseWorksmobileOrgUnitDetailList(raw, preferPrimary)
return detail.ID, detail.Name
}
func parseWorksmobileOrgUnitDetailList(raw any, preferPrimary bool) worksmobileOrgUnitDetail {
values, ok := raw.([]any)
if !ok {
return worksmobileOrgUnitDetail{}
}
var fallback worksmobileOrgUnitDetail
for _, item := range values {
orgUnit, ok := item.(map[string]any)
if !ok {
continue
}
detail := worksmobileOrgUnitDetail{
ID: firstStringFromMap(orgUnit, "orgUnitId", "id", "value"),
Name: firstStringFromMap(orgUnit, "orgUnitName", "displayName", "name"),
PositionID: firstStringFromMap(orgUnit, "positionId"),
PositionName: firstStringFromMap(orgUnit, "positionName"),
IsManager: boolPointerFromMap(orgUnit, "isManager", "manager"),
}
if detail.empty() {
if nested := parseWorksmobileOrgUnitDetailList(orgUnit["orgUnits"], preferPrimary); !nested.empty() {
detail = nested
}
}
if fallback.empty() {
fallback = detail
}
if !preferPrimary || boolFromMap(orgUnit, "primary") {
return detail
}
}
return fallback
}
func stringFromMap(values map[string]any, key string) string {
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
func firstStringFromMap(values map[string]any, keys ...string) string {
for _, key := range keys {
if value := stringFromMap(values, key); value != "" {
return value
}
}
return ""
}
func boolFromMap(values map[string]any, key string) bool {
value, _ := values[key].(bool)
return value
}
func boolPointerFromMap(values map[string]any, keys ...string) *bool {
for _, key := range keys {
if value, ok := values[key].(bool); ok {
return &value
}
}
return nil
}
func (c *WorksmobileHTTPClient) baseURL() string {
if strings.TrimSpace(c.BaseURL) == "" {
return defaultWorksmobileAPIBaseURL
}
return c.BaseURL
}
func (c *WorksmobileHTTPClient) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{Timeout: 15 * time.Second}
}
func (c *WorksmobileHTTPClient) currentTime() time.Time {
if c.now != nil {
return c.now()
}
return time.Now()
}

View File

@@ -0,0 +1,703 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *testing.T) {
transport := &captureRoundTripper{
statusCode: http.StatusCreated,
body: `{}`,
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
SCIMToken: "scim-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.CreateUser(context.Background(), WorksmobileUserPayload{
DomainID: 300285955,
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
Locale: "ko_KR",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: GenerateWorksmobileInitialPassword(),
},
Organizations: []WorksmobileUserOrganization{
{DomainID: 300285955, Primary: true, OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:tenant-saman"}}},
},
})
require.NoError(t, err)
require.NotNil(t, transport.request)
require.Equal(t, "/v1.0/users", transport.request.URL.Path)
require.Equal(t, http.MethodPost, transport.request.Method)
require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization"))
var payload map[string]any
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
require.Equal(t, "tester@samaneng.com", payload["email"])
require.Equal(t, "user-1", payload["userExternalKey"])
require.NotContains(t, payload, "privateEmail")
require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"])
passwordConfig := payload["passwordConfig"].(map[string]any)
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
require.Len(t, passwordConfig["password"], 16)
}
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"ALREADY_EXISTS"}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300285955,
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
PrivateEmail: "private@example.com",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: GenerateWorksmobileInitialPassword(),
},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300285955,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:tenant-saman", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 2)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, "/v1.0/users", transport.requests[0].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
require.Equal(t, "/v1.0/users/tester@samaneng.com", transport.requests[1].URL.Path)
var patchPayload map[string]any
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &patchPayload))
require.NotContains(t, patchPayload, "passwordConfig")
require.NotContains(t, patchPayload, "privateEmail")
require.Equal(t, "tester@samaneng.com", patchPayload["email"])
require.Equal(t, "user-1", patchPayload["userExternalKey"])
}
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
SCIMToken: "scim-token-1",
HTTPClient: &http.Client{Transport: &captureRoundTripper{statusCode: http.StatusCreated, body: `{}`}},
}
err := client.CreateUser(context.Background(), WorksmobileUserPayload{Email: "tester@samaneng.com"})
require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile directory token is not configured")
}
func TestWorksmobileHTTPClientRequestsJWTBearerAccessToken(t *testing.T) {
privateKey := testRSAPrivateKeyPEM(t)
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"access_token":"directory-token-from-jwt","token_type":"Bearer","expires_in":3600}`},
{statusCode: http.StatusCreated, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
HTTPClient: &http.Client{Transport: transport},
now: func() time.Time { return time.Unix(1710000000, 0) },
OAuthConfig: WorksmobileOAuthConfig{
ClientID: "client-id-1",
ClientSecret: "client-secret-1",
ServiceAccount: "service-account-1",
PrivateKey: privateKey,
Scope: "directory",
TokenURL: "https://auth.example.test/token",
},
}
err := client.CreateUser(context.Background(), WorksmobileUserPayload{
DomainID: 300285955,
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
PasswordConfig: WorksmobilePasswordConfig{PasswordCreationType: "ADMIN", Password: "Aa1!Aa1!Aa1!Aa1!"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 2)
require.Equal(t, "https://auth.example.test/token", transport.requests[0].URL.String())
require.Equal(t, "/v1.0/users", transport.requests[1].URL.Path)
require.Equal(t, "Bearer directory-token-from-jwt", transport.requests[1].Header.Get("Authorization"))
form, err := url.ParseQuery(string(transport.requestBodies[0]))
require.NoError(t, err)
require.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", form.Get("grant_type"))
require.Equal(t, "client-id-1", form.Get("client_id"))
require.Equal(t, "client-secret-1", form.Get("client_secret"))
require.Equal(t, "directory", form.Get("scope"))
parts := strings.Split(form.Get("assertion"), ".")
require.Len(t, parts, 3)
payloadData, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(payloadData, &payload))
require.Equal(t, "client-id-1", payload["iss"])
require.Equal(t, "service-account-1", payload["sub"])
require.Equal(t, float64(1710000000), payload["iat"])
require.Equal(t, float64(1710003600), payload["exp"])
}
func TestWorksmobileHTTPClientListUsersUsesDirectoryAPIFirst(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"tester@samaneng.com","userName":{"lastName":"Tester"},"organizations":[{"primary":true,"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"삼안"}]}]}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
SCIMToken: "scim-token-1",
DomainIDs: []int64{300285955},
HTTPClient: &http.Client{Transport: transport},
}
users, err := client.ListUsers(context.Background())
require.NoError(t, err)
require.Len(t, users, 1)
require.Equal(t, "user-1", users[0].ExternalID)
require.Equal(t, "tester@samaneng.com", users[0].Email)
require.Equal(t, int64(300285955), users[0].DomainID)
require.Equal(t, "삼안", users[0].DomainName)
require.Equal(t, "works-org-1", users[0].PrimaryOrgUnitID)
require.Len(t, transport.requests, 1)
require.Equal(t, "/v1.0/users", transport.requests[0].URL.Path)
require.Equal(t, "300285955", transport.requests[0].URL.Query().Get("domainId"))
}
func TestWorksmobileHTTPClientListUsersFallsBackToSCIMWhenDirectoryFails(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusForbidden, body: `{"code":"FORBIDDEN"}`},
{statusCode: http.StatusOK, body: `{"totalResults":1,"Resources":[{"id":"scim-user-1","userName":"tester@samaneng.com","displayName":"Tester","emails":[]}]} `},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
SCIMToken: "scim-token-1",
DomainIDs: []int64{300285955},
HTTPClient: &http.Client{Transport: transport},
}
users, err := client.ListUsers(context.Background())
require.NoError(t, err)
require.Len(t, users, 1)
require.Equal(t, "scim-user-1", users[0].ID)
require.Equal(t, "tester@samaneng.com", users[0].Email)
require.Equal(t, "/v1.0/users", transport.requests[0].URL.Path)
require.Equal(t, "/scim/v2/Users", transport.requests[1].URL.Path)
}
func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitExternalKey":"tenant-1","orgUnitName":"삼안","parentOrgUnitId":"parent-1","parentOrgUnitName":"상위"}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
SCIMToken: "scim-token-1",
DomainIDs: []int64{300285955},
HTTPClient: &http.Client{Transport: transport},
}
groups, err := client.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, groups, 1)
require.Equal(t, "tenant-1", groups[0].ExternalID)
require.Equal(t, "삼안", groups[0].DisplayName)
require.Equal(t, int64(300285955), groups[0].DomainID)
require.Equal(t, "삼안", groups[0].DomainName)
require.Equal(t, "parent-1", groups[0].ParentID)
require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path)
}
func TestWorksmobileLiveJWTTokenExchange(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" {
t.Skip("live Worksmobile token exchange is disabled")
}
client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
})
token, err := client.directoryAccessToken(context.Background())
require.NoError(t, err)
require.NotEmpty(t, token)
}
func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: "Aa1!Aa1!Aa1!Aa1!",
},
}),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processingIDs)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
ID: "job-1",
Payload: domain.JSONMap{
"loginEmail": "tester@samaneng.com",
"initialPassword": "Aa1!Aa1!Aa1!Aa1!",
},
},
}
redacted := redactWorksmobileOutboxPayloads(jobs)
require.Nil(t, redacted[0].Payload)
}
func TestCompareWorksmobileUsersHidesMatchedByDefault(t *testing.T) {
localUsers := []domain.User{
{ID: "user-1", Email: "matched@samaneng.com", Name: "Matched"},
{ID: "user-2", Email: "missing@samaneng.com", Name: "Missing"},
}
remoteUsers := []WorksmobileRemoteUser{
{ID: "works-1", ExternalID: "user-1", Email: "matched@samaneng.com", DisplayName: "Matched"},
}
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
require.Len(t, diffOnly, 1)
require.Equal(t, "missing_in_worksmobile", diffOnly[0].Status)
require.Len(t, all, 2)
require.Equal(t, "matched", all[0].Status)
}
func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) {
tenantID := "tenant-primary"
localUsers := []domain.User{
{ID: "user-1", Email: "matched@samaneng.com", Name: "Matched", TenantID: &tenantID},
}
localTenants := map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "기술기획", Slug: "tech-planning"},
}
remoteUsers := []WorksmobileRemoteUser{
{
ID: "works-1",
ExternalID: "user-1",
Email: "matched@samaneng.com",
DisplayName: "Matched",
DomainID: 300285955,
DomainName: "삼안",
PrimaryOrgUnitID: "works-org-1",
PrimaryOrgUnitName: "WORKS 기술기획",
},
}
items := compareWorksmobileUsers(localUsers, remoteUsers, true, localTenants)
require.Len(t, items, 1)
require.Equal(t, tenantID, items[0].BaronPrimaryOrgID)
require.Equal(t, "기술기획", items[0].BaronPrimaryOrgName)
require.Equal(t, int64(300285955), items[0].WorksmobileDomainID)
require.Equal(t, "삼안", items[0].WorksmobileDomainName)
require.Equal(t, "works-org-1", items[0].WorksmobilePrimaryOrgID)
require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName)
}
func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) {
localUsers := []domain.User{
{ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"},
}
remoteUsers := []WorksmobileRemoteUser{
{ID: "works-1", ExternalID: "", Email: "tester@samaneng.com", DisplayName: "Tester"},
}
diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil)
all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil)
require.Empty(t, diffOnly)
require.Len(t, all, 1)
require.Equal(t, "matched", all[0].Status)
require.Equal(t, "works-1", all[0].WorksmobileID)
require.Empty(t, all[0].ExternalKey)
}
func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
remoteUsers := []WorksmobileRemoteUser{
{ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"},
}
items := compareWorksmobileUsers(nil, remoteUsers, true, nil)
require.Len(t, items, 1)
require.Equal(t, "missing_external_key", items[0].Status)
require.Equal(t, "works-1", items[0].WorksmobileID)
require.Equal(t, "works-only@samaneng.com", items[0].WorksmobileEmail)
}
func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) {
parentID := "tenant-parent"
childID := "tenant-child"
localTenants := []domain.Tenant{
{ID: parentID, Name: "기술본부", Type: domain.TenantTypeOrganization},
{ID: childID, Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID},
}
remoteGroups := []WorksmobileRemoteGroup{
{
ID: "works-parent",
ExternalID: parentID,
DisplayName: "WORKS 기술본부",
DomainID: 300286337,
DomainName: "총괄기획&기술개발센터",
},
{
ID: "works-child",
ExternalID: childID,
DisplayName: "WORKS 기술기획",
DomainID: 300286337,
DomainName: "총괄기획&기술개발센터",
ParentID: "works-parent",
ParentName: "WORKS 기술본부",
},
}
items := compareWorksmobileGroups(localTenants, remoteGroups, true)
require.Len(t, items, 2)
require.Equal(t, parentID, items[1].BaronParentID)
require.Equal(t, "기술본부", items[1].BaronParentName)
require.Equal(t, int64(300286337), items[1].WorksmobileDomainID)
require.Equal(t, "총괄기획&기술개발센터", items[1].WorksmobileDomainName)
require.Equal(t, "works-parent", items[1].WorksmobileParentID)
require.Equal(t, "WORKS 기술본부", items[1].WorksmobileParentName)
}
func TestCompareWorksmobileGroupsDoesNotMatchDomainCompanyByDomainID(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
parentID := "root-tenant"
localTenants := []domain.Tenant{
{
ID: "company-saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
},
}
remoteGroups := []WorksmobileRemoteGroup{
{
ID: "works-org-1",
DisplayName: "WORKS 전용 조직",
DomainID: 1001,
DomainName: "삼안",
},
}
items := compareWorksmobileGroups(localTenants, remoteGroups, true)
require.Len(t, items, 1)
require.Empty(t, items[0].BaronID)
require.Equal(t, "missing_external_key", items[0].Status)
}
func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) {
remoteGroups := []WorksmobileRemoteGroup{
{ID: "works-group-1", ExternalID: "", DisplayName: "WORKS 전용 조직"},
}
items := compareWorksmobileGroups(nil, remoteGroups, true)
require.Len(t, items, 1)
require.Equal(t, "missing_external_key", items[0].Status)
require.Equal(t, "works-group-1", items[0].WorksmobileID)
require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName)
}
func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) {
user := parseWorksmobileRemoteUser(map[string]any{
"id": "works-1",
"userName": "tester@samaneng.com",
"displayName": "Tester",
"emails": []any{},
})
require.Equal(t, "tester@samaneng.com", user.UserName)
require.Equal(t, "tester@samaneng.com", user.Email)
}
func TestParseWorksmobileRemoteResourcesExtractsOrgFields(t *testing.T) {
user := parseWorksmobileRemoteUser(map[string]any{
"id": "works-user",
"externalId": "user-1",
"organizations": []any{
map[string]any{
"primary": true,
"orgUnitId": "works-org-1",
"orgUnitName": "WORKS 기술기획",
},
},
})
group := parseWorksmobileRemoteGroup(map[string]any{
"id": "works-group",
"externalId": "group-1",
"parent": map[string]any{
"id": "works-parent",
"displayName": "WORKS 기술본부",
},
})
require.Equal(t, "works-org-1", user.PrimaryOrgUnitID)
require.Equal(t, "WORKS 기술기획", user.PrimaryOrgUnitName)
require.Equal(t, "works-parent", group.ParentID)
require.Equal(t, "WORKS 기술본부", group.ParentName)
}
func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing.T) {
user := parseWorksmobileDirectoryUser(map[string]any{
"userId": "works-user",
"email": "tester@samaneng.com",
"userName": map[string]any{
"lastName": "홍",
"firstName": "길동",
},
"levelId": "level-1",
"levelName": "책임",
"task": "기술검토",
"organizations": []any{
map[string]any{
"primary": true,
"orgUnits": []any{
map[string]any{
"orgUnitId": "works-org-1",
"orgUnitName": "기술기획",
"positionId": "position-1",
"positionName": "팀장",
"isManager": true,
},
},
},
},
})
require.Equal(t, "홍길동", user.DisplayName)
require.Equal(t, "level-1", user.LevelID)
require.Equal(t, "책임", user.LevelName)
require.Equal(t, "기술검토", user.Task)
require.Equal(t, "works-org-1", user.PrimaryOrgUnitID)
require.Equal(t, "기술기획", user.PrimaryOrgUnitName)
require.Equal(t, "position-1", user.PrimaryOrgUnitPositionID)
require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName)
require.NotNil(t, user.PrimaryOrgUnitIsManager)
require.True(t, *user.PrimaryOrgUnitIsManager)
}
type fakeWorksmobileOutboxRepo struct {
ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox
processingIDs []string
processedIDs []string
failedIDs []string
}
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
f.created = append(f.created, *item)
return nil
}
func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
return nil, nil
}
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
return f.ready, nil
}
func (f *fakeWorksmobileOutboxRepo) FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error) {
return nil, nil
}
func (f *fakeWorksmobileOutboxRepo) MarkRetry(ctx context.Context, id string) error {
return nil
}
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
f.processingIDs = append(f.processingIDs, id)
return nil
}
func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
f.processedIDs = append(f.processedIDs, id)
return nil
}
func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
f.failedIDs = append(f.failedIDs, id)
return nil
}
type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload
deletedUsers []string
}
type captureRoundTripper struct {
request *http.Request
requestBody []byte
statusCode int
body string
responses []captureResponse
requests []*http.Request
requestBodies [][]byte
}
type captureResponse struct {
statusCode int
body string
}
func (t *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
t.request = req
t.requests = append(t.requests, req)
if req.Body != nil {
data, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
t.requestBody = data
t.requestBodies = append(t.requestBodies, data)
}
statusCode := t.statusCode
body := t.body
if len(t.responses) > 0 {
response := t.responses[0]
t.responses = t.responses[1:]
statusCode = response.statusCode
body = response.body
}
if statusCode == 0 {
statusCode = http.StatusOK
}
return &http.Response{
StatusCode: statusCode,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}
func testRSAPrivateKeyPEM(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
data := x509.MarshalPKCS1PrivateKey(key)
return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: data}))
}
func getenvDefault(key string, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func (f *fakeWorksmobileDirectoryClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error {
f.createdOrgUnits = append(f.createdOrgUnits, payload)
return nil
}
func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
f.createdUsers = append(f.createdUsers, payload)
return nil
}
func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
f.createdUsers = append(f.createdUsers, payload)
return nil
}
func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error {
f.deletedUsers = append(f.deletedUsers, userID)
return nil
}
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
return nil, nil
}
func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
return nil, nil
}

View File

@@ -0,0 +1,145 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
if os.Getenv("WORKSMOBILE_LIVE_SAMAN_PROVISIONING") != "1" {
t.Skip("live Worksmobile Saman provisioning is disabled")
}
ctx := context.Background()
db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{})
require.NoError(t, err)
tenantRepo := repository.NewTenantRepository(db)
userRepo := repository.NewUserRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db)
outboxRepo := repository.NewWorksmobileOutboxRepository(db)
tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil)
client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{
ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"),
ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"),
PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"),
Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
})
syncService := NewWorksmobileSyncService(tenantService, userRepo, outboxRepo, client)
worker := NewWorksmobileRelayWorker(outboxRepo, client)
root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug)
require.NoError(t, err)
samanTenant, err := tenantService.GetTenantBySlug(ctx, "saman")
require.NoError(t, err)
createWorksmobileLiveOrgUnitIfMissing(t, ctx, client, *samanTenant)
targetEmails := []string{"tester@samaneng.com", "orgadmin@samaneng.com"}
for _, email := range targetEmails {
user, err := userRepo.FindByEmail(ctx, email)
require.NoError(t, err)
dedupeKey := "user:" + strings.ToLower(WorksmobileUserStatusAction(user.Status)) + ":" + user.ID
job := findWorksmobileLiveOutboxByDedupe(t, db, dedupeKey)
if job.Status != domain.WorksmobileOutboxStatusProcessed {
remote, err := client.FindUser(ctx, user.Email)
require.NoError(t, err)
if remote != nil {
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
continue
}
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID)
require.NoError(t, err)
require.NotEmpty(t, item)
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))
job = findWorksmobileLiveOutboxByDedupe(t, db, dedupeKey)
err = worker.processJob(ctx, job)
require.NoError(t, err)
}
processed, err := outboxRepo.FindByID(ctx, job.ID)
require.NoError(t, err)
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status)
}
credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID)
require.NoError(t, err)
seen := map[string]bool{}
for _, credential := range credentials {
if credential.Email == "tester@samaneng.com" || credential.Email == "orgadmin@samaneng.com" {
require.Equal(t, domain.WorksmobileOutboxStatusProcessed, credential.Status)
require.Len(t, credential.InitialPassword, 16)
seen[credential.Email] = true
}
}
require.True(t, seen["tester@samaneng.com"])
require.True(t, seen["orgadmin@samaneng.com"])
remoteUsers, err := client.ListUsers(ctx)
require.NoError(t, err)
remoteByEmail := map[string]WorksmobileRemoteUser{}
for _, user := range remoteUsers {
remoteByEmail[user.Email] = user
}
require.NotEmpty(t, remoteByEmail["tester@samaneng.com"].ID)
require.NotEmpty(t, remoteByEmail["orgadmin@samaneng.com"].ID)
remoteGroups, err := client.ListGroups(ctx)
require.NoError(t, err)
foundSamanOrgUnit := false
for _, group := range remoteGroups {
if group.ExternalID == samanTenant.ID {
foundSamanOrgUnit = true
require.Equal(t, "삼안", group.DisplayName)
}
}
require.True(t, foundSamanOrgUnit)
}
func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) {
t.Helper()
payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1)
require.NoError(t, err)
if tenant.ParentID != nil {
payload.ParentOrgUnitID = ""
}
err = client.CreateOrgUnit(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 {
return
}
require.NoError(t, err)
}
func worksmobileLiveDSN() string {
host := getenvDefault("DB_HOST", "localhost")
port := getenvDefault("DB_PORT", "5432")
user := getenvDefault("DB_USER", "baron")
password := os.Getenv("DB_PASSWORD")
name := getenvDefault("DB_NAME", "baron_sso")
return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", host, user, password, name, port)
}
func findWorksmobileLiveOutboxByDedupe(t *testing.T, db *gorm.DB, dedupeKey string) domain.WorksmobileOutbox {
t.Helper()
var job domain.WorksmobileOutbox
deadline := time.Now().Add(3 * time.Second)
for {
err := db.Where("dedupe_key = ?", dedupeKey).First(&job).Error
if err == nil {
return job
}
if time.Now().After(deadline) {
require.NoError(t, err)
}
time.Sleep(100 * time.Millisecond)
}
}

View File

@@ -0,0 +1,604 @@
package service
import (
"baron-sso-backend/internal/domain"
"crypto/rand"
"errors"
"fmt"
"math/big"
"net/mail"
"os"
"sort"
"strconv"
"strings"
)
const (
WorksmobileUserActionUpsert = "UPSERT"
WorksmobileUserActionSuspend = "SUSPEND"
)
type WorksmobileOrgUnitPayload struct {
DomainID int64 `json:"domainId"`
OrgUnitName string `json:"orgUnitName"`
OrgUnitExternalKey string `json:"orgUnitExternalKey"`
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
DisplayOrder int `json:"displayOrder"`
}
type WorksmobileUserPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email"`
UserExternalKey string `json:"userExternalKey"`
UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"`
}
type WorksmobilePasswordConfig struct {
PasswordCreationType string `json:"passwordCreationType"`
Password string `json:"password"`
}
type WorksmobileUserOrganization struct {
DomainID int64 `json:"domainId,omitempty"`
Primary bool `json:"primary,omitempty"`
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
}
type WorksmobileUserOrgUnit struct {
OrgUnitID string `json:"orgUnitId"`
Primary bool `json:"primary,omitempty"`
PositionID string `json:"positionId,omitempty"`
IsManager *bool `json:"isManager,omitempty"`
}
func BuildWorksmobileOrgUnitPayload(tenant domain.Tenant, rootConfig domain.JSONMap, displayOrder int) (WorksmobileOrgUnitPayload, error) {
return BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, tenant, rootConfig, displayOrder)
}
func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainTenant domain.Tenant, rootConfig domain.JSONMap, displayOrder int) (WorksmobileOrgUnitPayload, error) {
if err := ValidateWorksmobileExternalKey(tenant.ID); err != nil {
return WorksmobileOrgUnitPayload{}, err
}
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return WorksmobileOrgUnitPayload{}, err
}
payload := WorksmobileOrgUnitPayload{
DomainID: domainID,
OrgUnitName: strings.TrimSpace(tenant.Name),
OrgUnitExternalKey: tenant.ID,
DisplayOrder: displayOrder,
}
if tenant.ParentID != nil && *tenant.ParentID != "" {
if err := ValidateWorksmobileExternalKey(*tenant.ParentID); err != nil {
return WorksmobileOrgUnitPayload{}, err
}
payload.ParentOrgUnitID = "externalKey:" + *tenant.ParentID
}
return payload, nil
}
func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig)
}
func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.Tenant, _ domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return BuildWorksmobileUserPayloadForDomainTenants(user, tenant, map[string]domain.Tenant{tenant.ID: tenant}, rootConfig)
}
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
return WorksmobileUserPayload{}, err
}
if tenant.ID == "" {
return WorksmobileUserPayload{}, errors.New("tenant is required")
}
if tenantByID == nil {
tenantByID = map[string]domain.Tenant{}
}
tenantByID[tenant.ID] = tenant
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
}
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
}
if task == "" {
task = strings.TrimSpace(user.JobTitle)
}
payload := WorksmobileUserPayload{
DomainID: domainID,
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: strings.TrimSpace(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: GenerateWorksmobileInitialPassword(),
},
Task: task,
Organizations: organizations,
}
payload.AliasEmails = BuildWorksmobileAliasEmails(user, tenant)
return payload, nil
}
type worksmobileAppointment struct {
TenantID string
IsPrimary bool
IsOwner bool
HasOwner bool
JobTitle string
PositionID string
}
func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) ([]WorksmobileUserOrganization, string, error) {
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
if len(appointments) == 0 {
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
}
primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id")
if primaryTenantID == "" && user.TenantID != nil {
primaryTenantID = *user.TenantID
}
hasPrimary := false
for i := range appointments {
if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary {
appointments[i].IsPrimary = true
hasPrimary = true
break
}
}
if !hasPrimary {
for i := range appointments {
if appointments[i].TenantID == tenant.ID {
appointments[i].IsPrimary = true
break
}
}
}
organizations := make([]WorksmobileUserOrganization, 0, len(appointments))
seen := map[string]bool{}
task := ""
for _, appointment := range appointments {
if appointment.TenantID == "" || seen[appointment.TenantID] {
continue
}
appointmentTenant, ok := tenantByID[appointment.TenantID]
if !ok {
continue
}
if err := ValidateWorksmobileExternalKey(appointmentTenant.ID); err != nil {
return nil, "", err
}
domainTenant := worksmobileDomainClassificationTenant(appointmentTenant, tenantByID)
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return nil, "", err
}
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
Primary: appointment.IsPrimary,
PositionID: appointment.PositionID,
}
if appointment.HasOwner {
isManager := appointment.IsOwner
orgUnit.IsManager = &isManager
}
organizations = append(organizations, WorksmobileUserOrganization{
DomainID: domainID,
Primary: appointment.IsPrimary,
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
})
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" {
task = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
}
if len(organizations) == 0 {
return nil, "", errors.New("no valid worksmobile organization")
}
sortWorksmobileOrganizations(organizations)
return organizations, task, nil
}
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
rawAppointments, ok := metadata["additionalAppointments"].([]any)
if !ok {
return nil
}
appointments := make([]worksmobileAppointment, 0, len(rawAppointments))
for _, raw := range rawAppointments {
item, ok := raw.(map[string]any)
if !ok {
continue
}
appointment := worksmobileAppointment{
TenantID: metadataString(domain.JSONMap(item), "tenantId", "tenant_id"),
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
}
if isOwner, ok := metadataOptionalBool(domain.JSONMap(item), "isOwner", "isManager"); ok {
appointment.IsOwner = isOwner
appointment.HasOwner = true
}
appointments = append(appointments, appointment)
}
return appointments
}
func sortWorksmobileOrganizations(organizations []WorksmobileUserOrganization) {
sort.SliceStable(organizations, func(i, j int) bool {
if organizations[i].Primary != organizations[j].Primary {
return organizations[i].Primary
}
left := ""
right := ""
if len(organizations[i].OrgUnits) > 0 {
left = organizations[i].OrgUnits[0].OrgUnitID
}
if len(organizations[j].OrgUnits) > 0 {
right = organizations[j].OrgUnits[0].OrgUnitID
}
return left < right
})
}
func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []string {
candidates := metadataStringList(user.Metadata, "aliasEmails", "alias_emails", "worksmobileAliasEmails")
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
}
return normalizeWorksmobileAliasEmails(user.Email, candidates)
}
func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) []string {
result := make([]string, 0, len(candidates))
seen := map[string]bool{}
primary := strings.ToLower(strings.TrimSpace(primaryEmail))
for _, candidate := range candidates {
normalized := strings.ToLower(strings.TrimSpace(candidate))
if normalized == "" || normalized == primary || seen[normalized] {
continue
}
if _, err := mail.ParseAddress(normalized); err != nil {
continue
}
seen[normalized] = true
result = append(result, normalized)
}
return result
}
func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error {
seen := map[string]string{}
primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
if err != nil {
return err
}
seen[primaryLocalPart] = primaryEmail
for _, aliasEmail := range aliasEmails {
localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail)
if err != nil {
return err
}
if previous, ok := seen[localPart]; ok {
return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail)
}
if owner, ok := existingLocalParts[localPart]; ok {
return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner)
}
seen[localPart] = aliasEmail
}
return nil
}
func GenerateWorksmobileInitialPassword() string {
digits := "0123456789"
letters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
symbols := "!@#$%^&*()-_=+[]{}"
all := digits + letters + symbols
password := []byte{
randomChar(digits),
randomChar(letters),
randomChar(symbols),
}
for len(password) < 16 {
password = append(password, randomChar(all))
}
shuffleBytes(password)
return string(password)
}
func randomChar(chars string) byte {
if chars == "" {
return 'x'
}
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return chars[0]
}
return chars[index.Int64()]
}
func shuffleBytes(values []byte) {
for i := len(values) - 1; i > 0; i-- {
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
continue
}
values[i], values[j.Int64()] = values[j.Int64()], values[i]
}
}
func WorksmobileUserStatusAction(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case domain.UserStatusInactive, domain.UserStatusSuspended, domain.UserStatusLeaveOfAbsence:
return WorksmobileUserActionSuspend
default:
return WorksmobileUserActionUpsert
}
}
func ValidateWorksmobileExternalKey(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return errors.New("external key is required")
}
if strings.ContainsAny(value, `%\#/?`) {
return fmt.Errorf("external key contains unsupported character: %s", value)
}
return nil
}
func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap) (int64, error) {
envKey := worksmobileTenantDomainIDEnvKey(tenant)
if domainID, ok := worksmobileDomainIDFromEnv(envKey); ok {
return domainID, nil
}
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
}
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
return "SAMAN_DOMAIN_ID"
}
if isHanmacWorksmobileTenant(tenant) {
return "HANMAC_DOMAIN_ID"
}
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
return "GPDTDC_DOMAIN_ID"
}
return "BARONGROUP_DOMAIN_ID"
}
func worksmobileDomainIDFromEnv(key string) (int64, bool) {
if key == "" {
return 0, false
}
id, ok := parseDomainID(os.Getenv(key))
return id, ok
}
type worksmobileDomainEnvMapping struct {
Key string
Label string
}
func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping {
return []worksmobileDomainEnvMapping{
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
}
}
func WorksmobileDomainIDsFromEnv() []int64 {
mappings := worksmobileDomainEnvMappings()
result := make([]int64, 0, len(mappings))
seen := map[int64]bool{}
for _, mapping := range mappings {
if id, ok := worksmobileDomainIDFromEnv(mapping.Key); ok && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}
func WorksmobileDomainLabelForID(domainID int64) string {
for _, mapping := range worksmobileDomainEnvMappings() {
if id, ok := worksmobileDomainIDFromEnv(mapping.Key); ok && id == domainID {
return mapping.Label
}
}
return ""
}
func isHanmacWorksmobileTenant(tenant domain.Tenant) bool {
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
}
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
domainName = strings.ToLower(strings.TrimSpace(domainName))
for _, d := range tenant.Domains {
if strings.EqualFold(strings.TrimSpace(d.Domain), domainName) {
return true
}
}
return false
}
func tenantMatchesAny(tenant domain.Tenant, needles ...string) bool {
haystack := strings.ToLower(strings.TrimSpace(tenant.Slug + " " + tenant.Name))
for _, needle := range needles {
if strings.Contains(haystack, strings.ToLower(strings.TrimSpace(needle))) {
return true
}
}
return false
}
func WorksmobileEnabled(rootConfig domain.JSONMap) bool {
rawWorksmobile, ok := rootConfig["worksmobile"].(map[string]any)
if !ok {
if raw, ok := rootConfig["worksmobile"].(domain.JSONMap); ok {
rawWorksmobile = map[string]any(raw)
} else {
return false
}
}
enabled, _ := rawWorksmobile["enabled"].(bool)
return enabled
}
func WorksmobileDomainMappings(rootConfig domain.JSONMap) map[string]int64 {
result := map[string]int64{}
rawWorksmobile, ok := rootConfig["worksmobile"].(map[string]any)
if !ok {
if raw, ok := rootConfig["worksmobile"].(domain.JSONMap); ok {
rawWorksmobile = map[string]any(raw)
} else {
return result
}
}
rawMappings, ok := rawWorksmobile["domainMappings"].(map[string]any)
if !ok {
if raw, ok := rawWorksmobile["domainMappings"].(domain.JSONMap); ok {
rawMappings = map[string]any(raw)
} else {
return result
}
}
for key, raw := range rawMappings {
if id, ok := parseDomainID(raw); ok {
result[strings.ToLower(strings.TrimSpace(key))] = id
}
}
return result
}
func parseDomainID(raw any) (int64, bool) {
switch value := raw.(type) {
case int:
return int64(value), value > 0
case int64:
return value, value > 0
case float64:
id := int64(value)
return id, id > 0
case string:
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return id, err == nil && id > 0
default:
return 0, false
}
}
func metadataString(metadata domain.JSONMap, keys ...string) string {
for _, key := range keys {
if value, ok := metadata[key]; ok {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
}
return ""
}
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
value, _ := metadataOptionalBool(metadata, keys...)
return value
}
func metadataOptionalBool(metadata domain.JSONMap, keys ...string) (bool, bool) {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch v := value.(type) {
case bool:
return v, true
case string:
normalized := strings.ToLower(strings.TrimSpace(v))
if normalized == "true" || normalized == "1" || normalized == "yes" {
return true, true
}
if normalized == "false" || normalized == "0" || normalized == "no" {
return false, true
}
case int:
return v != 0, true
case float64:
return v != 0, true
}
}
return false, false
}
func metadataStringList(metadata domain.JSONMap, keys ...string) []string {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch v := value.(type) {
case []string:
return splitWorksmobileAliasValues(v)
case []any:
values := make([]string, 0, len(v))
for _, item := range v {
values = append(values, strings.TrimSpace(fmt.Sprint(item)))
}
return splitWorksmobileAliasValues(values)
case string:
return splitWorksmobileAliasValues([]string{v})
default:
return splitWorksmobileAliasValues([]string{fmt.Sprint(v)})
}
}
return nil
}
func splitWorksmobileAliasValues(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
})
for _, field := range fields {
if trimmed := strings.TrimSpace(field); trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
}

View File

@@ -0,0 +1,347 @@
package service
import (
"baron-sso-backend/internal/domain"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassification(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
parentID := "11111111-1111-1111-1111-111111111111"
tenant := domain.Tenant{
ID: "22222222-2222-2222-2222-222222222222",
Name: "Saman Engineering",
ParentID: &parentID,
Domains: []domain.TenantDomain{
{Domain: "samaneng.com"},
},
}
rootConfig := domain.JSONMap{
"worksmobile": map[string]any{
"domainMappings": map[string]any{
"samaneng.com": float64(9999),
},
},
}
payload, err := BuildWorksmobileOrgUnitPayload(tenant, rootConfig, 7)
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "Saman Engineering", payload.OrgUnitName)
require.Equal(t, tenant.ID, payload.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+parentID, payload.ParentOrgUnitID)
require.Equal(t, 7, payload.DisplayOrder)
}
func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) {
rootID := "038326b6-954a-48a7-a85f-efd83f62b82a"
payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID}
tenant := domain.Tenant{ParentID: &rootID}
normalized := normalizeWorksmobileOrgUnitParent(payload, tenant, nil, rootID)
require.Empty(t, normalized.ParentOrgUnitID)
}
func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "john1@samaneng.com",
Name: "John Doe",
Phone: "+19144812222",
Position: "Manager",
JobTitle: "Sales management",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"employee_id": "AB001",
},
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
rootConfig := domain.JSONMap{
"worksmobile": map[string]any{
"domainMappings": map[string]any{
"samaneng.com": int64(9999),
},
},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, rootConfig)
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "john1@samaneng.com", payload.Email)
require.Equal(t, user.ID, payload.UserExternalKey)
require.Equal(t, "John Doe", payload.UserName.LastName)
require.Equal(t, "+19144812222", payload.CellPhone)
require.Equal(t, "AB001", payload.EmployeeNumber)
require.Equal(t, "Sales management", payload.Task)
require.Empty(t, payload.PrivateEmail)
require.Empty(t, payload.AliasEmails)
require.Equal(t, "ko_KR", payload.Locale)
require.Equal(t, "ADMIN", payload.PasswordConfig.PasswordCreationType)
require.Len(t, payload.PasswordConfig.Password, 16)
require.True(t, containsAny(payload.PasswordConfig.Password, "0123456789"))
require.True(t, containsAny(payload.PasswordConfig.Password, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"))
require.True(t, containsAny(payload.PasswordConfig.Password, "!@#$%^&*()-_=+[]{}"))
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
}
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
primaryTenantID := "33333333-3333-3333-3333-333333333333"
secondaryTenantID := "55555555-5555-5555-5555-555555555555"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "john1@samaneng.com",
Name: "John Doe",
Phone: "+19144812222",
TenantID: &primaryTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": secondaryTenantID,
"isPrimary": false,
"isOwner": true,
"jobTitle": "PM",
"position": "팀장",
},
map[string]any{
"tenantId": primaryTenantID,
"isPrimary": true,
"isOwner": false,
"jobTitle": "Engineering",
"position": "책임",
},
},
},
}
primaryTenant := domain.Tenant{
ID: primaryTenantID,
Slug: "saman",
Name: "Saman",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
secondaryTenant := domain.Tenant{
ID: secondaryTenantID,
Slug: "hanmac",
Name: "Hanmac",
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
primaryTenant,
map[string]domain.Tenant{
primaryTenantID: primaryTenant,
secondaryTenantID: secondaryTenant,
},
nil,
)
require.NoError(t, err)
require.Equal(t, "Engineering", payload.Task)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
require.False(t, *payload.Organizations[0].OrgUnits[0].IsManager)
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.False(t, payload.Organizations[1].OrgUnits[0].Primary)
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
}
func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootConfig := domain.JSONMap{
"worksmobile": map[string]any{
"domainMappings": map[string]any{
"samaneng.com": int64(9999),
},
},
}
got, err := ResolveWorksmobileDomainIDFromTenant(
domain.Tenant{
Slug: "saman",
Name: "삼안",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
},
rootConfig,
)
require.NoError(t, err)
require.Equal(t, int64(1001), got)
}
func TestResolveWorksmobileDomainIDFromTenantRequiresFamilyDomainEnv(t *testing.T) {
rootConfig := domain.JSONMap{
"worksmobile": map[string]any{
"domainMappings": map[string]any{
"samaneng.com": int64(9999),
},
},
}
_, err := ResolveWorksmobileDomainIDFromTenant(
domain.Tenant{
Slug: "saman",
Name: "삼안",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
},
rootConfig,
)
require.Error(t, err)
require.Contains(t, err.Error(), "SAMAN_DOMAIN_ID")
}
func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tests := []struct {
name string
tenant domain.Tenant
want int64
}{
{
name: "saman",
tenant: domain.Tenant{Slug: "saman", Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
want: 1001,
},
{
name: "hanmac",
tenant: domain.Tenant{Slug: "hanmac", Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
want: 1002,
},
{
name: "gpdtdc",
tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"},
want: 1003,
},
{
name: "barongroup fallback",
tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"},
want: 1004,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveWorksmobileDomainIDFromTenant(tt.tenant, nil)
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) {
t.Setenv("HANMAC_DOMAIN_ID", "1002")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "main@hanmaceng.co.kr",
Name: "Hanmac User",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"employee_id": "HM001",
"personal_email": "private@example.com",
},
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "hanmac",
Name: "한맥",
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, int64(1002), payload.DomainID)
require.Equal(t, []string{"hm001@hanmaceng.co.kr"}, payload.AliasEmails)
require.Empty(t, payload.PrivateEmail)
require.Equal(t, "ko_KR", payload.Locale)
}
func TestBuildWorksmobileUserPayloadAddsMultipleMetadataAliases(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "main@samaneng.com",
Name: "Saman User",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"aliasEmails": []any{"alias1@samaneng.com", "alias2@samaneng.com", "main@samaneng.com"},
},
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "삼안",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, []string{"alias1@samaneng.com", "alias2@samaneng.com"}, payload.AliasEmails)
}
func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) {
err := ValidateWorksmobileAliasLocalParts(
"main@samaneng.com",
[]string{"main@hanmaceng.co.kr"},
map[string]string{},
)
require.Error(t, err)
require.Contains(t, err.Error(), "local-part")
err = ValidateWorksmobileAliasLocalParts(
"main@samaneng.com",
[]string{"alias@hanmaceng.co.kr"},
map[string]string{"alias": "existing-user"},
)
require.Error(t, err)
require.Contains(t, err.Error(), "이미 사용 중")
}
func containsAny(value string, candidates string) bool {
return strings.ContainsAny(value, candidates)
}
func TestWorksmobileUserStatusAction(t *testing.T) {
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusActive))
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusInactive))
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusSuspended))
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence))
}
func TestValidateWorksmobileExternalKeyRejectsUnsupportedCharacters(t *testing.T) {
require.NoError(t, ValidateWorksmobileExternalKey("44444444-4444-4444-4444-444444444444"))
require.Error(t, ValidateWorksmobileExternalKey("user/with/slash"))
require.Error(t, ValidateWorksmobileExternalKey("user#with-hash"))
}

View File

@@ -0,0 +1,141 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"time"
)
type WorksmobileRelayWorker struct {
repo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
interval time.Duration
batchLimit int
}
func NewWorksmobileRelayWorker(repo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *WorksmobileRelayWorker {
return &WorksmobileRelayWorker{
repo: repo,
client: client,
interval: 3 * time.Second,
batchLimit: 10,
}
}
func (w *WorksmobileRelayWorker) Start(ctx context.Context) {
if w.repo == nil || w.client == nil {
slog.Warn("Worksmobile relay worker disabled")
return
}
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
if err := w.ProcessOnce(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Warn("Worksmobile relay tick failed", "error", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
jobs, err := w.repo.ListReady(ctx, w.batchLimit)
if err != nil {
return err
}
for _, job := range jobs {
if err := w.processJob(ctx, job); err != nil {
slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err)
}
}
return nil
}
func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error {
if err := w.repo.MarkProcessing(ctx, job.ID); err != nil {
return err
}
err := w.dispatch(ctx, job)
if err != nil {
nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount))
_ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt)
return err
}
return w.repo.MarkProcessed(ctx, job.ID)
}
func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.WorksmobileOutbox) error {
if job.Action == domain.WorksmobileActionDryRun {
return nil
}
switch job.ResourceType {
case domain.WorksmobileResourceOrgUnit:
if job.Action != domain.WorksmobileActionUpsert {
return nil
}
var payload WorksmobileOrgUnitPayload
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err
}
err := w.client.CreateOrgUnit(ctx, payload)
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 {
return nil
}
return err
case domain.WorksmobileResourceUser:
switch job.Action {
case domain.WorksmobileActionUpsert:
var payload WorksmobileUserPayload
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err
}
return w.client.UpsertUser(ctx, payload)
case domain.WorksmobileActionDelete:
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {
userID = stringValue(job.Payload["userExternalKey"])
}
return w.client.DeleteUser(ctx, userID)
default:
return nil
}
default:
return nil
}
}
func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
raw := payload["request"]
if raw == nil {
return errors.New("worksmobile request payload is missing")
}
data, err := json.Marshal(raw)
if err != nil {
return err
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}
func worksmobileRetryDelay(retryCount int) time.Duration {
if retryCount < 0 {
retryCount = 0
}
if retryCount > 5 {
retryCount = 5
}
return time.Duration(1<<retryCount) * time.Minute
}

View File

@@ -0,0 +1,881 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"errors"
"os"
"sort"
"strings"
)
const HanmacFamilyTenantSlug = "hanmac-family"
type WorksmobileSyncer interface {
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
}
type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
}
type WorksmobileConfigSummary struct {
Enabled bool `json:"enabled"`
DomainMappings map[string]int64 `json:"domainMappings"`
TokenConfigured bool `json:"tokenConfigured"`
}
type WorksmobileTenantOverview struct {
Tenant domain.Tenant `json:"tenant"`
Config WorksmobileConfigSummary `json:"config"`
RecentJobs []domain.WorksmobileOutbox `json:"recentJobs"`
}
type WorksmobileBackfillDryRun struct {
OrgUnitCount int `json:"orgUnitCount"`
UserCount int `json:"userCount"`
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
InitialPassword string `json:"initialPassword"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
}
type WorksmobileComparison struct {
Users []WorksmobileComparisonItem `json:"users"`
Groups []WorksmobileComparisonItem `json:"groups"`
}
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
WorksmobileName string `json:"worksmobileName,omitempty"`
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
Status string `json:"status"`
}
type worksmobileSyncService struct {
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
}
func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService {
return &worksmobileSyncService{
tenantService: tenantService,
userRepo: userRepo,
outboxRepo: outboxRepo,
client: client,
}
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecent(ctx, 50)
jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{
Tenant: *tenant,
Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(tenant.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(),
},
RecentJobs: jobs,
}, nil
}
func worksmobileDirectoryAuthConfigured() bool {
if strings.TrimSpace(os.Getenv("WORKS_ADMIN_ACCESS_TOKEN")) != "" || strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN")) != "" {
return true
}
return strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID")) != "" &&
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET")) != "" &&
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT")) != "" &&
(strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY")) != "" ||
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
}
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
for i := range jobs {
if jobs[i].Payload != nil {
jobs[i].Payload = nil
}
}
return jobs
}
func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileComparison{}, err
}
if s.client == nil {
return WorksmobileComparison{}, errors.New("worksmobile client is not configured")
}
tenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileComparison{}, err
}
tenantByID := worksmobileTenantByID(tenants)
tenantByID[root.ID] = *root
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
if isWorksmobileUserScopeTenant(tenant) {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return WorksmobileComparison{}, err
}
remoteUsers, err := s.client.ListUsers(ctx)
if err != nil {
return WorksmobileComparison{}, err
}
remoteGroups, err := s.client.ListGroups(ctx)
if err != nil {
return WorksmobileComparison{}, err
}
return WorksmobileComparison{
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
tenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
orgUnitTenantIDs := make([]string, 0, len(tenants))
userTenantIDs := make([]string, 0, len(tenants))
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...))
for _, tenant := range tenants {
if isWorksmobileOrgUnitTenant(tenant, tenantByID) {
orgUnitTenantIDs = append(orgUnitTenantIDs, tenant.ID)
}
if isWorksmobileUserScopeTenant(tenant) {
userTenantIDs = append(userTenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, userTenantIDs)
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
_ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: root.ID,
Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
},
})
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
}
func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
tenant, err := s.tenantService.GetTenant(ctx, orgUnitID)
if err != nil {
return nil, err
}
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil {
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
return nil, errors.New("target orgunit is outside hanmac-family subtree")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
*tenant,
worksmobileDomainClassificationTenant(*tenant, tenantByID),
root.Config,
0,
)
if err != nil {
return nil, err
}
payload = normalizeWorksmobileOrgUnitParent(payload, *tenant, tenantByID, root.ID)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload},
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user.TenantID == nil {
return nil, errors.New("target user has no tenant")
}
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
if err != nil {
return nil, err
}
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil {
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
return nil, errors.New("target user is outside hanmac-family subtree")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
*user,
*tenant,
tenantByID,
root.Config,
)
if err != nil {
return nil, err
}
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
return nil, err
}
action := WorksmobileUserStatusAction(user.Status)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload),
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
if err != nil {
return nil, err
}
credentials := make([]WorksmobileInitialPasswordCredential, 0)
seen := map[string]bool{}
for _, job := range jobs {
if job.ResourceType != domain.WorksmobileResourceUser {
continue
}
if stringValue(job.Payload["tenantRootId"]) != root.ID {
continue
}
email := stringValue(job.Payload["loginEmail"])
password := stringValue(job.Payload["initialPassword"])
if email == "" || password == "" || seen[email] {
continue
}
seen[email] = true
credentials = append(credentials, WorksmobileInitialPasswordCredential{
Email: email,
InitialPassword: password,
Status: job.Status,
LastError: job.LastError,
})
}
return credentials, nil
}
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
return nil, err
}
if err := s.outboxRepo.MarkRetry(ctx, jobID); err != nil {
return nil, err
}
return s.outboxRepo.FindByID(ctx, jobID)
}
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
root, ok, err := s.rootForTenant(ctx, tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
tenant,
worksmobileDomainClassificationTenant(tenant, tenantByID),
root.Config,
0,
)
if err != nil {
return err
}
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload},
})
}
func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
root, ok, err := s.rootForTenant(ctx, tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:" + tenant.ID,
Payload: domain.JSONMap{"orgUnitExternalKey": tenant.ID},
})
}
func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
if user.TenantID == nil || *user.TenantID == "" {
return nil
}
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
if err != nil {
return err
}
root, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
*tenant,
tenantByID,
root.Config,
)
if err != nil {
return err
}
if err := s.validateUserAliasLocalParts(ctx, root, user, payload); err != nil {
return err
}
action := WorksmobileUserStatusAction(user.Status)
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload),
})
}
func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
if user.TenantID == nil || *user.TenantID == "" {
return nil
}
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
if err != nil {
return err
}
_, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil || !ok {
return err
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: domain.WorksmobileActionDelete,
DedupeKey: "user:delete:" + user.ID,
Payload: domain.JSONMap{
"userExternalKey": user.ID,
"loginEmail": user.Email,
},
})
}
func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string) (*domain.Tenant, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
return nil, err
}
if tenant.Slug != HanmacFamilyTenantSlug || tenant.ParentID != nil {
return nil, errors.New("worksmobile is only available for hanmac-family root tenant")
}
return tenant, nil
}
func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) {
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "")
if err != nil {
return nil, err
}
byParent := map[string][]domain.Tenant{}
for _, tenant := range all {
if tenant.ParentID != nil {
byParent[*tenant.ParentID] = append(byParent[*tenant.ParentID], tenant)
}
}
result := []domain.Tenant{}
var visit func(id string)
visit = func(id string) {
for _, child := range byParent[id] {
result = append(result, child)
visit(child.ID)
}
}
visit(rootID)
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result, nil
}
func (s *worksmobileSyncService) rootForTenant(ctx context.Context, tenant domain.Tenant) (*domain.Tenant, bool, error) {
current := tenant
for current.ParentID != nil && *current.ParentID != "" {
parent, err := s.tenantService.GetTenant(ctx, *current.ParentID)
if err != nil {
return nil, false, err
}
current = *parent
}
return &current, current.Slug == HanmacFamilyTenantSlug, nil
}
func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context, root *domain.Tenant, user domain.User, payload WorksmobileUserPayload) error {
if len(payload.AliasEmails) == 0 {
return nil
}
tenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := make(map[string]domain.Tenant, len(tenants)+1)
tenantByID[root.ID] = *root
tenantIDs := make([]string, 0, len(tenants)+1)
tenantIDs = append(tenantIDs, root.ID)
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
if isWorksmobileUserScopeTenant(tenant) {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return err
}
existing := map[string]string{}
for _, existingUser := range users {
if existingUser.ID == user.ID {
continue
}
addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
if existingUser.TenantID == nil {
continue
}
tenant, ok := tenantByID[*existingUser.TenantID]
if !ok {
continue
}
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
addWorksmobileLocalPart(existing, alias, existingUser.ID)
}
}
return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
}
func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
if err == nil && localPart != "" {
target[localPart] = owner
}
}
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if tenant.Type == domain.TenantTypeOrganization {
return true
}
if tenant.Type == domain.TenantTypeCompany {
return isWorksmobileBarongroupChildCompany(tenant, tenantByID)
}
return false
}
func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool {
return tenant.Type == domain.TenantTypeCompany || tenant.Type == domain.TenantTypeOrganization || tenant.Type == domain.TenantTypeUserGroup
}
func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
current := tenant
for {
envKey := worksmobileTenantDomainIDEnvKey(current)
if envKey != "BARONGROUP_DOMAIN_ID" || current.Type == domain.TenantTypeCompany {
return current
}
parentID := worksmobileTenantParentID(current)
if parentID == "" {
return tenant
}
parent, ok := tenantByID[parentID]
if !ok {
return tenant
}
current = parent
}
}
func isWorksmobileBarongroupChildCompany(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if tenant.Type != domain.TenantTypeCompany || tenant.Slug == "baron-group" {
return false
}
parentID := worksmobileTenantParentID(tenant)
for parentID != "" {
parent, ok := tenantByID[parentID]
if !ok {
return false
}
if parent.Slug == "baron-group" {
return true
}
parentID = worksmobileTenantParentID(parent)
}
return false
}
func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootID string) WorksmobileOrgUnitPayload {
if tenant.ParentID != nil && *tenant.ParentID == rootID {
payload.ParentOrgUnitID = ""
}
if tenant.ParentID != nil {
if parent, ok := tenantByID[*tenant.ParentID]; ok && parent.Slug == "baron-group" {
payload.ParentOrgUnitID = ""
}
}
return payload
}
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload) domain.JSONMap {
return domain.JSONMap{
"request": payload,
"tenantRootId": rootID,
"loginEmail": payload.Email,
"initialPassword": payload.PasswordConfig.Password,
}
}
func stringValue(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return ""
}
}
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteUser{}
remoteByEmail := map[string]WorksmobileRemoteUser{}
for _, remote := range remoteUsers {
if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote
}
if normalizedEmail := strings.ToLower(strings.TrimSpace(remote.Email)); normalizedEmail != "" {
remoteByEmail[normalizedEmail] = remote
}
}
localByID := map[string]domain.User{}
matchedRemoteIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, user := range localUsers {
localByID[user.ID] = user
remote, matched := remoteByExternalID[user.ID]
if !matched {
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
if matched && !includeMatched {
matchedRemoteIDs[remote.ID] = true
continue
}
item := WorksmobileComparisonItem{
ResourceType: "USER",
BaronID: user.ID,
BaronName: user.Name,
BaronEmail: user.Email,
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
Status: "missing_in_worksmobile",
}
if matched {
item.Status = "matched"
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
item.WorksmobileEmail = remote.Email
item.WorksmobileLevelID = remote.LevelID
item.WorksmobileLevelName = remote.LevelName
item.WorksmobileTask = remote.Task
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
item.WorksmobilePrimaryOrgID = remote.PrimaryOrgUnitID
item.WorksmobilePrimaryOrgName = remote.PrimaryOrgUnitName
item.WorksmobilePrimaryOrgPositionID = remote.PrimaryOrgUnitPositionID
item.WorksmobilePrimaryOrgPositionName = remote.PrimaryOrgUnitPositionName
item.WorksmobilePrimaryOrgIsManager = remote.PrimaryOrgUnitIsManager
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
}
for _, remote := range remoteUsers {
if matchedRemoteIDs[remote.ID] {
continue
}
if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{
ResourceType: "USER",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileLevelID: remote.LevelID,
WorksmobileLevelName: remote.LevelName,
WorksmobileTask: remote.Task,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobilePrimaryOrgID: remote.PrimaryOrgUnitID,
WorksmobilePrimaryOrgName: remote.PrimaryOrgUnitName,
WorksmobilePrimaryOrgPositionID: remote.PrimaryOrgUnitPositionID,
WorksmobilePrimaryOrgPositionName: remote.PrimaryOrgUnitPositionName,
WorksmobilePrimaryOrgIsManager: remote.PrimaryOrgUnitIsManager,
Status: "missing_external_key",
})
continue
}
if _, ok := localByID[remote.ExternalID]; !ok {
result = append(result, WorksmobileComparisonItem{
ResourceType: "USER",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileLevelID: remote.LevelID,
WorksmobileLevelName: remote.LevelName,
WorksmobileTask: remote.Task,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobilePrimaryOrgID: remote.PrimaryOrgUnitID,
WorksmobilePrimaryOrgName: remote.PrimaryOrgUnitName,
WorksmobilePrimaryOrgPositionID: remote.PrimaryOrgUnitPositionID,
WorksmobilePrimaryOrgPositionName: remote.PrimaryOrgUnitPositionName,
WorksmobilePrimaryOrgIsManager: remote.PrimaryOrgUnitIsManager,
Status: "missing_in_baron",
})
}
}
return result
}
func worksmobileUserPrimaryOrgID(user domain.User) string {
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]domain.Tenant) string {
tenantID := worksmobileUserPrimaryOrgID(user)
if tenantID == "" {
return ""
}
if tenant, ok := localTenants[tenantID]; ok {
return strings.TrimSpace(tenant.Name)
}
if user.Tenant != nil && user.Tenant.ID == tenantID {
return strings.TrimSpace(user.Tenant.Name)
}
return ""
}
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
for _, remote := range remoteGroups {
if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote
}
}
tenantByID := worksmobileTenantByID(localTenants)
localByID := map[string]domain.Tenant{}
ignoredLocalByID := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, tenant := range localTenants {
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
ignoredLocalByID[tenant.ID] = true
continue
}
localByID[tenant.ID] = tenant
remote, matched := remoteByExternalID[tenant.ID]
if matched && !includeMatched {
continue
}
item := WorksmobileComparisonItem{
ResourceType: "GROUP",
BaronID: tenant.ID,
BaronName: tenant.Name,
BaronParentID: worksmobileTenantParentID(tenant),
BaronParentName: worksmobileTenantParentName(tenant, tenantByID),
Status: "missing_in_worksmobile",
}
if matched {
item.Status = "matched"
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
item.WorksmobileParentID = remote.ParentID
item.WorksmobileParentName = remote.ParentName
}
result = append(result, item)
}
for _, remote := range remoteGroups {
if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{
ResourceType: "GROUP",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID,
WorksmobileParentName: remote.ParentName,
Status: "missing_external_key",
})
continue
}
if ignoredLocalByID[remote.ExternalID] {
continue
}
if _, ok := localByID[remote.ExternalID]; !ok {
result = append(result, WorksmobileComparisonItem{
ResourceType: "GROUP",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID,
WorksmobileParentName: remote.ParentName,
Status: "missing_in_baron",
})
}
}
return result
}
func worksmobileTenantByID(tenants []domain.Tenant) map[string]domain.Tenant {
result := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
result[tenant.ID] = tenant
}
return result
}
func worksmobileTenantParentID(tenant domain.Tenant) string {
if tenant.ParentID == nil {
return ""
}
return strings.TrimSpace(*tenant.ParentID)
}
func worksmobileTenantParentName(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string {
parentID := worksmobileTenantParentID(tenant)
if parentID == "" {
return ""
}
return strings.TrimSpace(tenantByID[parentID].Name)
}

View File

@@ -0,0 +1,387 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"aliasEmails": []any{"used@hanmaceng.co.kr"},
},
}
existing := domain.User{
ID: "existing-user",
Email: "used@samaneng.com",
Name: "Existing",
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, existing}},
outboxRepo,
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "이미 사용 중")
require.Empty(t, outboxRepo.created)
}
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
parentID := "root-tenant"
root := domain.Tenant{
ID: parentID,
Name: "한맥가족",
Slug: HanmacFamilyTenantSlug,
Type: domain.TenantTypeCompanyGroup,
}
hanmac := domain.Tenant{
ID: "hanmac-tenant",
Name: "한맥기술",
Slug: "hanmac",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
}
barongroup := domain.Tenant{
ID: "barongroup-tenant",
Name: "바론그룹",
Slug: "baron-group",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
}
barongroupChildCompany := domain.Tenant{
ID: "barongroup-child-company",
Name: "바론그룹 하위 회사",
Type: domain.TenantTypeCompany,
ParentID: &barongroup.ID,
}
organization := domain.Tenant{
ID: "organization-tenant",
Name: "정규 조직",
Type: domain.TenantTypeOrganization,
ParentID: &hanmac.ID,
}
legacyUserGroup := domain.Tenant{
ID: "legacy-user-group-tenant",
Name: "레거시 사용자 그룹",
Type: domain.TenantTypeUserGroup,
ParentID: &hanmac.ID,
}
items := compareWorksmobileGroups(
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup},
[]WorksmobileRemoteGroup{
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name},
{ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name},
{ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name},
{ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name},
{ID: "works-legacy-user-group", ExternalID: legacyUserGroup.ID, DisplayName: legacyUserGroup.Name},
{ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
},
true,
)
require.Len(t, items, 3)
require.Equal(t, barongroupChildCompany.ID, items[0].BaronID)
require.Equal(t, "matched", items[0].Status)
require.Equal(t, organization.ID, items[1].BaronID)
require.Equal(t, "matched", items[1].Status)
require.Equal(t, "works-orphan", items[2].ExternalKey)
require.Equal(t, "missing_in_baron", items[2].Status)
}
func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
companyID := "company-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, companyID)
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile orgunit tenant")
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
rootID := "root-tenant"
barongroupID := "barongroup-tenant"
companyID := "barongroup-child-company"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
barongroup := domain.Tenant{
ID: barongroupID,
Slug: "baron-group",
Name: "바론그룹",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
company := domain.Tenant{
ID: companyID,
Slug: "barongroup-child",
Name: "바론그룹 하위 회사",
Type: domain.TenantTypeCompany,
ParentID: &barongroupID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, barongroupID: barongroup, companyID: company}, list: []domain.Tenant{root, barongroup, company}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, companyID)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, companyID, request.OrgUnitExternalKey)
require.Empty(t, request.ParentOrgUnitID)
}
func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
companyID := "company-tenant"
organizationID := "organization-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
organization := domain.Tenant{
ID: organizationID,
Slug: "engineering",
Name: "기술본부",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, companyID: company, organizationID: organization},
list: []domain.Tenant{root, company, organization},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organizationID)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, organizationID, request.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+companyID, request.ParentOrgUnitID)
}
func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
userGroupID := "user-group-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Name: "계열사",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
userGroup := domain.Tenant{
ID: userGroupID,
Name: "연동 조직",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
userRepo := &fakeWorksmobileUserRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company, userGroupID: userGroup}, list: []domain.Tenant{root, company, userGroup}},
userRepo,
&fakeWorksmobileOutboxRepo{},
&fakeWorksmobileDirectoryClient{},
)
_, err := service.GetComparison(context.Background(), rootID, true)
require.NoError(t, err)
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
}
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant
}
func (f *fakeWorksmobileTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
tenant := f.tenants[id]
return &tenant, nil
}
func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
return f.list, int64(len(f.list)), nil
}
func (f *fakeWorksmobileTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
return false, nil
}
func (f *fakeWorksmobileTenantService) ApproveTenant(ctx context.Context, id string) error {
return nil
}
func (f *fakeWorksmobileTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) SetKetoService(keto KetoService) {}
func (f *fakeWorksmobileTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
return nil
}
type fakeWorksmobileUserRepo struct {
byID map[string]domain.User
byTenant []domain.User
requestedTenantIDs []string
}
func (f *fakeWorksmobileUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
user := f.byID[id]
return &user, nil
}
func (f *fakeWorksmobileUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
return nil, 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
return 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
return f.byTenant, nil
}
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (f *fakeWorksmobileUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (f *fakeWorksmobileUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}

0
backend/seed-tenant.csv Normal file
View File