diff --git a/Makefile b/Makefile index 2f11547d..0c688341 100644 --- a/Makefile +++ b/Makefile @@ -181,13 +181,8 @@ PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTAL PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE -ifeq ($(CI),) PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi' PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi' -else -PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps -PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium -endif .PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests @@ -254,12 +249,12 @@ code-check-userfront-lint: code-check-front-lint: @echo "==> adminfront biome lint/format check" rm -rf adminfront/playwright-report adminfront/test-results - cd adminfront && npm ci + cd adminfront && npm ci --ignore-scripts cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false @echo "==> devfront biome lint/format check" rm -rf devfront/playwright-report devfront/test-results - cd devfront && npm ci + cd devfront && npm ci --ignore-scripts cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false @@ -298,7 +293,7 @@ code-check-devfront-tests: @mkdir -p reports/devfront @rm -rf reports/devfront/playwright-report reports/devfront/test-results @status=0; \ - (cd devfront && npm ci) || status=$$?; \ + (cd devfront && npm ci --ignore-scripts) || status=$$?; \ if [ $$status -eq 0 ]; then \ (cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ fi; \ diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 8d2958d2..2d701319 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -1,8 +1,10 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -13,6 +15,121 @@ import ( "github.com/stretchr/testify/mock" ) +// --- Mocks --- + +type MockKratosAdminServiceForConsent struct { + mock.Mock +} + +func (m *MockKratosAdminServiceForConsent) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { + args := m.Called(ctx, identifier) + return args.String(0), args.Error(1) +} + +func (m *MockKratosAdminServiceForConsent) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosIdentity), args.Error(1) +} + +func (m *MockKratosAdminServiceForConsent) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) { + return nil, nil +} + +func (m *MockKratosAdminServiceForConsent) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { + return nil, nil +} + +func (m *MockKratosAdminServiceForConsent) CreateIdentity(ctx context.Context, traits map[string]interface{}) (*service.KratosIdentity, error) { + return nil, nil +} + +func (m *MockKratosAdminServiceForConsent) DeleteIdentity(ctx context.Context, identityID string) error { + return nil +} + +func (m *MockKratosAdminServiceForConsent) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { + return nil +} + +func (m *MockKratosAdminServiceForConsent) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + return nil, nil +} + +func (m *MockKratosAdminServiceForConsent) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + return nil, nil +} + +func (m *MockKratosAdminServiceForConsent) DeleteSession(ctx context.Context, sessionID string) error { + return nil +} + +func (m *MockKratosAdminServiceForConsent) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + return "", nil +} + +type MockTenantServiceForConsent struct { + mock.Mock +} + +func (m *MockTenantServiceForConsent) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantServiceForConsent) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + +func (m *MockTenantServiceForConsent) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForConsent) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForConsent) ApproveTenant(ctx context.Context, id string) error { + return nil +} + +func (m *MockTenantServiceForConsent) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Tenant), args.Error(1) +} + +func (m *MockTenantServiceForConsent) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Tenant), args.Error(1) +} + +func (m *MockTenantServiceForConsent) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { + return false, nil +} + +func (m *MockTenantServiceForConsent) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForConsent) SetKetoService(keto service.KetoService) {} + +func (m *MockTenantServiceForConsent) DeleteTenantsBulk(ctx context.Context, ids []string) error { + return nil +} + // --- Test Helpers --- func newConsentTestApp(h *AuthHandler) *fiber.App { @@ -100,11 +217,36 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) { http.DefaultClient = client defer func() { http.DefaultClient = origDefault }() + mockTenantSvc := &MockTenantServiceForConsent{} + mockKratosAdmin := &MockKratosAdminServiceForConsent{} + + // Mock profile resolution to allow tenant access + mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ + ID: "user-123", + Traits: map[string]interface{}{ + "email": "user@example.com", + }, + }, nil) + + mockTenantSvc.On("GetTenant", mock.Anything, "tenant-allow").Return(&domain.Tenant{ + ID: "tenant-allow", + Slug: "tenant-allow", + Name: "Allowed Tenant", + }, nil) + + // Mock hydration calls + mockTenantSvc.On("ListJoinedTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{ + {ID: "tenant-allow", Slug: "tenant-allow", Name: "Allowed Tenant"}, + }, nil) + mockTenantSvc.On("ListManageableTenants", mock.Anything, mock.Anything).Return([]domain.Tenant{}, nil) + h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, + TenantService: mockTenantSvc, + KratosAdmin: mockKratosAdmin, } app := newConsentTestApp(h) @@ -163,16 +305,17 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { defer func() { http.DefaultClient = origDefault }() consentRepo := &mockConsentRepo{} + mockKratosAdmin := &MockKratosAdminServiceForConsent{} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, - KratosAdmin: new(MockKratosAdminService), // Reusing MockKratosAdminService if defined or use MockKratosAdminServiceShared + KratosAdmin: mockKratosAdmin, ConsentRepo: consentRepo, } - h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ + mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", Traits: map[string]interface{}{ "email": "user@test.com", @@ -199,7 +342,8 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { "requested_scope": []string{"openid", "profile"}, "subject": "user-123", "client": map[string]interface{}{ - "client_id": "client-app", + "client_id": "client-app", + "client_name": "Test App", }, }), nil } @@ -226,17 +370,18 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { auditRepo := &mockAuditRepo{} consentRepo := &mockConsentRepo{} + mockKratosAdmin := &MockKratosAdminServiceForConsent{} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, - KratosAdmin: new(MockKratosAdminService), + KratosAdmin: mockKratosAdmin, AuditRepo: auditRepo, ConsentRepo: consentRepo, } - h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ + mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", Traits: map[string]interface{}{ "email": "user@test.com", @@ -260,6 +405,8 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { } func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { + t.Setenv("APP_ENV", "dev") + var capturedGrantScopes []string transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -309,14 +456,16 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { http.DefaultClient = client defer func() { http.DefaultClient = origDefault }() + mockKratosAdmin := &MockKratosAdminServiceForConsent{} + h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, - KratosAdmin: new(MockKratosAdminService), + KratosAdmin: mockKratosAdmin, } - h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ + mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", Traits: map[string]interface{}{ "email": "user@test.com", diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index a27af74e..914f726b 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -184,6 +184,7 @@ func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) { app := fiber.New() app.Get("/api/v1/auth/consent", h.GetConsentRequest) + t.Setenv("APP_ENV", "dev") req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-tenant", nil) req.Header.Set("X-Mock-Role", "user") req.Header.Set("X-Tenant-ID", "tenant-a") diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go index b4c59782..1b75b958 100644 --- a/backend/internal/idp/factory.go +++ b/backend/internal/idp/factory.go @@ -13,47 +13,47 @@ import ( // Ory 계열(kratos/hydra) 공급자 문자열을 정규화하기 위한 매핑. var providerAliases = map[string]string{ - "ory": "ory", - "hydra": "ory", - "kratos": "ory", - "ory-kratos": "ory", - "ory_hydra": "ory", - "ory_kratos": "ory", + "ory": "ory", + "hydra": "ory", + "kratos": "ory", + "ory-kratos": "ory", + "ory_hydra": "ory", + "ory_kratos": "ory", } // getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다. func getEnv(key, fallback string) string { - if value, ok := os.LookupEnv(key); ok { - return value - } - return fallback + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback } // InitializeProvider는 환경 설정을 기반으로 IDP 공급자를 생성하고 반환합니다. // 이것은 IdentityProvider 인터페이스의 팩토리 역할을 합니다. func InitializeProvider() (domain.IdentityProvider, error) { - rawProviders := getEnv("IDP_PROVIDER", "ory") - providers := strings.Split(rawProviders, ",") - slog.Info("Initializing IDP chain", "providers", rawProviders) + rawProviders := getEnv("IDP_PROVIDER", "ory") + providers := strings.Split(rawProviders, ",") + slog.Info("Initializing IDP chain", "providers", rawProviders) - var initialized []domain.IdentityProvider - for _, p := range providers { - providerName := strings.TrimSpace(strings.ToLower(p)) - if canonical, ok := providerAliases[providerName]; ok { - providerName = canonical - } + var initialized []domain.IdentityProvider + for _, p := range providers { + providerName := strings.TrimSpace(strings.ToLower(p)) + if canonical, ok := providerAliases[providerName]; ok { + providerName = canonical + } - switch providerName { - case "ory": - // Kratos/Hydra 주 공급자 - oryProvider := service.NewOryProvider() - initialized = append(initialized, oryProvider) + switch providerName { + case "ory": + // Kratos/Hydra 주 공급자 + oryProvider := service.NewOryProvider() + initialized = append(initialized, oryProvider) - default: - // 알 수 없는 공급자는 건너뛰고 다음 후보를 시도 - slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName) - } - } + default: + // 알 수 없는 공급자는 건너뛰고 다음 후보를 시도 + slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName) + } + } if len(initialized) == 0 { return nil, fmt.Errorf("no valid IDP_PROVIDER entries configured from: %s", rawProviders) } diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index 1ae356d4..fbfbf02a 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -63,7 +63,7 @@ func TestMain(m *testing.M) { } // Auto-migrate - err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}) + err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}) if err != nil { log.Fatalf("failed to migrate database: %s", err) } diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index a2b384ee..2d625e5c 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -174,10 +174,7 @@ function ClientGeneralPage() { { id: "2", name: "tenant", - description: t( - "msg.dev.clients.scopes.tenant", - "소속 테넌트 정보 접근", - ), + description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"), mandatory: false, }, { @@ -347,12 +344,15 @@ function ClientGeneralPage() { return { ...scope, description: scope.description || tenantScopeDescription, - mandatory: restricted ? true : false, + mandatory: restricted, locked: restricted, }; }); - if (restricted && !normalized.some((scope) => scope.name.trim() === "tenant")) { + if ( + restricted && + !normalized.some((scope) => scope.name.trim() === "tenant") + ) { normalized.push(buildTenantScope(`tenant-${Date.now()}`)); } @@ -526,7 +526,8 @@ function ClientGeneralPage() { const hasValidationErrors = validationErrors.length > 0; const normalizedTenantSearch = tenantSearch.trim().toLowerCase(); - const tenantOptions: Array = tenantData ?? []; + const tenantOptions: Array = + tenantData ?? []; const filteredTenants = tenantOptions.filter((tenant) => { if (!normalizedTenantSearch) { return true; @@ -1375,10 +1376,7 @@ function ClientGeneralPage() {