forked from baron/baron-sso
정합성 위반사항 확인 및 조치기능 추가
This commit is contained in:
@@ -716,6 +716,8 @@ func main() {
|
||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
||||
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
|
||||
admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
|
||||
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
|
||||
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
|
||||
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
|
||||
|
||||
@@ -39,3 +39,22 @@ type DataIntegrityCheck struct {
|
||||
Severity string `json:"severity"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type OrphanUserLoginID struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
UserDeletedAt *time.Time `json:"userDeletedAt,omitempty"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
TenantDeletedAt *time.Time `json:"tenantDeletedAt,omitempty"`
|
||||
FieldKey string `json:"fieldKey"`
|
||||
LoginID string `json:"loginId"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
type DeleteOrphanUserLoginIDsResult struct {
|
||||
DeletedCount int64 `json:"deletedCount"`
|
||||
Deleted []OrphanUserLoginID `json:"deleted"`
|
||||
SkippedIDs []string `json:"skippedIds"`
|
||||
}
|
||||
|
||||
@@ -169,6 +169,43 @@ func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
|
||||
return c.JSON(report)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListOrphanUserLoginIDs(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IntegrityChecker == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||
}
|
||||
items, err := h.IntegrityChecker.ListOrphanUserLoginIDs(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteOrphanUserLoginIDs(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IntegrityChecker == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||
}
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
result, err := h.IntegrityChecker.DeleteOrphanUserLoginIDs(c.Context(), req.IDs)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
// GetSystemStats returns runtime statistics for monitoring
|
||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||
var m runtime.MemStats
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +15,14 @@ import (
|
||||
)
|
||||
|
||||
type fakeDataIntegrityChecker struct {
|
||||
calls int
|
||||
report domain.DataIntegrityReport
|
||||
err error
|
||||
calls int
|
||||
listCalls int
|
||||
deleteCalls int
|
||||
deletedIDs []string
|
||||
report domain.DataIntegrityReport
|
||||
orphans []domain.OrphanUserLoginID
|
||||
deleteResult domain.DeleteOrphanUserLoginIDsResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
||||
@@ -24,6 +30,17 @@ func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (doma
|
||||
return f.report, f.err
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
|
||||
f.listCalls++
|
||||
return f.orphans, f.err
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||
f.deleteCalls++
|
||||
f.deletedIDs = append([]string(nil), ids...)
|
||||
return f.deleteResult, f.err
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
@@ -90,3 +107,90 @@ func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
|
||||
require.Len(t, body.Sections, 1)
|
||||
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
|
||||
}
|
||||
|
||||
func TestAdminHandler_ListOrphanUserLoginIDsReturnsTargetsForSuperAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{
|
||||
orphans: []domain.OrphanUserLoginID{
|
||||
{
|
||||
ID: "login-id-1",
|
||||
UserID: "user-1",
|
||||
TenantID: "tenant-1",
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "EMP001",
|
||||
Reasons: []string{"missing_tenant"},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity/orphan-user-login-ids", h.ListOrphanUserLoginIDs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity/orphan-user-login-ids", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, checker.listCalls)
|
||||
|
||||
var body struct {
|
||||
Items []domain.OrphanUserLoginID `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, 1, body.Total)
|
||||
require.Equal(t, "login-id-1", body.Items[0].ID)
|
||||
require.Equal(t, []string{"missing_tenant"}, body.Items[0].Reasons)
|
||||
}
|
||||
|
||||
func TestAdminHandler_DeleteOrphanUserLoginIDsRequiresSuperAdminAndDeletesSelectedTargets(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{
|
||||
deleteResult: domain.DeleteOrphanUserLoginIDsResult{
|
||||
DeletedCount: 1,
|
||||
Deleted: []domain.OrphanUserLoginID{
|
||||
{ID: "login-id-1", LoginID: "EMP001", Reasons: []string{"missing_user"}},
|
||||
},
|
||||
SkippedIDs: []string{"valid-login-id"},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1","valid-login-id"]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, checker.deleteCalls)
|
||||
require.Equal(t, []string{"login-id-1", "valid-login-id"}, checker.deletedIDs)
|
||||
|
||||
var body domain.DeleteOrphanUserLoginIDsResult
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, int64(1), body.DeletedCount)
|
||||
require.Equal(t, []string{"valid-login-id"}, body.SkippedIDs)
|
||||
}
|
||||
|
||||
func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1"]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Equal(t, 0, checker.deleteCalls)
|
||||
}
|
||||
|
||||
@@ -210,6 +210,14 @@ func roleFromTraits(traits map[string]interface{}) string {
|
||||
return domain.RoleUser
|
||||
}
|
||||
|
||||
func normalizeAssignableSystemRole(value string) (string, bool) {
|
||||
role, ok := domain.NormalizeRoleAlias(value)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
|
||||
}
|
||||
|
||||
func gradeFromTraits(traits map[string]interface{}) string {
|
||||
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
||||
if value == "" {
|
||||
@@ -661,9 +669,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
role := domain.NormalizeRole(req.Role)
|
||||
if role == "" {
|
||||
role = domain.RoleUser
|
||||
role := domain.RoleUser
|
||||
if strings.TrimSpace(req.Role) != "" {
|
||||
normalizedRole, ok := normalizeAssignableSystemRole(req.Role)
|
||||
if !ok {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
||||
}
|
||||
if normalizedRole == domain.RoleSuperAdmin {
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can assign super admin role")
|
||||
}
|
||||
}
|
||||
role = normalizedRole
|
||||
}
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
@@ -1532,7 +1550,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
||||
}
|
||||
role, ok := domain.NormalizeRoleAlias(*req.Role)
|
||||
role, ok := normalizeAssignableSystemRole(*req.Role)
|
||||
if !ok {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
||||
}
|
||||
@@ -1841,7 +1859,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
|
||||
}
|
||||
role, ok := domain.NormalizeRoleAlias(*req.Role)
|
||||
role, ok := normalizeAssignableSystemRole(*req.Role)
|
||||
if !ok {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
|
||||
}
|
||||
|
||||
@@ -1020,6 +1020,21 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
||||
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
|
||||
})
|
||||
|
||||
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
|
||||
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
||||
payload := map[string]interface{}{
|
||||
"userIds": []string{"u-1"},
|
||||
"role": role,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
|
||||
@@ -1141,6 +1156,33 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
h := &UserHandler{KratosAdmin: mockKratos}
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
|
||||
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
|
||||
ID: "u-1",
|
||||
Traits: map[string]interface{}{"email": "user@test.com", "role": domain.RoleUser},
|
||||
State: "active",
|
||||
}, nil).Once()
|
||||
|
||||
payload := map[string]interface{}{"role": role}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
|
||||
@@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -10,6 +12,8 @@ import (
|
||||
|
||||
type DataIntegrityChecker interface {
|
||||
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
|
||||
ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error)
|
||||
DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error)
|
||||
}
|
||||
|
||||
type dataIntegrityChecker struct {
|
||||
@@ -24,6 +28,14 @@ func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.D
|
||||
return CheckDataIntegrity(ctx, c.db)
|
||||
}
|
||||
|
||||
func (c *dataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
|
||||
return ListOrphanUserLoginIDs(ctx, c.db, nil)
|
||||
}
|
||||
|
||||
func (c *dataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||
return DeleteOrphanUserLoginIDs(ctx, c.db, ids)
|
||||
}
|
||||
|
||||
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
|
||||
tenantChecks := []domain.DataIntegrityCheck{
|
||||
{
|
||||
@@ -224,3 +236,145 @@ func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataI
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func ListOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) ([]domain.OrphanUserLoginID, error) {
|
||||
type orphanRow struct {
|
||||
ID string
|
||||
UserID string
|
||||
UserEmail string
|
||||
UserDeletedAt *time.Time
|
||||
TenantID string
|
||||
TenantSlug string
|
||||
TenantDeletedAt *time.Time
|
||||
FieldKey string
|
||||
LoginID string
|
||||
MissingUser bool
|
||||
DeletedUser bool
|
||||
MissingTenant bool
|
||||
DeletedTenant bool
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
uli.id,
|
||||
uli.user_id,
|
||||
COALESCE(u.email, '') AS user_email,
|
||||
u.deleted_at AS user_deleted_at,
|
||||
uli.tenant_id,
|
||||
COALESCE(t.slug, '') AS tenant_slug,
|
||||
t.deleted_at AS tenant_deleted_at,
|
||||
uli.field_key,
|
||||
uli.login_id,
|
||||
(u.id IS NULL) AS missing_user,
|
||||
(u.id IS NOT NULL AND u.deleted_at IS NOT NULL) AS deleted_user,
|
||||
(t.id IS NULL) AS missing_tenant,
|
||||
(t.id IS NOT NULL AND t.deleted_at IS NOT NULL) AS deleted_tenant
|
||||
FROM user_login_ids AS uli
|
||||
LEFT JOIN users AS u ON u.id = uli.user_id
|
||||
LEFT JOIN tenants AS t ON t.id = uli.tenant_id
|
||||
WHERE (
|
||||
u.id IS NULL
|
||||
OR u.deleted_at IS NOT NULL
|
||||
OR t.id IS NULL
|
||||
OR t.deleted_at IS NOT NULL
|
||||
)
|
||||
`
|
||||
args := []any{}
|
||||
if len(ids) > 0 {
|
||||
query += " AND uli.id IN ?\n"
|
||||
args = append(args, ids)
|
||||
}
|
||||
query += "ORDER BY uli.login_id, uli.id"
|
||||
|
||||
var rows []orphanRow
|
||||
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]domain.OrphanUserLoginID, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
reasons := make([]string, 0, 4)
|
||||
if row.MissingUser {
|
||||
reasons = append(reasons, "missing_user")
|
||||
}
|
||||
if row.DeletedUser {
|
||||
reasons = append(reasons, "deleted_user")
|
||||
}
|
||||
if row.MissingTenant {
|
||||
reasons = append(reasons, "missing_tenant")
|
||||
}
|
||||
if row.DeletedTenant {
|
||||
reasons = append(reasons, "deleted_tenant")
|
||||
}
|
||||
items = append(items, domain.OrphanUserLoginID{
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
UserEmail: row.UserEmail,
|
||||
UserDeletedAt: row.UserDeletedAt,
|
||||
TenantID: row.TenantID,
|
||||
TenantSlug: row.TenantSlug,
|
||||
TenantDeletedAt: row.TenantDeletedAt,
|
||||
FieldKey: row.FieldKey,
|
||||
LoginID: row.LoginID,
|
||||
Reasons: reasons,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func DeleteOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
|
||||
ids = normalizeIDList(ids)
|
||||
result := domain.DeleteOrphanUserLoginIDsResult{
|
||||
Deleted: []domain.OrphanUserLoginID{},
|
||||
SkippedIDs: []string{},
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
items, err := ListOrphanUserLoginIDs(ctx, tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletableIDs := make([]string, 0, len(items))
|
||||
deletableSet := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
deletableIDs = append(deletableIDs, item.ID)
|
||||
deletableSet[item.ID] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !deletableSet[id] {
|
||||
result.SkippedIDs = append(result.SkippedIDs, id)
|
||||
}
|
||||
}
|
||||
if len(deletableIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deleteResult := tx.Exec("DELETE FROM user_login_ids WHERE id IN ?", deletableIDs)
|
||||
if deleteResult.Error != nil {
|
||||
return deleteResult.Error
|
||||
}
|
||||
result.Deleted = items
|
||||
result.DeletedCount = deleteResult.RowsAffected
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func normalizeIDList(ids []string) []string {
|
||||
normalized := make([]string, 0, len(ids))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
normalized = append(normalized, id)
|
||||
}
|
||||
slices.Sort(normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -88,6 +88,110 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
|
||||
}
|
||||
|
||||
func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := uuid.NewString()
|
||||
|
||||
validTenant := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Valid Tenant " + suffix,
|
||||
Slug: "valid-tenant-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
deletedTenant := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Deleted Tenant " + suffix,
|
||||
Slug: "deleted-tenant-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
require.NoError(t, testDB.Create(&validTenant).Error)
|
||||
require.NoError(t, testDB.Create(&deletedTenant).Error)
|
||||
|
||||
validUser := domain.User{
|
||||
ID: uuid.NewString(),
|
||||
Email: "valid-login-" + suffix + "@example.com",
|
||||
Name: "Valid Login User",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &validTenant.ID,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
deletedUser := domain.User{
|
||||
ID: uuid.NewString(),
|
||||
Email: "deleted-login-" + suffix + "@example.com",
|
||||
Name: "Deleted Login User",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &validTenant.ID,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, testDB.Create(&validUser).Error)
|
||||
require.NoError(t, testDB.Create(&deletedUser).Error)
|
||||
|
||||
validLogin := domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: validUser.ID,
|
||||
TenantID: validTenant.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "VALID-" + suffix,
|
||||
}
|
||||
deletedTenantLogin := domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: validUser.ID,
|
||||
TenantID: deletedTenant.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "DELETED-TENANT-" + suffix,
|
||||
}
|
||||
deletedUserLogin := domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: deletedUser.ID,
|
||||
TenantID: validTenant.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "DELETED-USER-" + suffix,
|
||||
}
|
||||
require.NoError(t, testDB.Create(&validLogin).Error)
|
||||
require.NoError(t, testDB.Create(&deletedTenantLogin).Error)
|
||||
require.NoError(t, testDB.Create(&deletedUserLogin).Error)
|
||||
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
|
||||
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", deletedUser.ID).Error)
|
||||
|
||||
items, err := ListOrphanUserLoginIDs(ctx, testDB, nil)
|
||||
require.NoError(t, err)
|
||||
orphanReasons := map[string][]string{}
|
||||
for _, item := range items {
|
||||
orphanReasons[item.ID] = item.Reasons
|
||||
}
|
||||
require.Equal(t, []string{"deleted_tenant"}, orphanReasons[deletedTenantLogin.ID])
|
||||
require.Equal(t, []string{"deleted_user"}, orphanReasons[deletedUserLogin.ID])
|
||||
require.NotContains(t, orphanReasons, validLogin.ID)
|
||||
|
||||
result, err := DeleteOrphanUserLoginIDs(ctx, testDB, []string{
|
||||
deletedTenantLogin.ID,
|
||||
validLogin.ID,
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.DeletedCount)
|
||||
require.Len(t, result.Deleted, 1)
|
||||
require.Equal(t, deletedTenantLogin.ID, result.Deleted[0].ID)
|
||||
require.ElementsMatch(t, []string{
|
||||
validLogin.ID,
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
}, result.SkippedIDs)
|
||||
|
||||
var deletedTenantLoginCount int64
|
||||
require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", deletedTenantLogin.ID).Count(&deletedTenantLoginCount).Error)
|
||||
require.Equal(t, int64(0), deletedTenantLoginCount)
|
||||
|
||||
var validLoginCount int64
|
||||
require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", validLogin.ID).Count(&validLoginCount).Error)
|
||||
require.Equal(t, int64(1), validLoginCount)
|
||||
}
|
||||
|
||||
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
|
||||
t.Helper()
|
||||
for _, section := range report.Sections {
|
||||
|
||||
Reference in New Issue
Block a user