1
0
forked from baron/baron-sso

Merge branch 'feature/worksmobile' into dev

This commit is contained in:
2026-05-06 09:31:04 +09:00
74 changed files with 8698 additions and 212 deletions

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
}