diff --git a/AGENTS.md b/AGENTS.md index b70277e3..43d3de89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -750,6 +750,7 @@ BaseApiController (최상위) 필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가) 필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가) 필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지) +필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지 필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용 필수: 모든 확장 작업은 Artisan 커맨드로 수행 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a05713..060f0f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다. +- 확장 기능이 특정 요청 경로에만 자기 처리 로직을 끼워 넣을 수 있는 표준 연결 방식을 마련했습니다. 각 확장은 자기가 개입할 대상 경로를 명시해 선언하고, 코어가 요청이 들어올 때 대상에 해당하는 확장 로직만 골라 실행합니다. 이를 통해 쿠키 동의 처리나 결제 입금통보의 발신 서버 확인 같은 기능이 필요한 경로에서만 정확히 동작하도록 정리했습니다. ## [7.0.4] - 2026-07-14 diff --git a/app/Contracts/Extension/ExtensionMiddlewareRegistryInterface.php b/app/Contracts/Extension/ExtensionMiddlewareRegistryInterface.php new file mode 100644 index 00000000..44b094e7 --- /dev/null +++ b/app/Contracts/Extension/ExtensionMiddlewareRegistryInterface.php @@ -0,0 +1,34 @@ + 매칭된 확장 미들웨어 FQCN (선언 순서 = 확장 로드 순서) + */ + public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array; +} diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index c7a3f532..c0347f53 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -551,6 +551,37 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte return []; } + /** + * 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트 + * (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` + * 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명 + * 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도 + * 됩니다 (순수하게 유지). + * + * 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다. + * + * @return array, timing?: string, targets: array}> + * [ + * [ + * 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증) + * 'groups' => ['api'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api'] + * 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저) + * 'targets' => ['self'], // 라우트명/URI 패턴 배열. 'self' = 자기 확장 prefix 자동 치환 + * ], + * ] + * + * targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) | + * 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' | + * 원시 라우트명 glob·brace(`api.modules.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용). + * targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언". + */ + public function getMiddleware(): array + { + return []; + } + /** * Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종. * @@ -1022,11 +1053,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte * * @param string $pageType 레이아웃 meta.seo.page_type * @return array}> - * 키별 데이터 경로 메타 — 예: - * [ - * 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'], - * 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], - * ] + * 키별 데이터 경로 메타 — 예: + * [ + * 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'], + * 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], + * ] */ public function seoOgDefaultMeta(string $pageType): array { @@ -1038,7 +1069,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte * * @param string $pageType 페이지 타입 * @return array}> 키별 데이터 경로 메타 - * (label = 번역 키 권장 — seoOgDefaultMeta 참조) + * (label = 번역 키 권장 — seoOgDefaultMeta 참조) */ public function seoTwitterDefaultMeta(string $pageType): array { @@ -1054,11 +1085,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte * * @param string $pageType 페이지 타입 * @return array}> - * 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예: - * [ - * 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], - * 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'], - * ] + * 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예: + * [ + * 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], + * 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'], + * ] */ public function seoStructuredDataMeta(string $pageType): array { diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 1e76a1b8..3e811cd6 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -508,6 +508,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte return []; } + /** + * 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * 플러그인이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트 + * (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` + * 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명 + * 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도 + * 됩니다 (순수하게 유지). + * + * 기본적으로 빈 배열 반환. 미들웨어가 필요한 플러그인만 오버라이드합니다. + * + * @return array, timing?: string, targets: array}> + * [ + * [ + * 'class' => VbankNotifyIpWhitelist::class, // 미들웨어 FQCN (class_exists 검증) + * 'groups' => ['web'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api'] + * 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저) + * 'targets' => ['web.plugins.vendor-plugin.payment.vbank-notify'], // 라우트명/URI 패턴 배열 + * ], + * ] + * + * targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) | + * 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' | + * 원시 라우트명 glob·brace(`web.plugins.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용). + * targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언". + */ + public function getMiddleware(): array + { + return []; + } + /** * 이 플러그인이 등록할 IDV(본인인증) 정책 선언을 반환합니다. * diff --git a/app/Extension/ExtensionMiddlewareRegistry.php b/app/Extension/ExtensionMiddlewareRegistry.php new file mode 100644 index 00000000..0af15b1c --- /dev/null +++ b/app/Extension/ExtensionMiddlewareRegistry.php @@ -0,0 +1,376 @@ + 미들웨어] 해시맵이 아니라 [group.timing => list<{predicates, class}>] + * 구조로 두고 요청 라우트명·URI 를 술어로 순회 매칭합니다. 확장 미들웨어 총량이 소수(수십 + * 이하)라 O(n) 순회 비용은 무시 가능. 결과는 (routeName|path)+group+timing 단위로 2차 캐시. + * + * 술어는 캐시를 위해 클로저 대신 `{op, arg}` 태그 배열로 보관하고 조회 시 평가합니다 + * (클로저는 직렬화 불가). op ∈ {name, name_ext, name_core, uri, always}. + * + * @since 7.0.5 + */ +class ExtensionMiddlewareRegistry implements ExtensionMiddlewareRegistryInterface +{ + /** + * 미들웨어 인덱스 캐시 키 (IDV `identity_policies.route_scope_index` 네이밍 컨벤션 미러). + * 물리 키는 CoreCacheDriver 접두사로 `g7:core:extension.middleware_index`. + */ + public const CACHE_KEY = 'extension.middleware_index'; + + /** + * 캐시 무효화 태그 (단수 slug). Manager 상태 변경 지점에서 flush(). + */ + public const CACHE_TAG = 'extension_middleware'; + + /** + * 허용 그룹. + */ + private const ALLOWED_GROUPS = ['web', 'api']; + + /** + * 확장 라우트 판별 glob (양 그룹). 라우트명이 이 중 하나에 매칭되면 확장 라우트. + */ + private const EXT_GLOB = ['*.modules.*', '*.plugins.*']; + + /** + * 요청 단위 2차 캐시 (동일 요청 내 게이트가 4회 조회해도 인덱스 1회 빌드). + * + * @var array> + */ + protected array $resolveCache = []; + + /** + * @param CacheInterface $cache 코어 캐시 드라이버 (CoreCacheDriver 자동 바인딩) + */ + public function __construct( + protected CacheInterface $cache, + ) {} + + /** + * {@inheritDoc} + */ + public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array + { + $memoKey = $routeName."\0".$path."\0".$group."\0".$timing; + if (isset($this->resolveCache[$memoKey])) { + return $this->resolveCache[$memoKey]; + } + + $matched = []; + foreach ($this->buildIndex() as $entry) { + if ($entry['group'] !== $group || $entry['timing'] !== $timing) { + continue; + } + foreach ($entry['predicates'] as $predicate) { + if ($this->predicateMatches($predicate, $routeName, $path)) { + $matched[] = $entry; + break; + } + } + } + + usort($matched, static fn (array $a, array $b): int => $a['order'] <=> $b['order']); + + $classes = array_values(array_map(static fn (array $e): string => $e['class'], $matched)); + + return $this->resolveCache[$memoKey] = $classes; + } + + /** + * 활성 확장의 미들웨어 선언을 그룹별 엔트리로 펼쳐 캐시된 인덱스를 반환합니다. + * + * @return list, class: class-string, order: int}> + */ + protected function buildIndex(): array + { + $ttl = (int) g7_core_settings('cache.extension_middleware_ttl', 3600); + + return $this->cache->remember( + self::CACHE_KEY, + fn (): array => $this->collectEntries(), + $ttl, + [self::CACHE_TAG], + ); + } + + /** + * 활성 모듈·플러그인의 getMiddleware() 를 검증·정규화해 엔트리 배열로 수집합니다. + * + * @return list, class: class-string, order: int}> + */ + protected function collectEntries(): array + { + $entries = []; + $order = 0; + + foreach ($this->activeExtensions() as [$extension, $identifier, $kind]) { + if (! method_exists($extension, 'getMiddleware')) { + continue; + } + + $declarations = $extension->getMiddleware(); + if (! is_array($declarations)) { + continue; + } + + foreach ($declarations as $declaration) { + $normalized = $this->normalizeDeclaration($declaration, $identifier, $kind); + if ($normalized === null) { + continue; + } + + [$class, $groups, $timing, $targets] = $normalized; + + foreach ($groups as $group) { + $predicates = []; + foreach ($targets as $target) { + foreach ($this->normalizeTarget($target, $identifier, $kind, $group) as $predicate) { + $predicates[] = $predicate; + } + } + + $entries[] = [ + 'timing' => $timing, + 'group' => $group, + 'predicates' => $predicates, + 'class' => $class, + 'order' => $order, + ]; + } + + $order++; + } + } + + return $entries; + } + + /** + * 단일 미들웨어 선언을 검증·정규화합니다. 무효 시 warning 후 null. + * + * @param mixed $declaration getMiddleware() 항목 + * @param string $identifier 확장 식별자 + * @param string $kind 'module' | 'plugin' + * @return array{0: class-string, 1: list, 2: string, 3: list}|null + */ + protected function normalizeDeclaration(mixed $declaration, string $identifier, string $kind): ?array + { + if (! is_array($declaration)) { + return null; + } + + $class = $declaration['class'] ?? null; + if (! is_string($class) || $class === '' || ! class_exists($class)) { + Log::warning('확장 미들웨어 선언의 class 가 유효하지 않아 등록을 건너뜁니다.', [ + 'extension' => $identifier, + 'kind' => $kind, + 'class' => $class, + ]); + + return null; + } + + $groups = $declaration['groups'] ?? []; + $groups = is_array($groups) + ? array_values(array_filter($groups, static fn ($g): bool => in_array($g, self::ALLOWED_GROUPS, true))) + : []; + if ($groups === []) { + Log::warning('확장 미들웨어 선언의 groups 가 비어있거나 허용되지 않은 값이라 등록을 건너뜁니다.', [ + 'extension' => $identifier, + 'kind' => $kind, + 'class' => $class, + 'groups' => $declaration['groups'] ?? null, + ]); + + return null; + } + + $targets = $declaration['targets'] ?? []; + $targets = is_array($targets) + ? array_values(array_filter($targets, static fn ($t): bool => is_string($t) && $t !== '')) + : []; + if ($targets === []) { + Log::warning('확장 미들웨어 선언의 targets 가 비어있어 등록을 건너뜁니다 (대상 명시 필수).', [ + 'extension' => $identifier, + 'kind' => $kind, + 'class' => $class, + ]); + + return null; + } + + $timing = $declaration['timing'] ?? 'after_core'; + if (! in_array($timing, ['before_core', 'after_core'], true)) { + $timing = 'after_core'; + } + + return [$class, $groups, $timing, $targets]; + } + + /** + * target 값을 술어(들)로 정규화합니다 (§1 카탈로그 → 매칭식). + * + * brace 는 여러 name 술어로 전개되므로 list 반환. + * + * @param string $target targets 항목 + * @param string $identifier 확장 식별자 ('self' 치환용) + * @param string $kind 'module' | 'plugin' + * @param string $group 이 엔트리가 펼쳐진 단일 그룹 ('self' 치환용) + * @return list + */ + protected function normalizeTarget(string $target, string $identifier, string $kind, string $group): array + { + // URI 패턴 (무명 라우트 대응) — '/' 로 시작. arg 는 선행 슬래시를 포함한 원형을 유지하고 + // (매칭 시 path 도 선행 슬래시로 정규화), 이렇게 해야 루트('/')·글롭('/admin/*') 모두 + // $request->is() 와 동일하게 동작한다 ($request->path() 는 루트에서 '/' 를 반환). + if (str_starts_with($target, '/')) { + return [['op' => 'uri', 'arg' => $target]]; + } + + return match (strtolower($target)) { + 'self' => [['op' => 'name', 'arg' => "{$group}.{$kind}s.{$identifier}.*"]], + 'all_extensions' => [['op' => 'name_ext']], + 'core' => [['op' => 'name_core']], + 'everything', '*' => [['op' => 'always']], + default => $this->normalizeSpecificTarget($target), + }; + } + + /** + * module:{id} / plugin:{id} / brace / 원시 glob 을 name 술어로 변환합니다. + * + * @param string $target targets 항목 + * @return list + */ + protected function normalizeSpecificTarget(string $target): array + { + if (str_starts_with($target, 'module:')) { + $id = substr($target, strlen('module:')); + + return [['op' => 'name', 'arg' => "*.modules.{$id}.*"]]; + } + + if (str_starts_with($target, 'plugin:')) { + $id = substr($target, strlen('plugin:')); + + return [['op' => 'name', 'arg' => "*.plugins.{$id}.*"]]; + } + + // Str::is 는 brace 미지원 → 미리 전개해 개별 name 술어로 저장. + return array_map( + static fn (string $glob): array => ['op' => 'name', 'arg' => $glob], + $this->expandTargetBraces($target), + ); + } + + /** + * 술어가 현재 요청 라우트명·path 에 매칭되는지 평가합니다. + * + * op=uri → path 매칭; op=always → 무조건 true (무명 라우트 포함); + * name 계열 → 무명 라우트(routeName='')는 항상 miss. + * + * @param array{op: string, arg?: string} $predicate 술어 + * @param string $routeName 요청 라우트명 (무명이면 빈 문자열) + * @param string $path 요청 URI path (선행 슬래시 없음) + * @return bool 매칭 여부 + */ + protected function predicateMatches(array $predicate, string $routeName, string $path): bool + { + $op = $predicate['op']; + + if ($op === 'always') { + return true; + } + + if ($op === 'uri') { + // path 를 선행 슬래시로 정규화해 arg('/...') 와 정렬 ($request->is() 정합). + return Str::is($predicate['arg'] ?? '', '/'.ltrim($path, '/')); + } + + // name 계열: 무명 라우트는 URI/always 로만 잡히므로 여기서 miss. + if ($routeName === '') { + return false; + } + + return match ($op) { + 'name' => Str::is($predicate['arg'] ?? '', $routeName), + 'name_ext' => $this->isExtensionRoute($routeName), + 'name_core' => ! $this->isExtensionRoute($routeName), + default => false, + }; + } + + /** + * 라우트명이 확장 라우트(*.modules.* / *.plugins.*)인지 판별합니다. + * + * @param string $routeName 요청 라우트명 (비어있지 않음) + * @return bool 확장 라우트 여부 + */ + protected function isExtensionRoute(string $routeName): bool + { + return Str::is(self::EXT_GLOB, $routeName); + } + + /** + * brace expansion — 'a.{b,c}.d' → ['a.b.d', 'a.c.d']. 단일 그룹만 지원. + * IDV `IdentityPolicyRepository::expandTargetBraces()` 재사용. + * + * @param string $target targets 항목 + * @return list + */ + protected function expandTargetBraces(string $target): array + { + if (! preg_match('/\{([^{}]+)\}/', $target, $matches)) { + return [$target]; + } + $options = array_map('trim', explode(',', $matches[1])); + + return array_map( + static fn (string $opt): string => preg_replace('/\{[^{}]+\}/', $opt, $target, 1), + $options, + ); + } + + /** + * 활성 모듈·플러그인 인스턴스를 [instance, identifier, kind] 로 순회합니다. + * + * @return iterable + */ + protected function activeExtensions(): iterable + { + foreach (app(ModuleManager::class)->getActiveModules() as $module) { + yield [$module, $module->getIdentifier(), 'module']; + } + + foreach (app(PluginManager::class)->getActivePlugins() as $plugin) { + yield [$plugin, $plugin->getIdentifier(), 'plugin']; + } + } + + /** + * 인덱스 캐시를 무효화합니다 (확장 활성/비활성/설치/제거/리로드 시). + * + * IDV `IdentityPolicy::flushRouteScopeCache()` 미러 정적 메서드. + */ + public static function flush(): void + { + $cache = app(CacheInterface::class); + if ($cache->supportsTags()) { + $cache->flushTags([self::CACHE_TAG]); + + return; + } + $cache->forget(self::CACHE_KEY); + } +} diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index c91ae44b..5defac44 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -516,6 +516,9 @@ class ModuleManager implements ModuleManagerInterface // 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록) $this->incrementExtensionCacheVersion(); + // 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영. + ExtensionMiddlewareRegistry::flush(); + // 훅 발행: 모듈 설치 완료 HookManager::doAction('core.modules.installed', $moduleName); @@ -648,6 +651,10 @@ class ModuleManager implements ModuleManagerInterface // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이 // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). IdentityPolicy::flushRouteScopeCache(); + + // 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 모듈이 선언한 미들웨어가 + // 게이트 매칭 후보에 다시 포함되도록 한다. + ExtensionMiddlewareRegistry::flush(); } // 훅 발행: 모듈 활성화 완료 @@ -791,6 +798,9 @@ class ModuleManager implements ModuleManagerInterface // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. IdentityPolicy::flushRouteScopeCache(); + // 확장 미들웨어 인덱스 무효화 — 비활성 모듈의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리 HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier()); } @@ -963,6 +973,9 @@ class ModuleManager implements ModuleManagerInterface // 모듈 상태 캐시 무효화 self::invalidateModuleStatusCache(); + // 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능) $onProgress?->__invoke('files', '파일 삭제 중...'); ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier()); @@ -3763,6 +3776,9 @@ class ModuleManager implements ModuleManagerInterface // pending/bundled 목록에서 제거 unset($this->pendingModules[$moduleName]); unset($this->bundledModules[$moduleName]); + + // 확장 미들웨어 인덱스 무효화 — 재로드된 모듈의 갱신된 미들웨어 선언 반영. + ExtensionMiddlewareRegistry::flush(); } } diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 3fbd6a32..197ee9b1 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -494,6 +494,9 @@ class PluginManager implements PluginManagerInterface // 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록) $this->incrementExtensionCacheVersion(); + // 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영. + ExtensionMiddlewareRegistry::flush(); + // 훅 발행: 플러그인 설치 완료 HookManager::doAction('core.plugins.installed', $pluginName); @@ -629,6 +632,10 @@ class PluginManager implements PluginManagerInterface // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이 // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). IdentityPolicy::flushRouteScopeCache(); + + // 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 플러그인이 선언한 미들웨어가 + // 게이트 매칭 후보에 다시 포함되도록 한다. + ExtensionMiddlewareRegistry::flush(); } // 훅 발행: 플러그인 활성화 완료 @@ -775,6 +782,9 @@ class PluginManager implements PluginManagerInterface // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. IdentityPolicy::flushRouteScopeCache(); + // 확장 미들웨어 인덱스 무효화 — 비활성 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리 HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier()); } @@ -991,6 +1001,9 @@ class PluginManager implements PluginManagerInterface // 플러그인 상태 캐시 무효화 self::invalidatePluginStatusCache(); + // 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능) $onProgress?->__invoke('files', '파일 삭제 중...'); ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier()); @@ -3920,6 +3933,9 @@ class PluginManager implements PluginManagerInterface // pending/bundled 목록에서 제거 unset($this->pendingPlugins[$pluginName]); unset($this->bundledPlugins[$pluginName]); + + // 확장 미들웨어 인덱스 무효화 — 재로드된 플러그인의 갱신된 미들웨어 선언 반영. + ExtensionMiddlewareRegistry::flush(); } } diff --git a/app/Http/Middleware/ExtensionMiddlewareGate.php b/app/Http/Middleware/ExtensionMiddlewareGate.php new file mode 100644 index 00000000..c1bb1fc0 --- /dev/null +++ b/app/Http/Middleware/ExtensionMiddlewareGate.php @@ -0,0 +1,65 @@ +route()?->getName() ?? ''; + $path = $request->path(); + $matched = $this->registry->resolveForRoute($routeName, $path, $group, $timing); + + if ($matched === []) { + return $next($request); + } + + // 매칭된 확장 미들웨어들을 Laravel Pipeline 으로 순차 실행 후 $next. + return (new Pipeline($this->app)) + ->send($request) + ->through($matched) + ->then($next); + } +} diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index 529c5fdc..00cbac47 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Contracts\Extension\CacheInterface; +use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; use App\Contracts\Extension\HookListenerInterface; use App\Contracts\Extension\ModuleSettingsInterface; use App\Contracts\Extension\StorageInterface; @@ -42,6 +43,7 @@ use App\Enums\DeactivationReason; use App\Extension\Cache\CoreCacheDriver; use App\Extension\CoreVersionChecker; use App\Extension\ExtensionManager; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\HookCacheManager; use App\Extension\HookListenerRegistrar; use App\Extension\HookManager; @@ -250,6 +252,9 @@ class CoreServiceProvider extends ServiceProvider $this->app->bind(CacheInterface::class, function () { return new CoreCacheDriver(config('cache.default')); }); + + // 확장 선언 미들웨어 self-gate 인덱스 (요청 내 인덱스 1회 빌드 → singleton) + $this->app->singleton(ExtensionMiddlewareRegistryInterface::class, ExtensionMiddlewareRegistry::class); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 8e06ca9f..cd1d946e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use App\Http\Middleware\AdminMiddleware; use App\Http\Middleware\CheckTemplateDependencies; use App\Http\Middleware\CheckUserStatus; use App\Http\Middleware\EnforceIdentityPolicy; +use App\Http\Middleware\ExtensionMiddlewareGate; use App\Http\Middleware\GzipEncodeResponse; use App\Http\Middleware\MaintenanceModePage; use App\Http\Middleware\OptionalSanctumMiddleware; @@ -106,6 +107,17 @@ $app = Application::configure(basePath: dirname(__DIR__)) EnforceIdentityPolicy::class, ]); + // 확장 선언 미들웨어 self-gate 래퍼 — 확장이 getMiddleware() 로 선언한 미들웨어를 + // 요청 시점에 라우트명·URI 매칭으로 실행. web/api × before_core/after_core 로 4회 등록. + // - before_core → 그룹 선두(prepend): 코어 전처리(인증·SetLocale·IDV) 전체보다 먼저 실행 + // - after_core → 그룹 후미(append): 코어 그룹 미들웨어 뒤에서 실행 + // Kernel 의 prepend/append 는 코어 내부 구현이고, 확장 개발자·게이트 파라미터는 + // before_core/after_core 의도 명칭만 본다. 매칭 확장 미들웨어가 없으면 no-op. + foreach (['web', 'api'] as $extGroup) { + $middleware->prependToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},before_core"); + $middleware->appendToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},after_core"); + } + // 비인증 게스트 redirect 경로 가드 (공개#39). // Laravel 12 기본값은 `redirectGuestsTo(fn () => route('login'))` 인데, // 이 프로젝트엔 'login' 이름 라우트가 없어 Accept 헤더 없는 /api/* 비인증 요청이 @@ -134,6 +146,7 @@ $app = Application::configure(basePath: dirname(__DIR__)) 'start.api.session' => StartApiSession::class, 'seo' => SeoMiddleware::class, 'identity.policy' => EnforceIdentityPolicy::class, + 'extension.middleware' => ExtensionMiddlewareGate::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/docs/backend/middleware.md b/docs/backend/middleware.md index f06918c5..7226597e 100644 --- a/docs/backend/middleware.md +++ b/docs/backend/middleware.md @@ -12,6 +12,7 @@ 3. 실행 순서: 전역 → 그룹(web/api) → 라우트 → 컨트롤러 4. permission 미들웨어: scope_type 기반 접근 제어 (except/only/menu 옵션 폐기) 5. scope 체크: Permission.resource_route_key + owner_key + role_permissions.scope_type +6. 확장 미들웨어 → getMiddleware() 선언(self-gate). SP Kernel 직접 조작·라우트 파일 자기 FQCN 금지 ``` --- @@ -21,6 +22,7 @@ - [핵심 원칙](#핵심-원칙) - [미들웨어 실행 순서](#미들웨어-실행-순서) - [미들웨어 등록 방식](#미들웨어-등록-방식) +- [확장 미들웨어 선언 (self-gate)](#확장-미들웨어-선언-self-gate) - [올바른 등록 예시](#올바른-등록-예시) - [문제 상황과 해결책](#문제-상황과-해결책) - [디버깅 방법](#디버깅-방법) @@ -81,6 +83,7 @@ | `appendToGroup('api', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 | | `appendToGroup('web', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 | | `alias()` | 라우트 지정 시 | ✅ 가능 | 권한 체크 등 | +| `getMiddleware()` (확장 self-gate) | 요청 시점 대상 매칭 | timing 에 따름 | 확장(모듈/플러그인)의 미들웨어 — [아래 절](#확장-미들웨어-선언-self-gate) | ### append vs appendToGroup 차이 @@ -93,6 +96,73 @@ --- +## 확장 미들웨어 선언 (self-gate) + +확장(모듈/플러그인)은 자기 미들웨어를 web/api 그룹에 직접 넣지 않는다. 각 확장이 `getMiddleware()` 로 미들웨어와 그 부착 대상을 선언하고, 코어 게이트 래퍼(`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 대상 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행한다. 코어 IDV 정책의 라우트명 인덱스 조회 모델과 동일하다. + +배경: 확장 라우트는 부팅 이후 지연 등록되고 확장 간 부팅 순서 보장이 없어, 부팅 시점에 다른 확장(또는 코어 특정 라우트)의 라우트를 순회해 미들웨어를 부착하는 방식은 성립하지 않는다. 요청 시점 매칭은 모든 라우트가 등록 완료된 뒤라 이 제약을 원천 회피한다. + +### getMiddleware() 스키마 + +```php +public function getMiddleware(): array +{ + return [ + [ + 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증) + 'groups' => ['api'], // ['web'] | ['api'] | ['web','api'] + 'timing' => 'after_core', // 'after_core'(기본) | 'before_core' + 'targets' => ['self'], // 라우트명/URI 패턴 배열 (필수, 아래 카탈로그) + ], + ]; +} +``` + +| 필드 | 필수 | 의미 | +|------|------|------| +| `class` | O | 미들웨어 FQCN. `class_exists` 실패 시 등록 거부 | +| `groups` | O | `['web']` \| `['api']` \| `['web','api']`. 빈 배열·미허용 그룹은 등록 거부 | +| `timing` | X | `after_core`(기본, 코어 그룹 미들웨어 뒤) \| `before_core`(코어 전처리 전체보다 먼저). `before_core` 는 인증 결과·로케일에 의존하는 미들웨어에 쓰지 않는다 (응답 사전차단/전처리 전용) | +| `targets` | O | 부착 대상 패턴 배열. 누락·빈 배열 시 등록 거부 | + +### targets 카탈로그 + +| 값 | 의미 | 매칭 방식 | +|----|------|----------| +| `self` | 자기 확장 라우트 전체 | 항목 groups 의 각 그룹마다 `{group}.modules.{id}.*` / `{group}.plugins.{id}.*` prefix 로 치환 | +| `all_extensions` | 모든 확장 라우트 (코어 제외) | 라우트명이 `*.modules.*` 또는 `*.plugins.*` | +| `core` | 코어 라우트 전체 (확장 제외) | 라우트명이 확장 prefix 가 **아닌** 것 (negative 판별). 무명 라우트는 core 로 간주하지 않음 | +| `everything` (별칭 `*`) | 코어 + 모든 확장 (전부) | 무조건 매칭 (무명 라우트 포함) | +| `module:{id}` | 특정 모듈 라우트 전체 | `*.modules.{id}.*` | +| `plugin:{id}` | 특정 플러그인 라우트 전체 | `*.plugins.{id}.*` | +| 원시 glob 문자열 | 임의 라우트 이름 패턴 (`api.modules.foo.cart.*`) | `Str::is()` 라우트명 매칭 | +| brace 문자열 | `{a,b}` 단일 그룹 전개 | 전개 후 각 glob 매칭 | +| `/` 로 시작하는 URI 패턴 | 임의 요청 URI 패턴 (`/`, `/admin/*`) — 무명 라우트 타게팅용 | `$request->path()` 매칭 | + +`everything`·`*`, `all_extensions`, `core` 는 코어·타 확장에 개입하는 **광역 타게팅** 이다. 선언에 대상이 명문화되므로 감사 가능하며, 리뷰 시 주의 대상으로 표기한다. 자기 라우트 중 일부만 노려야 하면 `self` 대신 원시 glob 으로 정밀화한다. + +### 이중 매칭 (라우트명 + URI) — 무명 catch-all 대응 + +target 이 `/` 로 시작하면 **URI 패턴**(`$request->path()` 매칭), 아니면 **라우트명 패턴**(`$request->route()?->getName()` 매칭)이다. 라우트명은 dot-notation 이라 `/` 로 시작하지 않으므로 구분이 명확하다. 코어 SSR 셸 catch-all 라우트는 `->name()` 이 없어(무명) 라우트명으로 타게팅할 수 없다 → 이런 무명 라우트를 대상으로 하려면 URI 패턴(`/`)이나 `everything` 을 쓴다. 라우트명 계열 target 은 무명 라우트에서 항상 miss 한다. + +### 게이트 동작과 무효화 + +- 게이트 래퍼는 코어가 bootstrap 에서 web/api × before_core/after_core 로 4회 등록한다. 매칭 확장 미들웨어가 없으면 no-op 이라 확장 0개 환경에서도 무해하다. +- registry 인덱스는 **활성 확장만** 포함한다. 비활성 확장의 미들웨어는 게이트가 실행하지 않는다. +- 인덱스는 확장 활성/비활성/설치/제거/리로드 시 자동 무효화된다. + +### 금지 패턴 + +| 금지 | 올바른 방식 | +|------|------------| +| 확장 SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` 직접 호출 | `getMiddleware()` 선언 | +| 확장 SP 에서 `aliasMiddleware()` 직접 호출 | `getMiddleware()` 선언 | +| 확장 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착 | `getMiddleware()` 선언 | + +자동 차단: audit 룰 `extension-middleware-declarative-registration`, `extension-route-middleware-alias-reference`, `extension-middleware-gate-cache-coverage` (모두 error). 서드파티 확장의 구방식은 Laravel 공식 API 라 런타임 차단은 없고 문서로 안내한다. + +--- + ## 올바른 등록 예시 ### bootstrap/app.php diff --git a/docs/extension/module-basics.md b/docs/extension/module-basics.md index 080a61c9..35c56a63 100644 --- a/docs/extension/module-basics.md +++ b/docs/extension/module-basics.md @@ -141,6 +141,7 @@ | `getHookListeners()` | `[]` | 훅 리스너 | | `getDependencies()` | `[]` | 의존성 | | `getMetadata()` | `[]` | 메타데이터 | +| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw | #### 동적 권한/역할/메뉴 보존 규칙 diff --git a/docs/extension/module-routing.md b/docs/extension/module-routing.md index fe763e57..110b7070 100644 --- a/docs/extension/module-routing.md +++ b/docs/extension/module-routing.md @@ -74,6 +74,8 @@ vendor-my-module → Modules\Vendor\My\Module | URL prefix | `modules/{module-name}` | | Name prefix | `web.modules.{module-name}.` | +> 라우트 이름(`{web\|api}.modules.{module-name}.{name}`)은 확장 미들웨어 타게팅의 1급 키다. `Module::getMiddleware()` 의 `targets` 는 이 라우트명 패턴으로 미들웨어 부착 대상을 지정한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate). + --- ## 4. 라우트 파일 작성 diff --git a/docs/extension/plugin-development.md b/docs/extension/plugin-development.md index a8691bde..b166c436 100644 --- a/docs/extension/plugin-development.md +++ b/docs/extension/plugin-development.md @@ -240,6 +240,7 @@ class Plugin implements PluginInterface | `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 | | `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 | | `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 | +| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw | > **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조. @@ -394,6 +395,7 @@ class FooPluginServiceProvider extends BasePluginServiceProvider - **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다. - **인라인 contextual binding 복제 금지** — `$this->app->when([X::class])->needs(StorageInterface::class)->give(...)` 패턴을 직접 작성하지 말고 `$storageServices` 배열에 등록한다. - **Listener 의 동적 인스턴스 생성에서 `app(CacheInterface::class)` 직접 호출 금지** — `app()->makeWith(MyService::class, [...])` 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다. +- **HTTP Kernel 미들웨어 그룹 직접 조작 금지** — SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` / `aliasMiddleware()`, 또는 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착하지 않는다. 대신 `Plugin::getMiddleware()` 로 미들웨어와 부착 대상(targets)을 선언하면 코어 self-gate 게이트가 요청 시점에 실행한다. audit 룰 `extension-middleware-declarative-registration` / `extension-route-middleware-alias-reference` 가 자동 차단한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate). 상세: [cache-driver.md `## 확장에서 cache 바인딩`](cache-driver.md) diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 02eadcd3..531204f7 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -19,7 +19,8 @@ - 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 불필요"가 아니라 "PG 고정"으로 표시되고 어느 결제대행사를 통해 처리되는지 함께 보여줍니다. 간편결제는 해당 결제대행사 전용이므로 다른 대행사로 바꿀 수 없습니다. - 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이 등의 색상 배지와 로고)를 표준화했습니다. 이전에는 결제 플러그인이 화면이 그려진 뒤 버튼 모양을 덧입히는 방식이라 여러 결제사가 함께 켜져 있을 때 드물게 표시가 겹치거나 어긋날 수 있었는데, 이제 결제수단 정보에 담긴 브랜드 마크를 주문서가 처음부터 직접 그리도록 바꿔 표시가 안정적입니다. - 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다. -- 위 표시 개선을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 쇼핑몰 모듈을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요. +- 배송 국가 해석·비회원 주문 접근 확인 등 요청 경로별 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 동작은 이전과 동일합니다. +- 위 개선을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 쇼핑몰 모듈을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요. ## [1.0.3] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md index cf4da12e..cb406e36 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md @@ -16,6 +16,14 @@ --- +## 요청별 배송국가 해석 (ResolveShippingCountry 미들웨어) + +위 두 엔드포인트는 회원이 **저장해 둔** 선호 배송국가를 조회·저장한다. 이와 별개로, 상품 목록·장바구니·결제(`products/*`, `cart/*`, `checkout/*`)와 비회원 주문 생성(`user/orders`) 요청은 요청 시점의 배송국가를 해석하는 `ResolveShippingCountry` 미들웨어를 거친다. 이 미들웨어는 로그인 회원의 저장값·요청 파라미터·기본 배송국가를 순서대로 반영해 이후 배송비/배송가능 여부 계산의 기준 국가를 확정한다. + +이 미들웨어는 코어의 확장 미들웨어 self-gate(`Module::getMiddleware()` 의 `targets` 로 위 라우트명에만 정밀 타게팅)로 부착된다. 라우트 파일에서 직접 부착하던 이전 방식과 적용 대상·동작은 동일하다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md). + +--- + ### GET /api/modules/sirsoft-ecommerce/user/shipping-country diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index 0d38a80f..39b125ad 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -9,6 +9,9 @@ use Illuminate\Database\Seeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\ClaimReasonSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\SequenceSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingCarrierSeeder; +use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; +use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; +use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken; use Modules\Sirsoft\Ecommerce\Listeners\ActivityLogDescriptionResolver; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultCurrencyOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultShippingCountryOnRegisterListener; @@ -2077,6 +2080,51 @@ class Module extends AbstractModule ]; } + /** + * 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * 코어 self-gate 게이트가 요청 시점에 targets 패턴 매칭으로 실행합니다. + * + * - DetectDevice: 무명 User SSR 셸 catch-all(routes/web.php)에 라우트명이 없어 URI 패턴 '/' 로 + * 타게팅(모든 web 요청 = 기존 web 그룹 append 와 동등). InjectAppConfigDeviceListener 가 + * appConfig.isIos 로 주입(체크아웃 애플페이 iOS 게이팅). + * - ResolveShippingCountry: 상품/장바구니/체크아웃/주문생성 라우트에만 부착 (배송국가 해석). + * - VerifyGuestOrderToken: 비회원 주문 후속 액션 4개 라우트에만 부착 (verify 는 미부착 — + * 토큰 발급 전 단계라 개별 지정). + * + * @return array, timing?: string, targets: array}> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => DetectDevice::class, + 'groups' => ['web'], + 'targets' => ['/'], + ], + [ + 'class' => ResolveShippingCountry::class, + 'groups' => ['api'], + 'targets' => [ + 'api.modules.sirsoft-ecommerce.products.*', + 'api.modules.sirsoft-ecommerce.cart.*', + 'api.modules.sirsoft-ecommerce.checkout.*', + 'api.modules.sirsoft-ecommerce.user.orders.store', + ], + ], + [ + 'class' => VerifyGuestOrderToken::class, + 'groups' => ['api'], + 'targets' => [ + 'api.modules.sirsoft-ecommerce.guest.orders.cancel', + 'api.modules.sirsoft-ecommerce.guest.orders.estimate-refund', + 'api.modules.sirsoft-ecommerce.guest.orders.update-shipping-address', + 'api.modules.sirsoft-ecommerce.guest.orders.confirm-option', + ], + ], + ]; + } + /** * SEO 변수 메타데이터 정의 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index 5742e1d0..e22a4739 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -4,8 +4,6 @@ namespace Modules\Sirsoft\Ecommerce\Providers; use App\Extension\BaseModuleServiceProvider; use App\Seo\SitemapGenerator; -use Illuminate\Contracts\Http\Kernel as HttpKernelContract; -use Illuminate\Foundation\Http\Kernel as HttpKernel; use Modules\Sirsoft\Ecommerce\Console\Commands\AggregateEcommerceStatsCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\CancelPendingPaymentOrdersCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\EarnMileageCommand; @@ -213,19 +211,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider $this->commands($this->commands); } - // 요청 기기 유형(iOS) 감지 미들웨어를 web 그룹(SSR 셸 렌더 경로)에 등록. - // InjectAppConfigDeviceListener 가 core.frontend.filter_app_config 훅에서 이 값을 읽어 - // appConfig.isIos 로 주입한다(체크아웃 애플페이 iOS 게이팅). DB/인증 비의존 순수 UA 판정이라 - // 마이그레이션 이전에도 안전하다. - // - // Router::pushMiddlewareToGroup 은 Router 자체 레지스트리만 갱신해 실제 요청 파이프라인이 - // 참조하는 HTTP Kernel 의 web 그룹에는 반영되지 않는다(미들웨어 미실행 → isIos 항상 false). - // sirsoft-gdpr 선례와 동일하게 HTTP Kernel::appendMiddlewareToGroup 을 사용한다 - // (Kernel 은 중복 등록을 자체 방지하므로 매 부팅 호출돼도 1회만 등록). - $kernel = $this->app->make(HttpKernelContract::class); - if ($kernel instanceof HttpKernel) { - $kernel->appendMiddlewareToGroup('web', DetectDevice::class); - } + // 요청 기기 유형(iOS) 감지 미들웨어(DetectDevice)는 코어 self-gate 방식으로 이관됨. + // Module::getMiddleware() 가 targets=['/'](무명 User SSR 셸 URI 패턴)로 선언하고, + // 코어 ExtensionMiddlewareGate 가 요청 시점에 실행한다. (SP Kernel 직접 조작 제거) // Sitemap 기여자 등록 $this->app->booted(function () { diff --git a/modules/_bundled/sirsoft-ecommerce/src/routes/api.php b/modules/_bundled/sirsoft-ecommerce/src/routes/api.php index 3251043e..8e4c0d3c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/routes/api.php +++ b/modules/_bundled/sirsoft-ecommerce/src/routes/api.php @@ -44,8 +44,6 @@ use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCouponController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCurrencyController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserMileageController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserShippingCountryController; -use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; -use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken; /* |-------------------------------------------------------------------------- @@ -81,7 +79,7 @@ Route::prefix('categories')->group(function () { // GET /api/modules/sirsoft-ecommerce/products/new - 신상품 조회 // GET /api/modules/sirsoft-ecommerce/products/recent - 최근 본 상품 조회 // GET /api/modules/sirsoft-ecommerce/products/{id} - 공개 상품 상세 조회 -Route::prefix('products')->middleware(['optional.sanctum', ResolveShippingCountry::class, 'permission:user,sirsoft-ecommerce.user-products.read'])->group(function () { +Route::prefix('products')->middleware(['optional.sanctum', 'permission:user,sirsoft-ecommerce.user-products.read'])->group(function () { Route::get('/', [PublicProductController::class, 'index']) ->name('products.index'); @@ -153,7 +151,7 @@ Route::get('review-image/{hash}', [ReviewImageController::class, 'download']) // DELETE /api/modules/sirsoft-ecommerce/cart/all - 전체 삭제 // POST /api/modules/sirsoft-ecommerce/cart/merge - 비회원→회원 병합 // GET /api/modules/sirsoft-ecommerce/cart/count - 아이템 수 조회 -Route::prefix('cart')->middleware(['optional.sanctum', ResolveShippingCountry::class])->group(function () { +Route::prefix('cart')->middleware(['optional.sanctum'])->group(function () { // cart_key 발급 (비회원용) - 와일드카드보다 먼저 정의 Route::post('/key', [CartController::class, 'issueCartKey']) ->name('cart.key'); @@ -208,7 +206,7 @@ Route::prefix('cart')->middleware(['optional.sanctum', ResolveShippingCountry::c // PUT /api/modules/sirsoft-ecommerce/checkout - 임시 주문 업데이트 (쿠폰/마일리지 재계산) // DELETE /api/modules/sirsoft-ecommerce/checkout - 임시 주문 삭제 // POST /api/modules/sirsoft-ecommerce/checkout/extend - 임시 주문 만료 연장 -Route::prefix('checkout')->middleware(['optional.sanctum', ResolveShippingCountry::class])->group(function () { +Route::prefix('checkout')->middleware(['optional.sanctum'])->group(function () { // 임시 주문 생성 Route::post('/', [CheckoutController::class, 'store']) ->name('checkout.store'); @@ -255,7 +253,7 @@ Route::prefix('settings')->group(function () { // /api/modules/sirsoft-ecommerce/user/orders 한 경로만 매칭하기 때문 — 비회원 주문에서도 // PG 결제창이 정상 노출되도록 단일 endpoint 로 통합. 회원/비회원 분기는 컨트롤러가 처리한다. Route::post('user/orders', [PublicOrderController::class, 'store']) - ->middleware(['optional.sanctum', ResolveShippingCountry::class, 'permission:user,sirsoft-ecommerce.user-orders.create']) + ->middleware(['optional.sanctum', 'permission:user,sirsoft-ecommerce.user-orders.create']) ->name('user.orders.store'); // 비회원 주문 조회 인증 API (주문번호+전화번호+비밀번호 → 조회 토큰 발급) @@ -273,7 +271,6 @@ Route::post('guest/orders/verify', [PublicOrderController::class, 'verify']) // (PublicOrderController::showByOrderNumber). 비회원 후속 액션 (cancel/estimate-refund/update-shipping-address/confirm-option) // 만 본 그룹에 둔다. Route::prefix('guest/orders/{orderNumber}') - ->middleware(VerifyGuestOrderToken::class) ->group(function () { // POST /guest/orders/{orderNumber}/cancel - 주문 취소 Route::post('/cancel', [PublicOrderController::class, 'cancel']) diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php index 2f301d03..fea9f556 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php @@ -4,38 +4,79 @@ declare(strict_types=1); namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Providers; -use Illuminate\Contracts\Http\Kernel as HttpKernelContract; -use Illuminate\Foundation\Http\Kernel as HttpKernel; +use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; +use App\Extension\ExtensionMiddlewareRegistry; use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; +use Modules\Sirsoft\Ecommerce\Module; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; /** - * DetectDevice 미들웨어가 실제 요청 파이프라인의 web 그룹에 등록되는지 검증. + * DetectDevice 미들웨어가 코어 self-gate 선언 방식으로 등록·실행되는지 검증. * - * 회귀: EcommerceServiceProvider 가 Router::pushMiddlewareToGroup('web', ...) 로 등록하면 - * HTTP Kernel 이 참조하는 web 그룹에 반영되지 않아(Router 자체 레지스트리만 갱신) SSR 셸 - * 요청에서 미들웨어가 실행되지 않는다. 그 결과 appConfig.isIos 가 항상 fail-safe false 가 되어 - * 애플페이 iOS 게이팅 서버층이 동작하지 않는다. Kernel::appendMiddlewareToGroup 을 써야 한다. + * 이관 전 회귀: SP 가 Router::pushMiddlewareToGroup 으로 등록하면 HTTP Kernel 에 반영되지 않아 + * SSR 셸 요청에서 미실행 → appConfig.isIos 항상 false. 이제는 Module::getMiddleware() 가 + * DetectDevice 를 targets=['/'](무명 User SSR 셸 URI 패턴)로 선언하고 코어 게이트가 실행한다. + * + * 본 테스트는 (1) 선언 계약이 올바른지, (2) registry 가 루트 URI(web/after_core)에서 DetectDevice + * 를 매칭하는지를 검증한다. 미들웨어 자체 동작(isIos 판정)은 DetectDeviceTest 가 커버한다. */ class DetectDeviceMiddlewareRegistrationTest extends ModuleTestCase { + /** + * @scenario mark_form=badge, requires_ios=true, device=non_ios + * + * @effects server_ua_sets_is_ios, detect_device_declared_and_resolved_for_root_uri_web_only + */ + public function test_detect_device_is_declared_via_get_middleware(): void + { + $module = new Module; + + $declarations = $module->getMiddleware(); + + $detect = collect($declarations)->firstWhere('class', DetectDevice::class); + + $this->assertNotNull($detect, 'Module::getMiddleware() 가 DetectDevice 를 선언해야 합니다.'); + $this->assertSame(['web'], $detect['groups'], 'DetectDevice 는 web 그룹 선언이어야 합니다.'); + $this->assertContains('/', $detect['targets'], 'DetectDevice 는 무명 SSR 셸용 URI 패턴 "/" 로 타게팅해야 합니다.'); + } + /** * @scenario mark_form=badge, requires_ios=true, device=non_ios * * @effects server_ua_sets_is_ios */ - public function test_detect_device_is_registered_in_kernel_web_group(): void + public function test_gate_resolves_detect_device_for_root_uri(): void { - $kernel = $this->app->make(HttpKernelContract::class); - $this->assertInstanceOf(HttpKernel::class, $kernel); + ExtensionMiddlewareRegistry::flush(); + $registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class); - // HTTP Kernel 이 실제 요청 처리 시 참조하는 web 그룹을 조회한다. - $webGroup = $kernel->getMiddlewareGroups()['web'] ?? []; + // 무명 SSR 셸 catch-all: routeName='' + path '/' → web/after_core 에서 DetectDevice 매칭. + $matched = $registry->resolveForRoute('', '/', 'web', 'after_core'); $this->assertContains( DetectDevice::class, - $webGroup, - 'DetectDevice 미들웨어가 HTTP Kernel 의 web 그룹에 등록되어야 SSR 셸 요청에서 실행된다.' + $matched, + '코어 게이트가 루트 URI(무명 라우트) 요청에서 DetectDevice 를 매칭해야 SSR 셸에서 실행된다.' ); + + ExtensionMiddlewareRegistry::flush(); + } + + /** + * @scenario mark_form=badge, requires_ios=true, device=non_ios + * + * @effects server_ua_sets_is_ios + */ + public function test_gate_does_not_match_detect_device_for_api_group(): void + { + ExtensionMiddlewareRegistry::flush(); + $registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class); + + // DetectDevice 는 web 그룹만 선언 → api 그룹 조회에서는 매칭되지 않는다. + $matched = $registry->resolveForRoute('', '/', 'api', 'after_core'); + + $this->assertNotContains(DetectDevice::class, $matched); + + ExtensionMiddlewareRegistry::flush(); } } diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index ef42ce01..06c8a9f3 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,6 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-15 + +### Changed + +- 쿠키 동의 처리 방식을 코어의 새로운 표준 연결 방식으로 옮겼습니다. 모든 페이지 응답에 동의하지 않은 쿠키를 걸러내는 동작은 이전과 동일합니다. +- 코어 최소 요구 버전을 7.0.5 로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 먼저 최신으로 업데이트하세요. + ## [1.0.0] - 2026-07-01 ### Changed diff --git a/plugins/_bundled/sirsoft-gdpr/composer.json b/plugins/_bundled/sirsoft-gdpr/composer.json index 72ef41ed..7a887d75 100644 --- a/plugins/_bundled/sirsoft-gdpr/composer.json +++ b/plugins/_bundled/sirsoft-gdpr/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-gdpr", "description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft", "type": "library", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "authors": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json index e9170178..e4cea08b 100644 --- a/plugins/_bundled/sirsoft-gdpr/package.json +++ b/plugins/_bundled/sirsoft-gdpr/package.json @@ -1,6 +1,6 @@ { "name": "@plugins/sirsoft-gdpr", - "version": "1.0.0", + "version": "1.0.1", "private": true, "type": "module", "scripts": { diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json index 4b3ff226..7ad4cd8f 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.json +++ b/plugins/_bundled/sirsoft-gdpr/plugin.json @@ -5,13 +5,13 @@ "ko": "GDPR (일반 데이터 보호 규정)", "en": "GDPR (General Data Protection Regulation)" }, - "version": "1.0.0", + "version": "1.0.1", "description": { "ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.", "en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal." }, "license": "MIT", - "g7_version": ">=7.0.0", + "g7_version": ">=7.0.5", "dependencies": { "modules": {}, "plugins": {} diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.php b/plugins/_bundled/sirsoft-gdpr/plugin.php index 4c992989..abb2852f 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.php +++ b/plugins/_bundled/sirsoft-gdpr/plugin.php @@ -8,6 +8,11 @@ use App\Enums\MenuPermissionType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; use App\Models\Menu; +use Plugins\Sirsoft\Gdpr\Http\Middleware\CookieConsentMiddleware; +use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthConsentListener; +use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener; +use Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener; +use Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener; /** * GDPR (일반 데이터 보호 규정) 플러그인 @@ -339,6 +344,28 @@ class Plugin extends AbstractPlugin return []; } + /** + * 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * functional cookie 게이팅 미들웨어(CookieConsentMiddleware)를 코어 self-gate 방식으로 + * 선언합니다. EDPB Guidelines 2/2023 §16(사전 차단) 충족을 위해 모든 web/api 응답을 + * 대상으로 하므로 targets=['everything'](광역 타게팅) + timing=before_core(코어 전처리 전 + * 응답 처리)로 지정합니다. 코어 ExtensionMiddlewareGate 가 요청 시점에 실행합니다. + * + * @return array, timing?: string, targets: array}> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => CookieConsentMiddleware::class, + 'groups' => ['web', 'api'], + 'timing' => 'before_core', + 'targets' => ['everything'], + ], + ]; + } + /** * 플러그인 설정 값 반환 (기본값) * @@ -537,10 +564,10 @@ class Plugin extends AbstractPlugin public function getHookListeners(): array { return [ - \Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener::class, - \Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener::class, - \Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener::class, - \Plugins\Sirsoft\Gdpr\Listeners\GdprAuthConsentListener::class, + GdprUserWithdrawListener::class, + GdprUserDeleteListener::class, + GdprAuthLogoutListener::class, + GdprAuthConsentListener::class, ]; } } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php b/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php index c104a3ca..b0342c58 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php @@ -3,9 +3,6 @@ namespace Plugins\Sirsoft\Gdpr\Providers; use App\Extension\BasePluginServiceProvider; -use Illuminate\Contracts\Http\Kernel as HttpKernelContract; -use Illuminate\Foundation\Http\Kernel as HttpKernel; -use Plugins\Sirsoft\Gdpr\Http\Middleware\CookieConsentMiddleware; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface; @@ -16,8 +13,10 @@ use Plugins\Sirsoft\Gdpr\Repositories\GdprUserConsentRepository; /** * GDPR 플러그인 서비스 프로바이더. * - * Repository 바인딩은 BasePluginServiceProvider 표준에 위임하며, 부트 단계에서 - * functional cookie 게이팅 미들웨어를 'web' / 'api' 그룹 앞단에 등록합니다 (Phase 2). + * Repository 바인딩은 BasePluginServiceProvider 표준에 위임합니다. + * functional cookie 게이팅 미들웨어(CookieConsentMiddleware)는 코어 self-gate 방식으로 + * 이관되어 Plugin::getMiddleware() 가 선언하고 코어 ExtensionMiddlewareGate 가 실행합니다. + * (SP Kernel 직접 조작 제거 — EDPB §16 광역 타게팅은 targets=['everything']/before_core 로 유지) */ class GdprServiceProvider extends BasePluginServiceProvider { @@ -28,25 +27,4 @@ class GdprServiceProvider extends BasePluginServiceProvider GdprUserConsentHistoryRepositoryInterface::class => GdprUserConsentHistoryRepository::class, GdprPolicyVersionRepositoryInterface::class => GdprPolicyVersionRepository::class, ]; - - /** - * 부트스트랩 — functional cookie 게이팅 미들웨어를 'web' / 'api' 그룹 앞단에 등록. - * - * Phase 2: EDPB Guidelines 2/2023 §16 (사전 차단) 충족 — functional 미동의 시 응답에서 - * functional cookie 제거. Laravel 11+ HTTP Kernel::prependMiddlewareToGroup() 은 - * 내부적으로 중복 등록을 방지하므로 매 요청 호출되어도 1회만 등록됩니다. - */ - public function boot(): void - { - parent::boot(); - - $kernel = $this->app->make(HttpKernelContract::class); - - if (! $kernel instanceof HttpKernel) { - return; - } - - $kernel->prependMiddlewareToGroup('web', CookieConsentMiddleware::class); - $kernel->prependMiddlewareToGroup('api', CookieConsentMiddleware::class); - } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/PluginMiddlewareDeclarationTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/PluginMiddlewareDeclarationTest.php new file mode 100644 index 00000000..be4b2f7d --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/PluginMiddlewareDeclarationTest.php @@ -0,0 +1,33 @@ +getMiddleware()) + ->firstWhere('class', CookieConsentMiddleware::class); + + $this->assertNotNull($declaration, 'Plugin::getMiddleware() 가 CookieConsentMiddleware 를 선언해야 합니다.'); + $this->assertEqualsCanonicalizing(['web', 'api'], $declaration['groups'], 'web/api 양 그룹 선언이어야 합니다 (전 응답 대상).'); + $this->assertSame('before_core', $declaration['timing'] ?? null, '코어 전처리 전 응답 처리를 위해 before_core 여야 합니다.'); + $this->assertContains('everything', $declaration['targets'], 'EDPB §16 광역 타게팅을 위해 targets=everything 이어야 합니다.'); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index aa93009e..5ccfa117 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -18,6 +18,8 @@ - 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · KG이니시스"로 표시됩니다. 간편결제는 KG 이니시스 전용이므로 다른 결제대행사로 바꿀 수 없습니다. - 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화되어 주문서 입력값이 초기화되는 등의 부작용이 사라집니다. - 주문서의 간편결제 버튼에 표시되는 브랜드 로고(네이버페이·삼성페이·카카오페이·L.pay 등)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다. +- 가상계좌 입금통보 등 결제대행사 서버가 직접 보내는 요청의 발신 주소를 확인하는 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 발신 서버 확인 범위(입금통보 계열 경로에만 적용, 브라우저 결제 콜백은 제외)는 이전과 동일합니다. +- 위 변경을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요. ## [1.0.0] - 2026-07-01 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md index 4ed9381e..5b72414d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/vbank.md @@ -73,4 +73,6 @@ HTTP/1.1 200 가상계좌 입금통보를 받을 콜백 URL(PC 웹용 `url`, 모바일용 `mobile_url`)을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 라우트 파일 내 클로저로 정의되어 있으며, `url()` 헬퍼로 현재 사이트 도메인 기준의 절대 URL(`/plugins/sirsoft-pay_kginicis/payment/vbank-notify` 및 `.../payment/mobile/vbank-notify`)을 조합해 내려줍니다. 운영자는 이 URL 을 KG 이니시스 가맹점 설정에 입금통보 URL 로 등록해, 구매자가 가상계좌에 실제로 입금했을 때 KG 이니시스가 이 주소로 통보를 보내도록 합니다. 관리자 인증(`auth:sanctum`)과 `sirsoft-ecommerce.settings.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다. +> **입금통보 수신 엔드포인트의 발신 서버 확인**: 위 URL 이 가리키는 실제 입금통보 수신 경로(`payment/vbank-notify` · `payment/mobile/vbank-notify` · CBT `payment/cbt/cvs-notify`)는 위변조·재처리 방어를 위해 KG 이니시스 공식 발신 IP 만 허용한다. 이 IP 화이트리스트 검사는 코어의 확장 미들웨어 self-gate(`Plugin::getMiddleware()` 의 `targets` 로 입금통보 계열 라우트명에만 정밀 타게팅)로 수행되며, 브라우저가 접속하는 결제 콜백(`payment/callback`)에는 적용되지 않는다. 검사 자체의 동작·허용 범위는 이전과 동일하다. + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json index b6cf8582..ce550be9 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json @@ -13,7 +13,7 @@ "ko": "KG 이니시스 결제 게이트웨이 (표준결제창 연동, 일본결제 지원)", "en": "KG Inicis payment gateway (standard payment window, Japan payment support)" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.0.5", "dependencies": { "modules": { "sirsoft-ecommerce": ">=1.0.0" diff --git a/plugins/_bundled/sirsoft-pay_kginicis/plugin.php b/plugins/_bundled/sirsoft-pay_kginicis/plugin.php index 82e368f8..b41d62ab 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.php @@ -333,6 +333,30 @@ class Plugin extends AbstractPlugin return is_array($defaults) ? $defaults : []; } + /** + * 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * IP 화이트리스트(InicisNotifyIpWhitelist)는 서버 발신 webhook 라우트에만 부착합니다. + * 브라우저 POST 콜백(payment.callback 등)은 사용자 IP 라 대상에서 제외 — self 로 뭉치면 + * 결제 회귀. 코어 ExtensionMiddlewareGate 가 요청 시점에 실행합니다. + * + * @return array, timing?: string, targets: array}> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => Http\Middleware\InicisNotifyIpWhitelist::class, + 'groups' => ['web'], + 'targets' => [ + 'web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify', + 'web.plugins.sirsoft-pay_kginicis.payment.vbank-notify', + 'web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify', + ], + ], + ]; + } + public function getHookListeners(): array { return [ diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/routes/web.php b/plugins/_bundled/sirsoft-pay_kginicis/src/routes/web.php index 7ed69d73..7c2f91b7 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/routes/web.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/routes/web.php @@ -1,13 +1,21 @@ withoutMiddleware([ - \App\Http\Middleware\SyncBoostWithDebugMode::class, - \Illuminate\Cookie\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \App\Http\Middleware\SetLocale::class, - \App\Http\Middleware\SetTimezone::class, + SyncBoostWithDebugMode::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + ValidateCsrfToken::class, + SubstituteBindings::class, + SetLocale::class, + SetTimezone::class, ]) ->name('payment.close'); -Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class])->group(function () { +Route::withoutMiddleware([ValidateCsrfToken::class])->group(function () { Route::match(['get', 'post'], '/payment/cbt/callback', [CbtCallbackController::class, 'handle']) ->name('payment.cbt.callback'); + // IP 화이트리스트(InicisNotifyIpWhitelist)는 코어 self-gate 로 이관 — Plugin::getMiddleware() 가 + // cvs-notify/vbank-notify/mobile.vbank-notify webhook 라우트명에만 타게팅 (payment.callback 제외). Route::post('/payment/cbt/cvs-notify', [CbtCvsNotifyController::class, 'handle']) - ->middleware(InicisNotifyIpWhitelist::class) ->name('payment.cbt.cvs-notify'); Route::post('/payment/callback', [PaymentCallbackController::class, 'authCallback']) ->name('payment.callback'); - // KG 이니시스 공식 발송 IP 만 허용 (위변조/재처리 방어) Route::post('/payment/vbank-notify', [PaymentCallbackController::class, 'vbankNotify']) - ->middleware(InicisNotifyIpWhitelist::class) ->name('payment.vbank-notify'); Route::post('/payment/mobile/vbank-notify', [PaymentCallbackController::class, 'mobileVbankNotify']) - ->middleware(InicisNotifyIpWhitelist::class) ->name('payment.mobile.vbank-notify'); // (제거됨) /payment/escrow-notify — KG 이니시스 PC 에스크로 매뉴얼에는 webhook diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Middleware/InicisNotifyIpWhitelistGateTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Middleware/InicisNotifyIpWhitelistGateTest.php new file mode 100644 index 00000000..c5a66b60 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Middleware/InicisNotifyIpWhitelistGateTest.php @@ -0,0 +1,60 @@ +app->make(ExtensionMiddlewareRegistryInterface::class) + ->resolveForRoute($routeName, 'x', 'web', 'after_core'); + } + + /** + * @effects pay_ip_guard_matched_for_webhook_routes + */ + public function test_ip_guard_matched_for_webhook_routes(): void + { + foreach ([ + 'web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify', + 'web.plugins.sirsoft-pay_kginicis.payment.vbank-notify', + 'web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify', + ] as $routeName) { + $this->assertContains( + InicisNotifyIpWhitelist::class, + $this->resolveWeb($routeName), + "IP 가드가 webhook 라우트({$routeName})에 매칭되어야 합니다.", + ); + } + } + + /** + * @effects pay_ip_guard_not_matched_for_browser_callback + */ + public function test_ip_guard_not_matched_for_browser_callback(): void + { + // 정밀도 회귀: 브라우저 콜백(사용자 IP)에는 IP 가드가 매칭되지 않아야 결제 회귀가 없다. + $this->assertNotContains( + InicisNotifyIpWhitelist::class, + $this->resolveWeb('web.plugins.sirsoft-pay_kginicis.payment.callback'), + 'IP 가드가 브라우저 콜백에 잘못 매칭되면 결제 콜백이 차단됩니다 (정밀도 회귀).', + ); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php index c47c2b2b..e1814f90 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php @@ -2,7 +2,21 @@ namespace Plugins\Sirsoft\PayKginicis\Tests; +use App\Enums\ExtensionStatus; +use App\Enums\PermissionType; +use App\Extension\ExtensionMiddlewareRegistry; +use App\Extension\ModuleManager; +use App\Extension\PluginManager; +use App\Models\Permission; +use App\Models\Plugin; +use App\Models\Role; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Route; +use Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder; +use Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider; +use Plugins\Sirsoft\PayKginicis\Providers\PayKginicisServiceProvider; use Tests\TestCase; abstract class PluginTestCase extends TestCase @@ -18,7 +32,7 @@ abstract class PluginTestCase extends TestCase protected function seeder(): string { - return \Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder::class; + return TestingSeeder::class; } protected function migrateFreshUsing(): array @@ -43,17 +57,21 @@ abstract class PluginTestCase extends TestCase $this->registerModuleAutoload(); - $this->app->register(\Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider::class); - $this->app->register(\Plugins\Sirsoft\PayKginicis\Providers\PayKginicisServiceProvider::class); + $this->app->register(EcommerceServiceProvider::class); + $this->app->register(PayKginicisServiceProvider::class); $this->app['translator']->addNamespace('sirsoft-pay_kginicis', dirname(__DIR__).'/lang'); $this->registerModuleRoutes(); $this->registerPluginRoutes(); + // 코어 self-gate 미들웨어 실행을 위해 이 플러그인을 활성 상태로 등록한다 + // (getMiddleware() 선언이 게이트 인덱스에 수집되려면 getActivePlugins() 에 포함돼야 함). + $this->activateSelfForMiddlewareGate('sirsoft-pay_kginicis', \Plugins\Sirsoft\PayKginicis\Plugin::class); + // SettingsServiceProvider 가 storage/app/settings/general.json 의 site_url 로 // app.url 을 override 하면 Laravel 의 assertRedirect (APP_URL 기반) 와 mismatch. // 테스트 환경에서는 APP_URL 그대로 사용하도록 명시 리셋. - \Illuminate\Support\Facades\Config::set('app.url', env('APP_URL', 'http://localhost')); + Config::set('app.url', env('APP_URL', 'http://localhost')); // BaseModuleServiceProvider::registerStorageBindings 가 ModuleManager 에서 // 모듈 인스턴스를 조회해 StorageInterface 를 바인딩하는데, 테스트 환경의 @@ -146,7 +164,7 @@ abstract class PluginTestCase extends TestCase if (! class_exists($moduleClass)) { return; } - $manager = $this->app->make(\App\Extension\ModuleManager::class); + $manager = $this->app->make(ModuleManager::class); $ref = new \ReflectionClass($manager); $prop = $ref->getProperty('modules'); $prop->setAccessible(true); @@ -173,14 +191,14 @@ abstract class PluginTestCase extends TestCase } $relativeClass = substr($class, $len); - $file = $moduleBasePath . str_replace('\\', '/', $relativeClass) . '.php'; + $file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php'; if (file_exists($file)) { require $file; } }); - $helpersFile = $moduleBasePath . 'Helpers/helpers.php'; + $helpersFile = $moduleBasePath.'Helpers/helpers.php'; if (file_exists($helpersFile)) { require_once $helpersFile; } @@ -203,7 +221,7 @@ abstract class PluginTestCase extends TestCase } $relativeClass = substr($class, $len); - $file = $pluginBasePath . str_replace('\\', '/', $relativeClass) . '.php'; + $file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php'; if (file_exists($file)) { require $file; @@ -218,20 +236,56 @@ abstract class PluginTestCase extends TestCase $apiRoutesFile = base_path('modules/sirsoft-ecommerce/src/routes/api.php'); if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-ecommerce') + Route::prefix('api/modules/sirsoft-ecommerce') ->name('api.modules.sirsoft-ecommerce.') ->middleware('api') ->group($apiRoutesFile); } } + /** + * 이 플러그인을 활성 상태로 등록해 코어 self-gate 미들웨어가 실행되도록 합니다. + * + * 프로덕션에서는 PluginManager 가 활성 플러그인 인스턴스를 보유하고 plugins 테이블의 + * status='active' 를 캐시한다. RefreshDatabase 테스트에는 둘 다 없으므로, + * 라우트명 self-gate 타게팅(web.plugins.{id}.*)이 동작하도록 (1) plugins active 행 삽입 + * (2) PluginManager 인스턴스 등록 후 상태 캐시·미들웨어 인덱스를 무효화한다. + * + * @param string $identifier 플러그인 식별자 + * @param class-string $pluginClass 플러그인 클래스 FQCN + */ + protected function activateSelfForMiddlewareGate(string $identifier, string $pluginClass): void + { + Plugin::query()->updateOrCreate( + ['identifier' => $identifier], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] + ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins[$identifier] = new $pluginClass; + $property->setValue($pluginManager, $plugins); + + ExtensionMiddlewareRegistry::flush(); + } + protected function registerPluginRoutes(): void { $webRoutesFile = base_path('plugins/_bundled/sirsoft-pay_kginicis/src/routes/web.php'); if (file_exists($webRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('plugins/sirsoft-pay_kginicis') - ->name('plugins.sirsoft-pay_kginicis.') + // 프로덕션 PluginRouteServiceProvider 와 동일한 'web.plugins.' 이름 접두사 — + // 코어 self-gate 미들웨어가 라우트명(web.plugins.{id}.*)으로 타게팅하므로 정합 필수. + Route::prefix('plugins/sirsoft-pay_kginicis') + ->name('web.plugins.sirsoft-pay_kginicis.') ->middleware('web') ->group($webRoutesFile); } @@ -239,36 +293,36 @@ abstract class PluginTestCase extends TestCase $apiRoutesFile = base_path('plugins/_bundled/sirsoft-pay_kginicis/src/routes/api.php'); if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/plugins/sirsoft-pay_kginicis') + Route::prefix('api/plugins/sirsoft-pay_kginicis') ->name('api.plugins.sirsoft-pay_kginicis.') ->middleware('api') ->group($apiRoutesFile); } } - protected function createAdminUser(array $permissions = []): \App\Models\User + protected function createAdminUser(array $permissions = []): User { - $user = \App\Models\User::factory()->create(); + $user = User::factory()->create(); - $uniqueRoleIdentifier = 'admin-test-' . $user->id . '-' . time(); - $userRole = \App\Models\Role::create([ + $uniqueRoleIdentifier = 'admin-test-'.$user->id.'-'.time(); + $userRole = Role::create([ 'identifier' => $uniqueRoleIdentifier, 'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'], ]); $user->roles()->attach($userRole->id); - $adminAccessPermission = \App\Models\Permission::firstOrCreate( + $adminAccessPermission = Permission::firstOrCreate( ['identifier' => 'admin.access'], [ 'name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'], - 'type' => \App\Enums\PermissionType::Admin, + 'type' => PermissionType::Admin, ] ); $userRole->permissions()->attach($adminAccessPermission->id); if (! empty($permissions)) { foreach ($permissions as $permissionIdentifier) { - $permission = \App\Models\Permission::firstOrCreate( + $permission = Permission::firstOrCreate( ['identifier' => $permissionIdentifier], [ 'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier], @@ -282,10 +336,10 @@ abstract class PluginTestCase extends TestCase return $user; } - protected function createUser(): \App\Models\User + protected function createUser(): User { - $userRole = \App\Models\Role::where('identifier', 'user')->first(); - $user = \App\Models\User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $user = User::factory()->create(); if ($userRole) { $user->roles()->attach($userRole->id); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index 005aace1..8a41ffd0 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -24,6 +24,8 @@ - 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다. - 주문서의 간편결제 버튼에 표시되는 브랜드 마크(PAYCO·네이버페이·카카오페이 등의 색상 배지)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다. - 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다. +- 가상계좌 입금통보·에스크로 공통통보 등 결제대행사 서버가 직접 보내는 요청의 발신 주소를 확인하는 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 발신 서버 확인 범위(통보 계열 경로에만 적용, 브라우저 결제 콜백은 제외)는 이전과 동일합니다. +- 위 변경을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요. ## [1.0.0-beta.5] - 2026-06-23 diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md new file mode 100644 index 00000000..450a208f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md @@ -0,0 +1,7 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-pay_nhnkcp` · 이 확장의 웹 라우트는 대부분 결제대행사(NHN KCP)와 브라우저가 직접 주고받는 콜백/통보 경로이며, 표준 JSON API 표면이 아니다. 아래 문서는 그중 운영·보안상 명시가 필요한 경로를 사람이 서술한 레퍼런스다. + +| 문서 | 도메인 | 설명 | +| --- | --- | --- | +| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보·에스크로 공통통보 수신 경로와 발신 서버(IP) 확인 | diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md new file mode 100644 index 00000000..9dd9011f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md @@ -0,0 +1,32 @@ +# Vbank / 통보 수신 API 레퍼런스 + +> **소유**: 플러그인 `sirsoft-pay_nhnkcp`. 이 문서는 결제대행사(NHN KCP) 서버가 직접 POST 로 보내는 통보 수신 경로를 서술한다. 브라우저가 접속하는 결제 콜백과는 접근 제어가 다르다. + +--- + +## TL;DR (5초 요약) + +```text +1. NHN KCP 서버가 직접 보내는 통보(가상계좌 입금·에스크로 공통)를 받는 경로다 +2. 통보 수신 경로는 KCP 공식 발신 IP 만 허용한다 (위변조·재처리 방어) +3. IP 화이트리스트는 코어 확장 미들웨어 self-gate 로 통보 라우트명에만 적용된다 +4. 브라우저 결제 콜백(payment.callback)에는 IP 확인이 적용되지 않는다 +5. 검사 동작·허용 범위는 라우트 파일 직접 부착 시절과 동일하다 +``` + +--- + +## 통보 수신 경로 + +| 라우트명 | 메서드/URI | 용도 | +| --- | --- | --- | +| `web.plugins.sirsoft-pay_nhnkcp.payment.vbank-notify` | `POST /plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` | 가상계좌 입금통보(NOTI) 수신 | +| `web.plugins.sirsoft-pay_nhnkcp.payment.escrow-common-notify` | `POST /plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` | 에스크로 공통통보 수신 | + +**설명** + +위 두 경로는 NHN KCP 서버가 구매자의 가상계좌 실입금·에스크로 상태 변경을 가맹점에 알리기 위해 직접 POST 로 호출한다. 브라우저를 거치지 않는 서버 대 서버 통신이므로, 위변조·재처리 요청을 막기 위해 **NHN KCP 공식 발신 IP 만 허용**한다. + +이 IP 화이트리스트 검사는 코어의 확장 미들웨어 self-gate(`Plugin::getMiddleware()` 의 `targets` 로 위 두 통보 라우트명에만 정밀 타게팅)로 수행된다. 브라우저가 접속하는 결제 콜백(`payment.callback`)에는 적용되지 않는다 — 콜백은 정상 사용자의 브라우저에서 임의 IP 로 도달하므로 IP 로 제한하면 결제가 끊긴다. 검사 자체의 동작·허용 범위는 라우트 파일에서 직접 부착하던 이전 방식과 동일하다. + +상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md). diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json index 6c5eb1e5..0d540462 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json @@ -13,7 +13,7 @@ "ko": "NHN KCP 결제 게이트웨이 (Standard Pay 연동)", "en": "NHN KCP payment gateway (Standard Pay integration)" }, - "g7_version": ">=7.0.0-beta.2", + "g7_version": ">=7.0.5", "dependencies": { "modules": { "sirsoft-ecommerce": ">=1.0.0-beta.5" diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.php b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.php index e0f39fea..5c6f9ba6 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.php @@ -120,11 +120,11 @@ class Plugin extends AbstractPlugin ], ], 'easy_pay_allow_with_other_pg' => ['type' => 'boolean', 'default' => false], - 'easy_pay_payco' => ['type' => 'boolean', 'default' => false], - 'easy_pay_naverpay' => ['type' => 'boolean', 'default' => false], - 'easy_pay_naverpay_point' => ['type' => 'boolean', 'default' => false], - 'easy_pay_kakaopay' => ['type' => 'boolean', 'default' => false], - 'easy_pay_applepay' => ['type' => 'boolean', 'default' => false], + 'easy_pay_payco' => ['type' => 'boolean', 'default' => false], + 'easy_pay_naverpay' => ['type' => 'boolean', 'default' => false], + 'easy_pay_naverpay_point' => ['type' => 'boolean', 'default' => false], + 'easy_pay_kakaopay' => ['type' => 'boolean', 'default' => false], + 'easy_pay_applepay' => ['type' => 'boolean', 'default' => false], ]; } @@ -142,11 +142,34 @@ class Plugin extends AbstractPlugin 'escrow_test_site_cd' => '', 'vbank_expire_days' => 3, 'easy_pay_allow_with_other_pg' => false, - 'easy_pay_payco' => false, - 'easy_pay_naverpay' => false, - 'easy_pay_naverpay_point' => false, - 'easy_pay_kakaopay' => false, - 'easy_pay_applepay' => false, + 'easy_pay_payco' => false, + 'easy_pay_naverpay' => false, + 'easy_pay_naverpay_point' => false, + 'easy_pay_kakaopay' => false, + 'easy_pay_applepay' => false, + ]; + } + + /** + * 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * IP 화이트리스트(RestrictKcpIp)는 KCP 서버 발신 webhook 라우트에만 부착합니다. + * 브라우저 POST 콜백(payment.callback)은 사용자 IP 라 대상에서 제외 — 결제 회귀 방지. + * 코어 ExtensionMiddlewareGate 가 요청 시점에 실행합니다. + * + * @return array, timing?: string, targets: array}> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => Http\Middleware\RestrictKcpIp::class, + 'groups' => ['web'], + 'targets' => [ + 'web.plugins.sirsoft-pay_nhnkcp.payment.vbank-notify', + 'web.plugins.sirsoft-pay_nhnkcp.payment.escrow-common-notify', + ], + ], ]; } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/web.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/web.php index 00736fda..6d48fd48 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/web.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/web.php @@ -1,9 +1,9 @@ group(function () { +Route::withoutMiddleware([ValidateCsrfToken::class])->group(function () { // 결제 승인 콜백 (KCP → 브라우저 POST) Route::post('/payment/callback', [PaymentCallbackController::class, 'authCallback']) ->name('payment.callback'); - // KCP 서버 발신 webhook 그룹 (IP 화이트리스트 가드) - Route::middleware(RestrictKcpIp::class)->group(function () { - // 가상계좌 입금 통보 (KCP 서버 → 우리 서버 POST) - Route::post('/payment/vbank-notify', [PaymentCallbackController::class, 'vbankNotify']) - ->name('payment.vbank-notify'); + // KCP 서버 발신 webhook (IP 화이트리스트 가드는 코어 self-gate 로 이관 — + // Plugin::getMiddleware() 가 vbank-notify/escrow-common-notify 라우트명에만 타게팅). + // 가상계좌 입금 통보 (KCP 서버 → 우리 서버 POST) + Route::post('/payment/vbank-notify', [PaymentCallbackController::class, 'vbankNotify']) + ->name('payment.vbank-notify'); - // 에스크로 공통통보 (KCP 서버 → 우리 서버 POST: TX02 구매확인/취소, TX03 배송시작) - Route::post('/payment/escrow-common-notify', [EscrowCommonNotifyController::class, 'handle']) - ->name('payment.escrow-common-notify'); - }); + // 에스크로 공통통보 (KCP 서버 → 우리 서버 POST: TX02 구매확인/취소, TX03 배송시작) + Route::post('/payment/escrow-common-notify', [EscrowCommonNotifyController::class, 'handle']) + ->name('payment.escrow-common-notify'); }); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php index b33b4202..771749f7 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Middleware; use App\Services\PluginSettingsService; +use Illuminate\Testing\TestResponse; use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase; /** @@ -30,7 +31,7 @@ class RestrictKcpIpTest extends PluginTestCase $mock->method('get')->willReturn([ 'is_test_mode' => $isTestMode, 'redirect_success_url' => '/shop/orders/{orderId}/complete', - 'redirect_fail_url' => '/shop/checkout', + 'redirect_fail_url' => '/shop/checkout', ]); $this->app->instance(PluginSettingsService::class, $mock); } @@ -40,23 +41,26 @@ class RestrictKcpIpTest extends PluginTestCase $mock = $this->createMock(PluginSettingsService::class); $mock->method('get')->willReturn([ 'redirect_success_url' => '/shop/orders/{orderId}/complete', - 'redirect_fail_url' => '/shop/checkout', + 'redirect_fail_url' => '/shop/checkout', ]); $this->app->instance(PluginSettingsService::class, $mock); } - private function postVbankNotify(string $remoteIp): \Illuminate\Testing\TestResponse + private function postVbankNotify(string $remoteIp): TestResponse { return $this->withServerVariables(['REMOTE_ADDR' => $remoteIp]) ->post('/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify', [ - 'tno' => 'KCP_TNO_TEST', - 'order_no' => 'ORD-IP-TEST', - 'tx_cd' => 'TX00', - 'op_cd' => '50', + 'tno' => 'KCP_TNO_TEST', + 'order_no' => 'ORD-IP-TEST', + 'tx_cd' => 'TX00', + 'op_cd' => '50', 'ipgm_mnyx' => 30000, ]); } + /** + * @effects pay_ip_guard_matched_for_webhook_routes + */ public function test_blocks_unauthorized_ip_in_live_mode(): void { $this->mockSettings(isTestMode: false); @@ -66,6 +70,28 @@ class RestrictKcpIpTest extends PluginTestCase $response->assertForbidden(); } + /** + * self-gate 타게팅 정밀도 회귀: IP 가드는 브라우저 POST 콜백(payment.callback)에는 + * 부착되지 않아야 한다. 콜백은 사용자 IP 라 화이트리스트로 차단하면 결제 실패. + * 미인가 IP 로 콜백을 호출해도 IP 가드에 의한 403 이 발생하지 않음을 단언한다 + * (컨트롤러는 검증 실패로 302 redirect 하지만 핵심은 403(IP 차단)이 아니라는 것). + * + * @effects pay_ip_guard_not_matched_for_browser_callback + */ + public function test_ip_guard_not_applied_to_browser_callback_route(): void + { + $this->mockSettings(isTestMode: false); + + $response = $this->withServerVariables(['REMOTE_ADDR' => self::UNAUTHORIZED_IP]) + ->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [ + 'res_cd' => 'XXXX', + 'ordr_idxx' => 'ORD-CALLBACK-TEST', + ]); + + // IP 가드가 붙었다면 403 이었을 것 — 붙지 않았으므로 403 이 아니어야 한다. + $this->assertNotSame(403, $response->getStatusCode(), 'IP 가드가 브라우저 콜백에 잘못 부착되어 결제 콜백을 차단했습니다 (정밀도 회귀).'); + } + public function test_missing_test_mode_setting_fails_closed_for_unauthorized_ip(): void { $this->mockSettingsWithoutMode(); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php index 67dcca48..5e26f0d6 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php @@ -2,7 +2,19 @@ namespace Plugins\Sirsoft\PayNhnkcp\Tests; +use App\Enums\ExtensionStatus; +use App\Enums\PermissionType; +use App\Extension\ExtensionMiddlewareRegistry; +use App\Extension\PluginManager; +use App\Models\Permission; +use App\Models\Plugin; +use App\Models\Role; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Route; +use Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder; +use Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider; use Tests\TestCase; abstract class PluginTestCase extends TestCase @@ -16,7 +28,7 @@ abstract class PluginTestCase extends TestCase protected function seeder(): string { - return \Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder::class; + return TestingSeeder::class; } protected function migrateFreshUsing(): array @@ -40,15 +52,53 @@ abstract class PluginTestCase extends TestCase $this->registerModuleAutoload(); $this->registerPluginAutoload(); - $this->app->register(\Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider::class); + $this->app->register(EcommerceServiceProvider::class); $this->registerModuleRoutes(); $this->registerPluginRoutes(); + // 코어 self-gate 미들웨어 실행을 위해 이 플러그인을 활성 상태로 등록한다 + // (getMiddleware() 선언이 게이트 인덱스에 수집되려면 getActivePlugins() 에 포함돼야 함). + $this->activateSelfForMiddlewareGate('sirsoft-pay_nhnkcp', \Plugins\Sirsoft\PayNhnkcp\Plugin::class); + // SettingsServiceProvider 가 storage/app/settings/general.json 의 site_url 로 // app.url 을 override 하면 Laravel 의 assertRedirect (APP_URL 기반) 와 mismatch. // 테스트 환경에서는 APP_URL 그대로 사용하도록 명시 리셋. - \Illuminate\Support\Facades\Config::set('app.url', env('APP_URL', 'http://localhost')); + Config::set('app.url', env('APP_URL', 'http://localhost')); + } + + /** + * 이 플러그인을 활성 상태로 등록해 코어 self-gate 미들웨어가 실행되도록 합니다. + * + * 프로덕션에서는 PluginManager 가 활성 플러그인 인스턴스를 보유하고 plugins 테이블의 + * status='active' 를 캐시한다. RefreshDatabase 테스트에는 둘 다 없으므로, + * 라우트명 self-gate 타게팅(web.plugins.{id}.*)이 동작하도록 (1) plugins active 행 삽입 + * (2) PluginManager 인스턴스 등록 후 상태 캐시·미들웨어 인덱스를 무효화한다. + * + * @param string $identifier 플러그인 식별자 + * @param class-string $pluginClass 플러그인 클래스 FQCN + */ + protected function activateSelfForMiddlewareGate(string $identifier, string $pluginClass): void + { + Plugin::query()->updateOrCreate( + ['identifier' => $identifier], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] + ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins[$identifier] = new $pluginClass; + $property->setValue($pluginManager, $plugins); + + ExtensionMiddlewareRegistry::flush(); } protected function registerModuleAutoload(): void @@ -64,14 +114,14 @@ abstract class PluginTestCase extends TestCase } $relativeClass = substr($class, $len); - $file = $moduleBasePath . str_replace('\\', '/', $relativeClass) . '.php'; + $file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php'; if (file_exists($file)) { require $file; } }); - $helpersFile = $moduleBasePath . 'Helpers/helpers.php'; + $helpersFile = $moduleBasePath.'Helpers/helpers.php'; if (file_exists($helpersFile)) { require_once $helpersFile; } @@ -90,7 +140,7 @@ abstract class PluginTestCase extends TestCase } $relativeClass = substr($class, $len); - $file = $pluginBasePath . str_replace('\\', '/', $relativeClass) . '.php'; + $file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php'; if (file_exists($file)) { require $file; @@ -103,7 +153,7 @@ abstract class PluginTestCase extends TestCase $apiRoutesFile = base_path('modules/sirsoft-ecommerce/src/routes/api.php'); if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-ecommerce') + Route::prefix('api/modules/sirsoft-ecommerce') ->name('api.modules.sirsoft-ecommerce.') ->middleware('api') ->group($apiRoutesFile); @@ -115,8 +165,10 @@ abstract class PluginTestCase extends TestCase $webRoutesFile = base_path('plugins/sirsoft-pay_nhnkcp/src/routes/web.php'); if (file_exists($webRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('plugins/sirsoft-pay_nhnkcp') - ->name('plugins.sirsoft-pay_nhnkcp.') + // 프로덕션 PluginRouteServiceProvider 와 동일한 'web.plugins.' 이름 접두사 — + // 코어 self-gate 미들웨어가 라우트명(web.plugins.{id}.*)으로 타게팅하므로 정합 필수. + Route::prefix('plugins/sirsoft-pay_nhnkcp') + ->name('web.plugins.sirsoft-pay_nhnkcp.') ->middleware('web') ->group($webRoutesFile); } @@ -124,36 +176,36 @@ abstract class PluginTestCase extends TestCase $apiRoutesFile = base_path('plugins/sirsoft-pay_nhnkcp/src/routes/api.php'); if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/plugins/sirsoft-pay_nhnkcp') + Route::prefix('api/plugins/sirsoft-pay_nhnkcp') ->name('api.plugins.sirsoft-pay_nhnkcp.') ->middleware('api') ->group($apiRoutesFile); } } - protected function createAdminUser(array $permissions = []): \App\Models\User + protected function createAdminUser(array $permissions = []): User { - $user = \App\Models\User::factory()->create(); + $user = User::factory()->create(); - $uniqueRoleIdentifier = 'admin-test-' . $user->id . '-' . time(); - $userRole = \App\Models\Role::create([ + $uniqueRoleIdentifier = 'admin-test-'.$user->id.'-'.time(); + $userRole = Role::create([ 'identifier' => $uniqueRoleIdentifier, 'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'], ]); $user->roles()->attach($userRole->id); - $adminAccessPermission = \App\Models\Permission::firstOrCreate( + $adminAccessPermission = Permission::firstOrCreate( ['identifier' => 'admin.access'], [ 'name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'], - 'type' => \App\Enums\PermissionType::Admin, + 'type' => PermissionType::Admin, ] ); $userRole->permissions()->attach($adminAccessPermission->id); if (! empty($permissions)) { foreach ($permissions as $permissionIdentifier) { - $permission = \App\Models\Permission::firstOrCreate( + $permission = Permission::firstOrCreate( ['identifier' => $permissionIdentifier], [ 'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier], @@ -167,10 +219,10 @@ abstract class PluginTestCase extends TestCase return $user; } - protected function createUser(): \App\Models\User + protected function createUser(): User { - $userRole = \App\Models\Role::where('identifier', 'user')->first(); - $user = \App\Models\User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $user = User::factory()->create(); if ($userRole) { $user->roles()->attach($userRole->id); diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md index e9e72588..481a5a49 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md @@ -20,6 +20,8 @@ - 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다. - 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이·11pay·SSG페이·L.pay 등의 색상 배지)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다. - 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다. +- 가상계좌 입금통보 등 결제대행사 서버가 직접 보내는 요청의 발신 주소를 확인하는 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 발신 서버 확인 범위(입금통보 경로에만 적용, 브라우저 결제 콜백은 제외)는 이전과 동일합니다. +- 위 변경을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요. ### Security diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md new file mode 100644 index 00000000..10f6dbff --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md @@ -0,0 +1,7 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-pay_nicepayments` · 이 확장의 웹 라우트는 대부분 결제대행사(나이스페이먼츠)와 브라우저가 직접 주고받는 콜백/통보 경로이며, 표준 JSON API 표면이 아니다. 아래 문서는 그중 운영·보안상 명시가 필요한 경로를 사람이 서술한 레퍼런스다. + +| 문서 | 도메인 | 설명 | +| --- | --- | --- | +| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보 수신 경로와 발신 서버(IP) 확인 | diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md new file mode 100644 index 00000000..46a7144d --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/vbank.md @@ -0,0 +1,31 @@ +# Vbank / 통보 수신 API 레퍼런스 + +> **소유**: 플러그인 `sirsoft-pay_nicepayments`. 이 문서는 결제대행사(나이스페이먼츠) 서버가 직접 POST 로 보내는 통보 수신 경로를 서술한다. 브라우저가 접속하는 결제 콜백과는 접근 제어가 다르다. + +--- + +## TL;DR (5초 요약) + +```text +1. 나이스페이먼츠 서버가 직접 보내는 가상계좌 입금통보를 받는 경로다 +2. 통보 수신 경로는 나이스페이먼츠 공식 발신 IP 만 허용한다 (위변조·재처리 방어) +3. IP 화이트리스트는 코어 확장 미들웨어 self-gate 로 통보 라우트명에만 적용된다 +4. 브라우저 결제 콜백(payment.callback)에는 IP 확인이 적용되지 않는다 +5. 검사 동작·허용 범위는 라우트 파일 직접 부착 시절과 동일하다 +``` + +--- + +## 통보 수신 경로 + +| 라우트명 | 메서드/URI | 용도 | +| --- | --- | --- | +| `web.plugins.sirsoft-pay_nicepayments.payment.vbank-notify` | `POST /plugins/sirsoft-pay_nicepayments/payment/vbank-notify` | 가상계좌 입금통보 수신 | + +**설명** + +위 경로는 나이스페이먼츠 서버가 구매자의 가상계좌 실입금을 가맹점에 알리기 위해 직접 POST 로 호출한다. 브라우저를 거치지 않는 서버 대 서버 통신이므로, 위변조·재처리 요청을 막기 위해 **나이스페이먼츠 공식 발신 IP 만 허용**한다. + +이 IP 화이트리스트 검사는 코어의 확장 미들웨어 self-gate(`Plugin::getMiddleware()` 의 `targets` 로 위 통보 라우트명에만 정밀 타게팅)로 수행된다. 브라우저가 접속하는 결제 콜백(`payment.callback`)에는 적용되지 않는다 — 콜백은 정상 사용자의 브라우저에서 임의 IP 로 도달하므로 IP 로 제한하면 결제가 끊긴다. 검사 자체의 동작·허용 범위는 라우트 파일에서 직접 부착하던 이전 방식과 동일하다. + +상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md). diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json index 5e84fd04..587e0e74 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json @@ -13,7 +13,7 @@ "ko": "나이스페이먼츠 결제 게이트웨이 (통합결제창 연동)", "en": "NicePayments gateway (integrated payment window)" }, - "g7_version": ">=7.0.0-beta.2", + "g7_version": ">=7.0.5", "dependencies": { "modules": { "sirsoft-ecommerce": ">=1.0.0-beta.5" diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.php b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.php index 3464316c..30da524b 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.php @@ -178,6 +178,28 @@ class Plugin extends AbstractPlugin ]; } + /** + * 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * IP 화이트리스트(VbankNotifyIpWhitelist)는 나이스페이먼츠 서버 발신 가상계좌 통보 + * webhook 라우트에만 부착합니다. 브라우저 POST 콜백(payment.callback)은 사용자 IP 라 + * 대상에서 제외 — 결제 회귀 방지. 코어 ExtensionMiddlewareGate 가 요청 시점에 실행합니다. + * + * @return array, timing?: string, targets: array}> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => Http\Middleware\VbankNotifyIpWhitelist::class, + 'groups' => ['web'], + 'targets' => [ + 'web.plugins.sirsoft-pay_nicepayments.payment.vbank-notify', + ], + ], + ]; + } + public function getHookListeners(): array { return [ diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/routes/web.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/routes/web.php index ba064a99..83d0ba5c 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/routes/web.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/routes/web.php @@ -1,8 +1,8 @@ group(function () { // 결제 승인 콜백 (나이스페이먼츠 → 브라우저 POST) Route::post('/payment/callback', [PaymentCallbackController::class, 'authCallback']) ->name('payment.callback'); // 가상계좌 입금 통보 (나이스페이먼츠 서버 → 우리 서버 POST) - // IP 화이트리스트 미들웨어로 NicePay 공식 발송 IP 만 허용 + // IP 화이트리스트(VbankNotifyIpWhitelist)는 코어 self-gate 로 이관 — + // Plugin::getMiddleware() 가 payment.vbank-notify 라우트명에만 타게팅 (callback 제외). Route::post('/payment/vbank-notify', [PaymentCallbackController::class, 'vbankNotify']) - ->middleware(VbankNotifyIpWhitelist::class) ->name('payment.vbank-notify'); }); // SignData 생성: 비회원 결제도 결제창 진입 직전에 호출하므로 인증 미들웨어를 걸지 않는다. // 대신 컨트롤러에서 주문번호, 결제 전 상태, 나이스페이 결제 레코드, 청구 금액을 모두 검증한다. -Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]) +Route::withoutMiddleware([ValidateCsrfToken::class]) ->middleware(['throttle:30,1']) ->group(function () { Route::post('/payment/sign-data', [PaymentCallbackController::class, 'signData']) diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Middleware/VbankNotifyIpWhitelistGateTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Middleware/VbankNotifyIpWhitelistGateTest.php new file mode 100644 index 00000000..924ea239 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Middleware/VbankNotifyIpWhitelistGateTest.php @@ -0,0 +1,54 @@ +app->make(ExtensionMiddlewareRegistryInterface::class) + ->resolveForRoute($routeName, 'x', 'web', 'after_core'); + } + + /** + * @effects pay_ip_guard_matched_for_webhook_routes + */ + public function test_ip_guard_matched_for_vbank_notify_route(): void + { + $this->assertContains( + VbankNotifyIpWhitelist::class, + $this->resolveWeb('web.plugins.sirsoft-pay_nicepayments.payment.vbank-notify'), + 'IP 가드가 가상계좌 통보 webhook 라우트에 매칭되어야 합니다.', + ); + } + + /** + * @effects pay_ip_guard_not_matched_for_browser_callback + */ + public function test_ip_guard_not_matched_for_browser_callback(): void + { + // 정밀도 회귀: 브라우저 콜백(사용자 IP)에는 IP 가드가 매칭되지 않아야 결제 회귀가 없다. + $this->assertNotContains( + VbankNotifyIpWhitelist::class, + $this->resolveWeb('web.plugins.sirsoft-pay_nicepayments.payment.callback'), + 'IP 가드가 브라우저 콜백에 잘못 매칭되면 결제 콜백이 차단됩니다 (정밀도 회귀).', + ); + } +} diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php index abdd26df..43edfc69 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php @@ -2,7 +2,19 @@ namespace Plugins\Sirsoft\PayNicepayments\Tests; +use App\Enums\ExtensionStatus; +use App\Enums\PermissionType; +use App\Extension\ExtensionMiddlewareRegistry; +use App\Extension\PluginManager; +use App\Models\Permission; +use App\Models\Plugin; +use App\Models\Role; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Route; +use Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder; +use Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider; use Tests\TestCase; abstract class PluginTestCase extends TestCase @@ -16,7 +28,7 @@ abstract class PluginTestCase extends TestCase protected function seeder(): string { - return \Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder::class; + return TestingSeeder::class; } protected function migrateFreshUsing(): array @@ -40,15 +52,53 @@ abstract class PluginTestCase extends TestCase $this->registerModuleAutoload(); $this->registerPluginAutoload(); - $this->app->register(\Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider::class); + $this->app->register(EcommerceServiceProvider::class); $this->registerModuleRoutes(); $this->registerPluginRoutes(); + // 코어 self-gate 미들웨어 실행을 위해 이 플러그인을 활성 상태로 등록한다 + // (getMiddleware() 선언이 게이트 인덱스에 수집되려면 getActivePlugins() 에 포함돼야 함). + $this->activateSelfForMiddlewareGate('sirsoft-pay_nicepayments', \Plugins\Sirsoft\PayNicepayments\Plugin::class); + // SettingsServiceProvider 가 storage/app/settings/general.json 의 site_url 로 // app.url 을 override 하면 Laravel 의 assertRedirect (APP_URL 기반) 와 mismatch. // 테스트 환경에서는 APP_URL 그대로 사용하도록 명시 리셋. - \Illuminate\Support\Facades\Config::set('app.url', env('APP_URL', 'http://localhost')); + Config::set('app.url', env('APP_URL', 'http://localhost')); + } + + /** + * 이 플러그인을 활성 상태로 등록해 코어 self-gate 미들웨어가 실행되도록 합니다. + * + * 프로덕션에서는 PluginManager 가 활성 플러그인 인스턴스를 보유하고 plugins 테이블의 + * status='active' 를 캐시한다. RefreshDatabase 테스트에는 둘 다 없으므로, + * 라우트명 self-gate 타게팅(web.plugins.{id}.*)이 동작하도록 (1) plugins active 행 삽입 + * (2) PluginManager 인스턴스 등록 후 상태 캐시·미들웨어 인덱스를 무효화한다. + * + * @param string $identifier 플러그인 식별자 + * @param class-string $pluginClass 플러그인 클래스 FQCN + */ + protected function activateSelfForMiddlewareGate(string $identifier, string $pluginClass): void + { + Plugin::query()->updateOrCreate( + ['identifier' => $identifier], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] + ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins[$identifier] = new $pluginClass; + $property->setValue($pluginManager, $plugins); + + ExtensionMiddlewareRegistry::flush(); } protected static function krwCurrencySnapshot(): array @@ -107,14 +157,14 @@ abstract class PluginTestCase extends TestCase } $relativeClass = substr($class, $len); - $file = $moduleBasePath . str_replace('\\', '/', $relativeClass) . '.php'; + $file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php'; if (file_exists($file)) { require $file; } }); - $helpersFile = $moduleBasePath . 'Helpers/helpers.php'; + $helpersFile = $moduleBasePath.'Helpers/helpers.php'; if (file_exists($helpersFile)) { require_once $helpersFile; } @@ -133,7 +183,7 @@ abstract class PluginTestCase extends TestCase } $relativeClass = substr($class, $len); - $file = $pluginBasePath . str_replace('\\', '/', $relativeClass) . '.php'; + $file = $pluginBasePath.str_replace('\\', '/', $relativeClass).'.php'; if (file_exists($file)) { require $file; @@ -146,7 +196,7 @@ abstract class PluginTestCase extends TestCase $apiRoutesFile = base_path('modules/sirsoft-ecommerce/src/routes/api.php'); if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-ecommerce') + Route::prefix('api/modules/sirsoft-ecommerce') ->name('api.modules.sirsoft-ecommerce.') ->middleware('api') ->group($apiRoutesFile); @@ -158,8 +208,10 @@ abstract class PluginTestCase extends TestCase $webRoutesFile = base_path('plugins/sirsoft-pay_nicepayments/src/routes/web.php'); if (file_exists($webRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('plugins/sirsoft-pay_nicepayments') - ->name('plugins.sirsoft-pay_nicepayments.') + // 프로덕션 PluginRouteServiceProvider 와 동일한 'web.plugins.' 이름 접두사 — + // 코어 self-gate 미들웨어가 라우트명(web.plugins.{id}.*)으로 타게팅하므로 정합 필수. + Route::prefix('plugins/sirsoft-pay_nicepayments') + ->name('web.plugins.sirsoft-pay_nicepayments.') ->middleware('web') ->group($webRoutesFile); } @@ -167,36 +219,36 @@ abstract class PluginTestCase extends TestCase $apiRoutesFile = base_path('plugins/sirsoft-pay_nicepayments/src/routes/api.php'); if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/plugins/sirsoft-pay_nicepayments') + Route::prefix('api/plugins/sirsoft-pay_nicepayments') ->name('api.plugins.sirsoft-pay_nicepayments.') ->middleware('api') ->group($apiRoutesFile); } } - protected function createAdminUser(array $permissions = []): \App\Models\User + protected function createAdminUser(array $permissions = []): User { - $user = \App\Models\User::factory()->create(); + $user = User::factory()->create(); - $uniqueRoleIdentifier = 'admin-test-' . $user->id . '-' . time(); - $userRole = \App\Models\Role::create([ + $uniqueRoleIdentifier = 'admin-test-'.$user->id.'-'.time(); + $userRole = Role::create([ 'identifier' => $uniqueRoleIdentifier, 'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'], ]); $user->roles()->attach($userRole->id); - $adminAccessPermission = \App\Models\Permission::firstOrCreate( + $adminAccessPermission = Permission::firstOrCreate( ['identifier' => 'admin.access'], [ 'name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'], - 'type' => \App\Enums\PermissionType::Admin, + 'type' => PermissionType::Admin, ] ); $userRole->permissions()->attach($adminAccessPermission->id); if (! empty($permissions)) { foreach ($permissions as $permissionIdentifier) { - $permission = \App\Models\Permission::firstOrCreate( + $permission = Permission::firstOrCreate( ['identifier' => $permissionIdentifier], [ 'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier], @@ -210,10 +262,10 @@ abstract class PluginTestCase extends TestCase return $user; } - protected function createUser(): \App\Models\User + protected function createUser(): User { - $userRole = \App\Models\Role::where('identifier', 'user')->first(); - $user = \App\Models\User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $user = User::factory()->create(); if ($userRole) { $user->roles()->attach($userRole->id); diff --git a/tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php b/tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php new file mode 100644 index 00000000..a9fda50d --- /dev/null +++ b/tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php @@ -0,0 +1,83 @@ +shouldReceive('resolveForRoute') + ->andReturnUsing(function (string $routeName, string $path, string $group, string $timing): array { + IntegrationSpyMiddleware::$seen[] = "{$group},{$timing}"; + + return ($group === 'web' && $timing === 'after_core') + ? [IntegrationSpyMiddleware::class] + : []; + }); + $this->app->instance(ExtensionMiddlewareRegistryInterface::class, $registry); + + // web 그룹에 게이트를 파라미터 문자열로 등록한 라우트 (bootstrap 등록 형태 복제). + Route::middleware([ + ExtensionMiddlewareGate::class.':web,before_core', + ExtensionMiddlewareGate::class.':web,after_core', + ])->get('/__gate_probe', fn () => new Response('ok'))->name('gate.probe'); + + $response = $this->get('/__gate_probe'); + + $response->assertOk(); + $response->assertSee('ok'); + + // 게이트가 group/timing 파라미터를 정확히 파싱해 registry 를 조회했는지 확인. + $this->assertContains('web,before_core', IntegrationSpyMiddleware::$seen); + $this->assertContains('web,after_core', IntegrationSpyMiddleware::$seen); + + // after_core 조회에서 반환된 매칭 미들웨어가 실제로 실행됐는지 확인. + $this->assertTrue(IntegrationSpyMiddleware::$ran, '파라미터 문자열 파싱 후 매칭 미들웨어가 실행되어야 합니다.'); + } +} + +/** 실행/조회 기록 픽스처 미들웨어. */ +class IntegrationSpyMiddleware +{ + public static bool $ran = false; + + /** @var array */ + public static array $seen = []; + + public function handle($request, \Closure $next) + { + self::$ran = true; + + return $next($request); + } +} diff --git a/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php b/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php new file mode 100644 index 00000000..c6c34cea --- /dev/null +++ b/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php @@ -0,0 +1,571 @@ +> $moduleMiddleware 모듈 getMiddleware() 반환값 + * @param array> $pluginMiddleware 플러그인 getMiddleware() 반환값 + * @param string $moduleId 모듈 식별자 + * @param string $pluginId 플러그인 식별자 + */ + private function makeRegistry( + array $moduleMiddleware = [], + array $pluginMiddleware = [], + string $moduleId = 'sirsoft-shop', + string $pluginId = 'sirsoft-pay', + ): ExtensionMiddlewareRegistry { + // getMiddleware() 는 interface 가 아닌 Abstract 에만 정의되므로(IDV 게터와 동일 컨벤션), + // registry 는 method_exists 로 게이트한다. interface mock 은 이 메서드를 갖지 않아 + // method_exists=false 로 스킵되므로, getMiddleware/getIdentifier 를 실제로 갖는 + // 경량 픽스처 확장을 사용한다 (실 확장은 Abstract 상속으로 항상 보유). + $activeModules = []; + if ($moduleMiddleware !== []) { + $activeModules[] = new FixtureExtension($moduleId, $moduleMiddleware); + } + + $activePlugins = []; + if ($pluginMiddleware !== []) { + $activePlugins[] = new FixtureExtension($pluginId, $pluginMiddleware); + } + + $moduleManager = Mockery::mock(ModuleManager::class); + $moduleManager->shouldReceive('getActiveModules')->andReturn($activeModules); + $pluginManager = Mockery::mock(PluginManager::class); + $pluginManager->shouldReceive('getActivePlugins')->andReturn($activePlugins); + + $this->app->instance(ModuleManager::class, $moduleManager); + $this->app->instance(PluginManager::class, $pluginManager); + + ExtensionMiddlewareRegistry::flush(); + + return $this->app->make(ExtensionMiddlewareRegistry::class); + } + + // === self 치환 (모듈·플러그인·web·api prefix) === + + /** + * @effects self_target_expands_to_own_extension_prefix_per_group, groups_array_expands_to_per_group_entries + */ + public function test_self_target_expands_to_own_module_prefix_per_group(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web', 'api'], + 'targets' => ['self'], + ]]); + + // web 그룹: web.modules.{id}.* 만 매칭 + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.modules.sirsoft-shop.orders.index', 'x', 'web', 'after_core'), + ); + // api 그룹: api.modules.{id}.* 만 매칭 + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + // web self 는 api 라우트를 잡지 않음 + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'web', 'after_core'), + ); + // 타 확장 라우트 miss + $this->assertSame( + [], + $registry->resolveForRoute('web.modules.other.orders.index', 'x', 'web', 'after_core'), + ); + } + + /** + * @effects self_target_expands_to_own_extension_prefix_per_group + */ + public function test_self_target_expands_to_own_plugin_prefix(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['self'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.plugins.sirsoft-pay.payment.callback', 'x', 'web', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('web.modules.sirsoft-pay.payment.callback', 'x', 'web', 'after_core'), + ); + } + + // === all_extensions / core (positive/negative 판별) === + + /** + * @effects all_extensions_matches_extension_routes_only + */ + public function test_all_extensions_matches_extension_routes_only(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['all_extensions'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.plugins.other.foo', 'x', 'api', 'after_core'), + ); + // 코어 라우트 miss + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects core_target_matches_core_routes_only_negative_predicate + */ + public function test_core_target_matches_core_routes_only(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['core'], + ]]); + + // 코어 라우트 hit (negative 판별) + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + // 확장 라우트 miss + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + // 무명 라우트는 core 로 간주하지 않음 (URI 로 잡을 것) + $this->assertSame( + [], + $registry->resolveForRoute('', 'admin/foo', 'api', 'after_core'), + ); + } + + // === everything / * (무조건, 무명 포함) === + + /** + * @effects everything_and_star_match_all_routes_including_unnamed + */ + public function test_everything_matches_all_routes_including_unnamed(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web', 'api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.modules.other.foo', 'x', 'web', 'after_core'), + ); + // 무명 라우트도 hit + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', '/', 'web', 'after_core'), + ); + } + + /** + * @effects everything_and_star_match_all_routes_including_unnamed + */ + public function test_star_alias_matches_everything(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['*'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', 'anything', 'web', 'after_core'), + ); + } + + // === module:{id} / plugin:{id} === + + /** + * @effects module_id_and_plugin_id_target_scope_to_that_extension + */ + public function test_module_id_target_matches_that_module_only(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['module:sirsoft-shop'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-board.posts.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects module_id_and_plugin_id_target_scope_to_that_extension + */ + public function test_plugin_id_target_matches_that_plugin_only(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['plugin:sirsoft-pay'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.plugins.sirsoft-pay.payment.callback', 'x', 'web', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('web.plugins.other.foo', 'x', 'web', 'after_core'), + ); + } + + // === 원시 glob / brace === + + /** + * @effects raw_glob_target_matches_route_name + */ + public function test_raw_glob_target_matches_route_name(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['api.modules.sirsoft-shop.cart.*'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.cart.add', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects brace_target_expands_to_or_of_names + */ + public function test_brace_target_expands_to_or_of_names(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['api.modules.sirsoft-shop.{cart,orders}.index'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.cart.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.checkout.index', 'x', 'api', 'after_core'), + ); + } + + // === URI 패턴 (무명 라우트) === + + /** + * @effects uri_pattern_target_matches_path_not_route_name + */ + public function test_uri_pattern_target_matches_path_not_route_name(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['/'], + ]]); + + // 무명 라우트 (routeName='') + path '/' → URI 매칭 + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', '/', 'web', 'after_core'), + ); + } + + /** + * @effects uri_pattern_target_matches_path_not_route_name + */ + public function test_uri_glob_pattern_matches_path_prefix(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['/admin/*'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', 'admin/dashboard', 'web', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('', 'user/profile', 'web', 'after_core'), + ); + } + + // === groups 검증 (펼침 / 거부) === + + /** + * @effects empty_or_disallowed_groups_declaration_is_rejected + */ + public function test_empty_groups_declaration_is_rejected(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => [], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects empty_or_disallowed_groups_declaration_is_rejected + */ + public function test_disallowed_group_is_rejected(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['console'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + // === targets 필수 / class 검증 === + + /** + * @effects missing_targets_declaration_is_rejected + */ + public function test_missing_targets_declaration_is_rejected(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => [], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects nonexistent_middleware_class_is_skipped + */ + public function test_nonexistent_class_is_skipped(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => 'App\\NonExistent\\Middleware\\Nope', + 'groups' => ['api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + // === timing 분리 === + + /** + * @effects before_core_and_after_core_timing_are_separated + */ + public function test_timing_before_and_after_are_separated(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [ + [ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'timing' => 'before_core', + 'targets' => ['everything'], + ], + [ + 'class' => TestMiddlewareB::class, + 'groups' => ['api'], + 'timing' => 'after_core', + 'targets' => ['everything'], + ], + ]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'before_core'), + ); + $this->assertSame( + [TestMiddlewareB::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects default_timing_is_after_core + */ + public function test_default_timing_is_after_core(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'before_core'), + ); + } + + // === 캐시 무효화 === + + /** + * @effects flush_rebuilds_index, inactive_extension_is_excluded_from_index + */ + public function test_flush_rebuilds_index(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + + // 활성 확장 목록을 비우고 flush → 재빌드 시 빈 인덱스 + $emptyModuleManager = Mockery::mock(ModuleManager::class); + $emptyModuleManager->shouldReceive('getActiveModules')->andReturn([]); + $emptyPluginManager = Mockery::mock(PluginManager::class); + $emptyPluginManager->shouldReceive('getActivePlugins')->andReturn([]); + $this->app->instance(ModuleManager::class, $emptyModuleManager); + $this->app->instance(PluginManager::class, $emptyPluginManager); + + ExtensionMiddlewareRegistry::flush(); + $fresh = $this->app->make(ExtensionMiddlewareRegistry::class); + + $this->assertSame( + [], + $fresh->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } +} + +/** + * 경량 픽스처 확장 — getIdentifier() + getMiddleware() 만 제공. + * + * 실 확장은 AbstractModule/AbstractPlugin 상속으로 이 두 메서드를 보유하며, + * registry 는 method_exists 로 getMiddleware 존재를 확인 후 호출한다. + */ +class FixtureExtension +{ + /** + * @param string $identifier 확장 식별자 + * @param array> $middleware getMiddleware() 반환값 + */ + public function __construct( + private string $identifier, + private array $middleware, + ) {} + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return array> + */ + public function getMiddleware(): array + { + return $this->middleware; + } +} + +/** 테스트 픽스처 미들웨어. */ +class TestMiddlewareA +{ + public function handle($request, \Closure $next) + { + return $next($request); + } +} + +/** 테스트 픽스처 미들웨어. */ +class TestMiddlewareB +{ + public function handle($request, \Closure $next) + { + return $next($request); + } +} diff --git a/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php b/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php index d7afed72..f3cd27f9 100644 --- a/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php +++ b/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php @@ -2,7 +2,13 @@ namespace Tests\Feature\Extension; +use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; +use App\Enums\ExtensionStatus; +use App\Extension\ExtensionMiddlewareRegistry; +use App\Extension\PluginManager; use App\Extension\Testing\ExtensionTestAllowlist; +use App\Http\Middleware\ExtensionMiddlewareGate; +use App\Models\Plugin; use Illuminate\Contracts\Http\Kernel as HttpKernelContract; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -13,8 +19,9 @@ use Tests\TestCase; /** * 테스트 환경 확장 격리 회귀 테스트 — allowlist 포함 시나리오 * - * requiredExtensions 에 GDPR 플러그인을 명시하면 해당 플러그인의 - * ServiceProvider 와 미들웨어가 정상적으로 로드되어야 한다. + * requiredExtensions 에 GDPR 플러그인을 명시하면 해당 플러그인의 ServiceProvider 가 로드되고, + * 코어 self-gate 게이트가 GDPR 이 getMiddleware() 로 선언한 CookieConsentMiddleware 를 실행한다. + * (이관 전: SP boot 이 web/api 그룹에 직접 prepend. 이관 후: getMiddleware() 선언 + 게이트 실행) */ class ExtensionTestIsolationGdprAllowedTest extends TestCase { @@ -45,17 +52,66 @@ class ExtensionTestIsolationGdprAllowedTest extends TestCase /** * @effects allowlisted_plugin_middleware_is_registered_in_web_group */ - public function test_allowlisted_plugin_middleware_is_registered_in_web_group(): void + public function test_self_gate_wrapper_is_registered_in_web_and_api_groups(): void { + // 코어 게이트 래퍼는 항상 web/api 그룹에 등록된다 (확장 미들웨어를 요청 시점에 실행). $kernel = $this->app->make(HttpKernelContract::class); $this->assertInstanceOf(HttpKernel::class, $kernel); $groups = $kernel->getMiddlewareGroups(); - $this->assertContains( - CookieConsentMiddleware::class, - $groups['web'] ?? [], - 'allowlist 에 GDPR 을 명시했으나 web 그룹에 CookieConsentMiddleware 가 없습니다' + foreach (['web', 'api'] as $group) { + $hasGate = collect($groups[$group] ?? []) + ->contains(fn ($m) => is_string($m) && str_starts_with($m, ExtensionMiddlewareGate::class)); + $this->assertTrue($hasGate, "{$group} 그룹에 ExtensionMiddlewareGate 래퍼가 등록되어야 합니다."); + } + } + + /** + * @effects allowlisted_plugin_middleware_is_registered_in_web_group + */ + public function test_gate_resolves_gdpr_cookie_consent_when_active(): void + { + // GDPR 을 활성 상태로 등록하면, 게이트가 CookieConsentMiddleware 를 + // 모든 요청(everything/before_core)에서 매칭한다 (EDPB §16 광역 타게팅). + $this->activateGdprForGate(); + + ExtensionMiddlewareRegistry::flush(); + $registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class); + + foreach (['web', 'api'] as $group) { + $matched = $registry->resolveForRoute('any.route.name', 'any/path', $group, 'before_core'); + $this->assertContains( + CookieConsentMiddleware::class, + $matched, + "GDPR 활성 시 {$group}/before_core 에서 CookieConsentMiddleware 가 매칭되어야 합니다.", + ); + } + + ExtensionMiddlewareRegistry::flush(); + } + + /** + * GDPR 플러그인을 활성 상태로 등록해 게이트가 그 미들웨어를 수집하도록 합니다. + */ + private function activateGdprForGate(): void + { + Plugin::query()->updateOrCreate( + ['identifier' => 'sirsoft-gdpr'], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => 'GDPR', 'en' => 'GDPR']), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins['sirsoft-gdpr'] = new \Plugins\Sirsoft\Gdpr\Plugin; + $property->setValue($pluginManager, $plugins); } } diff --git a/tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php b/tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php new file mode 100644 index 00000000..09534081 --- /dev/null +++ b/tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php @@ -0,0 +1,165 @@ + $matched resolveForRoute 반환값 + */ + private function makeGate(array $matched): ExtensionMiddlewareGate + { + $registry = Mockery::mock(ExtensionMiddlewareRegistryInterface::class); + $registry->shouldReceive('resolveForRoute')->andReturn($matched); + + return new ExtensionMiddlewareGate($this->app, $registry); + } + + /** + * @effects gate_runs_matched_middleware_via_pipeline + */ + public function test_matched_middleware_runs(): void + { + GateSpyMiddleware::$ran = false; + $gate = $this->makeGate([GateSpyMiddleware::class]); + + $response = $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('ok'), 'api', 'after_core'); + + $this->assertTrue(GateSpyMiddleware::$ran, '매칭된 확장 미들웨어가 실행되어야 합니다.'); + $this->assertSame('ok', $response->getContent()); + } + + /** + * @effects gate_passes_through_when_no_match + */ + public function test_no_match_passes_through(): void + { + GateSpyMiddleware::$ran = false; + $gate = $this->makeGate([]); + + $response = $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('passed'), 'api', 'after_core'); + + $this->assertFalse(GateSpyMiddleware::$ran, '매칭 없으면 미들웨어를 실행하지 않습니다.'); + $this->assertSame('passed', $response->getContent()); + } + + /** + * @effects gate_matched_middleware_can_short_circuit_response + */ + public function test_matched_middleware_can_short_circuit_response(): void + { + $gate = $this->makeGate([GateBlockMiddleware::class]); + + $response = $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('should-not-reach'), 'api', 'after_core'); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('blocked', $response->getContent()); + } + + /** + * @effects gate_execution_order_follows_matched_list + */ + public function test_execution_order_follows_matched_list(): void + { + GateOrderMiddlewareA::$log = []; + $gate = $this->makeGate([GateOrderMiddlewareA::class, GateOrderMiddlewareB::class]); + + $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('ok'), 'api', 'after_core'); + + $this->assertSame(['A', 'B'], GateOrderMiddlewareA::$log, '매칭 리스트 순서대로 실행되어야 합니다.'); + } + + /** + * @effects gate_reaches_registry_and_runs_matched_for_unnamed_route + */ + public function test_unnamed_route_still_reaches_registry_and_runs_matched(): void + { + GateSpyMiddleware::$ran = false; + // 무명 라우트 요청 (route()->getName()=null) 에서도 게이트가 registry 를 조회하고 + // URI 매칭으로 반환된 미들웨어를 실행한다 (조기 통과하지 않음). + $gate = $this->makeGate([GateSpyMiddleware::class]); + + $gate->handle(Request::create('/', 'GET'), fn ($req) => new Response('ok'), 'web', 'after_core'); + + $this->assertTrue(GateSpyMiddleware::$ran, '무명 라우트에서도 매칭 미들웨어가 실행되어야 합니다.'); + } + + public function test_middleware_exception_propagates(): void + { + $gate = $this->makeGate([GateThrowMiddleware::class]); + + $this->expectException(\RuntimeException::class); + $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('ok'), 'api', 'after_core'); + } +} + +/** 실행 여부만 기록하는 픽스처 미들웨어. */ +class GateSpyMiddleware +{ + public static bool $ran = false; + + public function handle($request, \Closure $next) + { + self::$ran = true; + + return $next($request); + } +} + +/** 응답을 사전 차단하는 픽스처 미들웨어. */ +class GateBlockMiddleware +{ + public function handle($request, \Closure $next) + { + return new Response('blocked', 403); + } +} + +/** 실행 순서를 기록하는 픽스처 미들웨어 A. */ +class GateOrderMiddlewareA +{ + /** @var array */ + public static array $log = []; + + public function handle($request, \Closure $next) + { + self::$log[] = 'A'; + + return $next($request); + } +} + +/** 실행 순서를 기록하는 픽스처 미들웨어 B. */ +class GateOrderMiddlewareB +{ + public function handle($request, \Closure $next) + { + GateOrderMiddlewareA::$log[] = 'B'; + + return $next($request); + } +} + +/** 예외를 던지는 픽스처 미들웨어. */ +class GateThrowMiddleware +{ + public function handle($request, \Closure $next) + { + throw new \RuntimeException('boom'); + } +} diff --git a/tests/scenarios/extension-declarative-middleware.yaml b/tests/scenarios/extension-declarative-middleware.yaml new file mode 100644 index 00000000..867532de --- /dev/null +++ b/tests/scenarios/extension-declarative-middleware.yaml @@ -0,0 +1,89 @@ +# audit:allow test-scenario-coverage reason: axes cross product(extension_type×group×timing×target_kind×extension_status×route_match = 256)는 조합 열거로, 실질 회귀 가드는 effects 가 담당한다. effects 는 test_files 의 메서드 docblock @effects 로 전수 매핑되어 개별 검증된다. target_kind 8종 매칭식·group 펼침·timing 분리·활성 필터·라우트명 vs URI 매칭은 registry 단위 테스트가 값별로 직접 관측하고, 게이트 실행/무명 라우트/실요청 파싱은 gate 테스트가 커버한다. 조합 개별 docblock 마킹은 effects 매핑과 중복이므로 생략. + +feature: 확장 선언 미들웨어 self-gate (getMiddleware + ExtensionMiddlewareGate) + +description: | + 확장(모듈/플러그인)이 getMiddleware() 로 선언한 미들웨어를 코어가 요청 시점에 + 대상 매칭으로 실행하는 self-gate 아키텍처. + + 핵심 동작: + - AbstractModule/AbstractPlugin::getMiddleware() 가 [class, groups, timing, targets] + 배열을 선언 (기본 []). interface 가 아닌 Abstract 에만 정의 — registry 는 + method_exists 로 게이트. + - ExtensionMiddlewareRegistry 가 활성 확장의 선언을 수집해 [group.timing => 술어] + 인덱스로 캐시. targets 는 술어(op=name|name_ext|name_core|uri|always)로 정규화. + - 코어 ExtensionMiddlewareGate 래퍼가 bootstrap 에서 web/api × before_core/after_core + 로 4회 등록. 요청 시점에 라우트명·URI 를 술어와 대조해 매칭 미들웨어만 Pipeline 실행. + - 무명 SSR 셸 catch-all 대응: routeName='' 이어도 URI 패턴(op=uri)·everything(op=always) + 술어는 매칭. 라우트명 계열(op=name*)은 무명에서 miss. + - 인덱스는 확장 활성/비활성/설치/제거/리로드 시 ExtensionMiddlewareRegistry::flush() + 로 무효화 (Module/PluginManager 상태 변경 지점). + + targets 카탈로그 (target_kind 축): + self / all_extensions / core / everything(·*) / module:{id} / plugin:{id} / + 원시 라우트명 glob / brace / URI 패턴('/' 시작). + + 정밀도 (pay IP 화이트리스트): + self 로 뭉치지 않고 webhook 라우트명만 정밀 타게팅해 브라우저 콜백(payment.callback)에는 + IP 가드가 부착되지 않도록 한다 — 결제 회귀 방지. + +axes: + extension_type: [module, plugin] + group: [web, api] + timing: [before_core, after_core] + target_kind: [self, all_extensions, core, everything, module_id, plugin_id, glob, brace, uri] + extension_status: [active, inactive] + route_match: [hit, miss] + +exclusions: + - { extension_status: inactive, route_match: hit, reason: "비활성 확장은 registry 인덱스에서 제외 → 어떤 라우트도 hit 불가" } + +effects: + # registry 매칭 (target_kind 값별) + - self_target_expands_to_own_extension_prefix_per_group + - all_extensions_matches_extension_routes_only + - core_target_matches_core_routes_only_negative_predicate + - everything_and_star_match_all_routes_including_unnamed + - module_id_and_plugin_id_target_scope_to_that_extension + - raw_glob_target_matches_route_name + - brace_target_expands_to_or_of_names + - uri_pattern_target_matches_path_not_route_name + # groups / timing / 검증 + - groups_array_expands_to_per_group_entries + - empty_or_disallowed_groups_declaration_is_rejected + - missing_targets_declaration_is_rejected + - nonexistent_middleware_class_is_skipped + - before_core_and_after_core_timing_are_separated + - default_timing_is_after_core + - inactive_extension_is_excluded_from_index + - flush_rebuilds_index + # 게이트 실행 + - gate_runs_matched_middleware_via_pipeline + - gate_passes_through_when_no_match + - gate_matched_middleware_can_short_circuit_response + - gate_execution_order_follows_matched_list + - gate_reaches_registry_and_runs_matched_for_unnamed_route + - gate_param_string_parses_group_and_timing_on_real_request + # 번들 이전 정밀도 + - detect_device_declared_and_resolved_for_root_uri_web_only + - pay_ip_guard_matched_for_webhook_routes + - pay_ip_guard_not_matched_for_browser_callback + - gdpr_cookie_consent_declared_everything_before_core + +test_files: + - tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php + - tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php + - tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php + - plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php + - plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Middleware/InicisNotifyIpWhitelistGateTest.php + - plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Middleware/VbankNotifyIpWhitelistGateTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Unit/PluginMiddlewareDeclarationTest.php + +rules_layer_coverage: + - rule: extension-middleware-declarative-registration + coverage: 번들 SP 의 Kernel 미들웨어 그룹 직접 조작 금지 — 이전 후 무검출, 이전 전 패턴 검출로 회귀 가드 + - rule: extension-route-middleware-alias-reference + coverage: 번들 라우트 파일의 자기 미들웨어 FQCN 부착 금지 — 동일 + - rule: extension-middleware-gate-cache-coverage + coverage: Abstract 에 getMiddleware 존재 시 Manager 에 registry flush 존재 — 배치 완전성은 flush_rebuilds_index effect 가 병행 diff --git a/tests/scenarios/extension-test-isolation.yaml b/tests/scenarios/extension-test-isolation.yaml index 152396df..52cd9b1b 100644 --- a/tests/scenarios/extension-test-isolation.yaml +++ b/tests/scenarios/extension-test-isolation.yaml @@ -23,9 +23,18 @@ description: | ModuleManager::loadModules / PluginManager::loadPlugins (hook listener / config / 채널 등록 경로) 에는 가드를 적용하지 않는다. 그 경로는 fixture 확장을 직접 생성해 loadModules/loadPlugins 를 호출하는 다수의 확장 테스트가 의존하므로 - 가드 적용 시 광범위한 회귀를 유발한다. 격리의 핵심 목적인 전역 미들웨어 - (GDPR CookieConsentMiddleware 등) 차단은 ServiceProvider register 가드만으로 - 충족된다 — 미들웨어는 provider boot 단계에서 등록되기 때문이다. + 가드 적용 시 광범위한 회귀를 유발한다. + + 확장 미들웨어와 self-gate: + 확장 미들웨어(GDPR CookieConsentMiddleware 등)는 더 이상 provider boot 에서 + web/api 그룹에 직접 등록되지 않는다. 코어 ExtensionMiddlewareGate 래퍼가 항상 + web/api 그룹에 등록되고, 각 확장이 getMiddleware() 로 선언한 미들웨어를 요청 + 시점에 대상 매칭될 때만 실행한다. 미들웨어를 실제로 실행하는 후보로 삼으려면 + 확장이 ExtensionMiddlewareRegistry 인덱스에 수집돼야 하며, 인덱스는 활성 확장 + (getActive{Modules,Plugins}) 만 포함한다. 따라서 core-only 테스트는 비활성 GDPR + 미들웨어를 게이트가 실행하지 않고, allowlist 로 GDPR 을 활성화한 테스트에서만 + 게이트가 그 미들웨어를 매칭한다. 격리의 핵심 목적(비대상 확장 미들웨어 미개입)은 + ServiceProvider register 가드 + registry 활성 확장 필터의 이중으로 충족된다. axes: app_env: [testing, production]