forked from baron/baron-sso
Merge branch 'feature/worksmobile' into dev
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
132
backend/internal/handler/worksmobile_handler.go
Normal file
132
backend/internal/handler/worksmobile_handler.go
Normal 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())
|
||||
}
|
||||
128
backend/internal/handler/worksmobile_handler_test.go
Normal file
128
backend/internal/handler/worksmobile_handler_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user