forked from baron/baron-sso
권한부여 및 정합성 검사 추가
This commit is contained in:
226
backend/internal/repository/data_integrity_repository.go
Normal file
226
backend/internal/repository/data_integrity_repository.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DataIntegrityChecker interface {
|
||||
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
|
||||
}
|
||||
|
||||
type dataIntegrityChecker struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDataIntegrityChecker(db *gorm.DB) DataIntegrityChecker {
|
||||
return &dataIntegrityChecker{db: db}
|
||||
}
|
||||
|
||||
func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
||||
return CheckDataIntegrity(ctx, c.db)
|
||||
}
|
||||
|
||||
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
|
||||
tenantChecks := []domain.DataIntegrityCheck{
|
||||
{
|
||||
Key: "duplicate_tenant_slugs",
|
||||
Label: "중복 테넌트 slug",
|
||||
Description: "삭제되지 않은 tenant의 slug를 대소문자 무시 기준으로 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
{
|
||||
Key: "orphan_tenant_parents",
|
||||
Label: "유령 상위 테넌트 참조",
|
||||
Description: "tenant.parent_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
}
|
||||
userChecks := []domain.DataIntegrityCheck{
|
||||
{
|
||||
Key: "orphan_user_tenant_memberships",
|
||||
Label: "유령 테넌트 사용자 소속",
|
||||
Description: "users.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
{
|
||||
Key: "orphan_user_login_id_tenants",
|
||||
Label: "유령 테넌트 로그인 ID",
|
||||
Description: "user_login_ids.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
{
|
||||
Key: "orphan_user_login_id_users",
|
||||
Label: "유령 사용자 로그인 ID",
|
||||
Description: "user_login_ids.user_id가 없거나 삭제된 user를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
}
|
||||
|
||||
counts := []struct {
|
||||
target *int64
|
||||
query string
|
||||
}{
|
||||
{
|
||||
target: &tenantChecks[0].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT LOWER(TRIM(slug)) AS normalized_slug
|
||||
FROM tenants
|
||||
WHERE deleted_at IS NULL
|
||||
AND status <> 'deleted'
|
||||
AND TRIM(slug) <> ''
|
||||
GROUP BY LOWER(TRIM(slug))
|
||||
HAVING COUNT(*) > 1
|
||||
) AS duplicate_slugs
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &tenantChecks[1].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM tenants AS child
|
||||
WHERE child.deleted_at IS NULL
|
||||
AND child.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS parent
|
||||
WHERE parent.id = child.parent_id
|
||||
AND parent.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &userChecks[0].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = u.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &userChecks[1].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM user_login_ids AS uli
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = uli.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &userChecks[2].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM user_login_ids AS uli
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users AS u
|
||||
WHERE u.id = uli.user_id
|
||||
AND u.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range counts {
|
||||
if err := db.WithContext(ctx).Raw(item.query).Scan(item.target).Error; err != nil {
|
||||
return domain.DataIntegrityReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tenantChecks = applyIntegrityStatuses(tenantChecks)
|
||||
userChecks = applyIntegrityStatuses(userChecks)
|
||||
sections := []domain.DataIntegritySection{
|
||||
{
|
||||
Key: "tenant_integrity",
|
||||
Label: "테넌트 정합성",
|
||||
Status: summarizeIntegrityStatus(tenantChecks),
|
||||
Checks: tenantChecks,
|
||||
},
|
||||
{
|
||||
Key: "user_integrity",
|
||||
Label: "사용자 정합성",
|
||||
Status: summarizeIntegrityStatus(userChecks),
|
||||
Checks: userChecks,
|
||||
},
|
||||
}
|
||||
|
||||
summary := domain.DataIntegritySummary{}
|
||||
for _, section := range sections {
|
||||
for _, check := range section.Checks {
|
||||
summary.TotalChecks++
|
||||
switch check.Status {
|
||||
case domain.DataIntegrityStatusFail:
|
||||
summary.Failures += check.Count
|
||||
case domain.DataIntegrityStatusWarning:
|
||||
summary.Warnings++
|
||||
default:
|
||||
summary.Passed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain.DataIntegrityReport{
|
||||
Status: summarizeSectionStatus(sections),
|
||||
CheckedAt: time.Now().UTC(),
|
||||
Summary: summary,
|
||||
Sections: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyIntegrityStatuses(checks []domain.DataIntegrityCheck) []domain.DataIntegrityCheck {
|
||||
for i := range checks {
|
||||
if checks[i].Count > 0 {
|
||||
checks[i].Status = domain.DataIntegrityStatusFail
|
||||
} else {
|
||||
checks[i].Status = domain.DataIntegrityStatusPass
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
func summarizeIntegrityStatus(checks []domain.DataIntegrityCheck) domain.DataIntegrityStatus {
|
||||
status := domain.DataIntegrityStatusPass
|
||||
for _, check := range checks {
|
||||
if check.Status == domain.DataIntegrityStatusFail {
|
||||
return domain.DataIntegrityStatusFail
|
||||
}
|
||||
if check.Status == domain.DataIntegrityStatusWarning {
|
||||
status = domain.DataIntegrityStatusWarning
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataIntegrityStatus {
|
||||
status := domain.DataIntegrityStatusPass
|
||||
for _, section := range sections {
|
||||
if section.Status == domain.DataIntegrityStatusFail {
|
||||
return domain.DataIntegrityStatusFail
|
||||
}
|
||||
if section.Status == domain.DataIntegrityStatusWarning {
|
||||
status = domain.DataIntegrityStatusWarning
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
106
backend/internal/repository/data_integrity_repository_test.go
Normal file
106
backend/internal/repository/data_integrity_repository_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := uuid.NewString()
|
||||
|
||||
parent := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Deleted Parent " + suffix,
|
||||
Slug: "deleted-parent-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Orphan Child " + suffix,
|
||||
Slug: "orphan-child-" + suffix,
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &parent.ID,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
dupA := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Duplicate A " + suffix,
|
||||
Slug: "Dup-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
dupB := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Duplicate B " + suffix,
|
||||
Slug: "dup-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
|
||||
require.NoError(t, testDB.Create(&parent).Error)
|
||||
require.NoError(t, testDB.Create(&child).Error)
|
||||
require.NoError(t, testDB.Create(&dupA).Error)
|
||||
require.NoError(t, testDB.Create(&dupB).Error)
|
||||
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", parent.ID).Error)
|
||||
|
||||
orphanUser := domain.User{
|
||||
ID: uuid.NewString(),
|
||||
Email: "orphan-" + suffix + "@example.com",
|
||||
Name: "Orphan User",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &parent.ID,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, testDB.Create(&orphanUser).Error)
|
||||
require.NoError(t, testDB.Create(&domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: orphanUser.ID,
|
||||
TenantID: parent.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "EMP-" + suffix,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: uuid.NewString(),
|
||||
TenantID: child.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "MISSING-" + suffix,
|
||||
}).Error)
|
||||
|
||||
report, err := CheckDataIntegrity(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.DataIntegrityStatusFail, report.Status)
|
||||
require.Equal(t, int64(5), report.Summary.Failures)
|
||||
|
||||
requireIntegrityCheck(t, report, "tenant_integrity", "duplicate_tenant_slugs", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "tenant_integrity", "orphan_tenant_parents", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_tenant_memberships", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_tenants", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
|
||||
}
|
||||
|
||||
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
|
||||
t.Helper()
|
||||
for _, section := range report.Sections {
|
||||
if section.Key != sectionKey {
|
||||
continue
|
||||
}
|
||||
for _, check := range section.Checks {
|
||||
if check.Key == checkKey {
|
||||
require.Equal(t, status, check.Status)
|
||||
require.Equal(t, count, check.Count)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("integrity check %s/%s not found", sectionKey, checkKey)
|
||||
}
|
||||
Reference in New Issue
Block a user