refactor(extension): 확장 미들웨어 선언적 self-gate 정규화
확장이 SP Kernel 미들웨어 그룹을 직접 조작하거나 라우트 파일에 미들웨어 FQCN 을 직접 부착하던 임시 방식을, 확장이 부착 대상(targets)을 명시 선언하고 코어가 요청 시점에 라우트명·URI 로 매칭해 실행하는 self-gate 로 정규화. 번들 7건(ecommerce/gdpr/pay 3종) 이전, audit 룰 3건·문서·버전 동기화 동반. 부수적으로 generate-skills.cjs 가 재생성 시 신의성실(부수의무) 섹션을 소실 시키던 회귀를 수정 — 스킬별 불릿을 스크립트 SSoT 로 편입해 재생성에도 보존.
This commit is contained in:
@@ -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 커맨드로 수행
|
||||
```
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다.
|
||||
- 확장 기능이 특정 요청 경로에만 자기 처리 로직을 끼워 넣을 수 있는 표준 연결 방식을 마련했습니다. 각 확장은 자기가 개입할 대상 경로를 명시해 선언하고, 코어가 요청이 들어올 때 대상에 해당하는 확장 로직만 골라 실행합니다. 이를 통해 쿠키 동의 처리나 결제 입금통보의 발신 서버 확인 같은 기능이 필요한 경로에서만 정확히 동작하도록 정리했습니다.
|
||||
|
||||
## [7.0.4] - 2026-07-14
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Extension;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어의 라우트 매칭 인덱스 계약.
|
||||
*
|
||||
* 활성 확장(모듈/플러그인)이 `getMiddleware()` 로 선언한 미들웨어를 수집해,
|
||||
* 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` 술어와 대조하여 매칭되는
|
||||
* 미들웨어 FQCN 목록을 반환합니다. 코어 self-gate 게이트(`ExtensionMiddlewareGate`)가
|
||||
* 이 계약을 사용해 그룹/timing 별로 매칭 미들웨어를 동적 실행합니다.
|
||||
*
|
||||
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
|
||||
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피합니다.
|
||||
* IDV `IdentityPolicyRepository::getRouteScopeIndex()` 미러.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
interface ExtensionMiddlewareRegistryInterface
|
||||
{
|
||||
/**
|
||||
* 현재 요청에 매칭되는 확장 미들웨어 FQCN 목록을 반환합니다.
|
||||
*
|
||||
* 게이트 래퍼가 자기 그룹·timing 을 전달하면, 그 축의 엔트리 중 라우트명 또는
|
||||
* URI 술어가 매칭되는 것을 확장 로드 순서대로 반환합니다.
|
||||
*
|
||||
* @param string $routeName 현재 요청 라우트 이름 (무명 라우트면 빈 문자열)
|
||||
* @param string $path 현재 요청 URI path (선행 슬래시 없음, 무명 라우트 타게팅용)
|
||||
* @param string $group 요청이 속한 그룹 ('web'|'api')
|
||||
* @param string $timing 실행 시점 ('before_core'|'after_core')
|
||||
* @return array<int, class-string> 매칭된 확장 미들웨어 FQCN (선언 순서 = 확장 로드 순서)
|
||||
*/
|
||||
public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array;
|
||||
}
|
||||
@@ -551,6 +551,37 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
||||
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
||||
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
||||
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
||||
* 됩니다 (순수하게 유지).
|
||||
*
|
||||
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
* [
|
||||
* [
|
||||
* '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<string, array{expr: string, label: string|array<string, string>}>
|
||||
* 키별 데이터 경로 메타 — 예:
|
||||
* [
|
||||
* '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<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타
|
||||
* (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<string, array{expr: string, label: string|array<string, string>}>
|
||||
* 점 경로 키별 데이터 경로 메타 (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
|
||||
{
|
||||
|
||||
@@ -508,6 +508,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* 플러그인이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
||||
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
||||
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
||||
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
||||
* 됩니다 (순수하게 유지).
|
||||
*
|
||||
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 플러그인만 오버라이드합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
* [
|
||||
* [
|
||||
* '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(본인인증) 정책 선언을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 활성 확장의 미들웨어 선언을 수집해 라우트 매칭 인덱스를 캐시로 제공합니다.
|
||||
*
|
||||
* IDV `getRouteScopeIndex` 와 달리, targets 가 정확한 이름이 아니라 glob/brace/URI
|
||||
* 패턴이므로 [정확한이름 => 미들웨어] 해시맵이 아니라 [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<string, list<class-string>>
|
||||
*/
|
||||
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<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, 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<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, 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<string>, 2: string, 3: list<string>}|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<array{op: string, arg?: string}>
|
||||
*/
|
||||
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<array{op: string, arg: string}>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<array{0: object, 1: string, 2: string}>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어의 self-gate 실행기.
|
||||
*
|
||||
* 확장이 getMiddleware() 로 선언한 미들웨어를 web/api 그룹에 직접 넣는 대신,
|
||||
* 이 래퍼가 그룹에 등록되어 요청 시점에 현재 라우트 이름·URI 가 각 확장 미들웨어의
|
||||
* targets 패턴에 매칭될 때만 해당 미들웨어를 동적으로 실행한다. 매칭 없으면
|
||||
* 즉시 통과 (코어 EnforceIdentityPolicy 의 라우트명 인덱스 조회 모델과 동일).
|
||||
*
|
||||
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
|
||||
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피한다.
|
||||
*
|
||||
* bootstrap/app.php 에서 web/api × before_core/after_core 로 총 4회 등록된다.
|
||||
* Kernel 상 prepend/append 위치는 등록 시점 매핑이고, 게이트 실행 로직은 timing 을 안다.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
class ExtensionMiddlewareGate
|
||||
{
|
||||
/**
|
||||
* @param Application $app Pipeline 실행용 컨테이너
|
||||
* @param ExtensionMiddlewareRegistryInterface $registry 확장 미들웨어 매칭 인덱스
|
||||
*/
|
||||
public function __construct(
|
||||
protected Application $app,
|
||||
protected ExtensionMiddlewareRegistryInterface $registry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 미들웨어 진입점.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Closure $next 다음 파이프라인
|
||||
* @param string $group 이 래퍼가 등록된 그룹 ('web'|'api')
|
||||
* @param string $timing 이 래퍼 인스턴스가 담당하는 실행 시점 ('before_core'|'after_core')
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $group, string $timing): Response
|
||||
{
|
||||
// 라우트명(무명이면 빈 문자열)과 URI path(항상 존재, 선행 슬래시 없음) 둘 다 전달 —
|
||||
// 무명 SSR 셸 catch-all 은 URI 패턴 target 으로만 매칭되므로 routeName 빈 값에 조기 통과하지 않는다.
|
||||
$routeName = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
@@ -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. 라우트 파일 작성
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.user.shipping-country.show -->
|
||||
|
||||
@@ -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<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
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 변수 메타데이터 정의
|
||||
*
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
+55
-14
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plugins/sirsoft-gdpr",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Gdpr\Tests\Unit;
|
||||
|
||||
use Plugins\Sirsoft\Gdpr\Http\Middleware\CookieConsentMiddleware;
|
||||
use Plugins\Sirsoft\Gdpr\Plugin;
|
||||
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* GDPR 플러그인 미들웨어 선언 계약 테스트.
|
||||
*
|
||||
* CookieConsentMiddleware 를 코어 self-gate 방식(getMiddleware())으로 선언한다.
|
||||
* EDPB Guidelines 2/2023 §16 사전 차단 요건을 위해 web/api 전 응답을 대상으로 하므로
|
||||
* targets=['everything'](광역), timing=before_core(코어 전처리 전) 여야 한다.
|
||||
*/
|
||||
class PluginMiddlewareDeclarationTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* @effects gdpr_cookie_consent_declared_everything_before_core
|
||||
*/
|
||||
public function test_declares_cookie_consent_middleware_via_get_middleware(): void
|
||||
{
|
||||
$plugin = new Plugin;
|
||||
|
||||
$declaration = collect($plugin->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 이어야 합니다.');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · KG이니시스"로 표시됩니다. 간편결제는 KG 이니시스 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화되어 주문서 입력값이 초기화되는 등의 부작용이 사라집니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 로고(네이버페이·삼성페이·카카오페이·L.pay 등)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다.
|
||||
- 가상계좌 입금통보 등 결제대행사 서버가 직접 보내는 요청의 발신 주소를 확인하는 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 발신 서버 확인 범위(입금통보 계열 경로에만 적용, 브라우저 결제 콜백은 제외)는 이전과 동일합니다.
|
||||
- 위 변경을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
|
||||
@@ -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`)에는 적용되지 않는다. 검사 자체의 동작·허용 범위는 이전과 동일하다.
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
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 [
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Http\Middleware\SetTimezone;
|
||||
use App\Http\Middleware\SyncBoostWithDebugMode;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\CbtCallbackController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\CbtCvsNotifyController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\MobileCallbackController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\PaymentCallbackController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\PaymentCloseController;
|
||||
use Plugins\Sirsoft\PayKginicis\Controllers\UserEscrowConfirmController;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Middleware\InicisNotifyIpWhitelist;
|
||||
|
||||
// 에스크로 구매결정: 사용자 인증 필요
|
||||
Route::get('/payment/escrow-confirm/{orderNumber}', [UserEscrowConfirmController::class, 'show'])
|
||||
@@ -21,36 +29,34 @@ Route::get('/payment/escrow-confirm/close', [UserEscrowConfirmController::class,
|
||||
// PC 표준결제창 닫기 (KG 이니시스 closeUrl — 인증 불필요)
|
||||
Route::get('/payment/close', [PaymentCloseController::class, 'show'])
|
||||
->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
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Middleware;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Middleware\InicisNotifyIpWhitelist;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* InicisNotifyIpWhitelist self-gate 타게팅 정밀도 회귀 테스트.
|
||||
*
|
||||
* IP 화이트리스트는 KG 이니시스 서버 발신 webhook 라우트(cvs-notify/vbank-notify/mobile.vbank-notify)
|
||||
* 에만 부착되고, 브라우저 POST 콜백(payment.callback)에는 부착되지 않아야 한다. self 로 뭉치면
|
||||
* 콜백까지 IP 차단돼 결제 실패. 미들웨어 자체가 testing 환경에서 IP 검사를 우회하므로(403 재현 불가),
|
||||
* 게이트 매칭 여부(registry 인덱스)로 타게팅 정밀도를 직접 관측한다.
|
||||
*/
|
||||
class InicisNotifyIpWhitelistGateTest extends PluginTestCase
|
||||
{
|
||||
private function resolveWeb(string $routeName): array
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
return $this->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 가드가 브라우저 콜백에 잘못 매칭되면 결제 콜백이 차단됩니다 (정밀도 회귀).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(PAYCO·네이버페이·카카오페이 등의 색상 배지)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다.
|
||||
- 가상계좌 입금통보·에스크로 공통통보 등 결제대행사 서버가 직접 보내는 요청의 발신 주소를 확인하는 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 발신 서버 확인 범위(통보 계열 경로에만 적용, 브라우저 결제 콜백은 제외)는 이전과 동일합니다.
|
||||
- 위 변경을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요.
|
||||
|
||||
## [1.0.0-beta.5] - 2026-06-23
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# API 레퍼런스 문서 목차
|
||||
|
||||
> **소유**: 플러그인 `sirsoft-pay_nhnkcp` · 이 확장의 웹 라우트는 대부분 결제대행사(NHN KCP)와 브라우저가 직접 주고받는 콜백/통보 경로이며, 표준 JSON API 표면이 아니다. 아래 문서는 그중 운영·보안상 명시가 필요한 경로를 사람이 서술한 레퍼런스다.
|
||||
|
||||
| 문서 | 도메인 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보·에스크로 공통통보 수신 경로와 발신 서버(IP) 확인 |
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
|
||||
@@ -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<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
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',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Controllers\EscrowCommonNotifyController;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentCallbackController;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Http\Middleware\RestrictKcpIp;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -22,19 +22,18 @@ use Plugins\Sirsoft\PayNhnkcp\Http\Middleware\RestrictKcpIp;
|
||||
|
|
||||
*/
|
||||
|
||||
Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class])->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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이·11pay·SSG페이·L.pay 등의 색상 배지)를 화면이 그려진 뒤 덧입히던 방식에서 결제수단 정보로 처음부터 표시하는 방식으로 바꿨습니다. 여러 결제사가 함께 켜져 있을 때 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다.
|
||||
- 가상계좌 입금통보 등 결제대행사 서버가 직접 보내는 요청의 발신 주소를 확인하는 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 발신 서버 확인 범위(입금통보 경로에만 적용, 브라우저 결제 콜백은 제외)는 이전과 동일합니다.
|
||||
- 위 변경을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# API 레퍼런스 문서 목차
|
||||
|
||||
> **소유**: 플러그인 `sirsoft-pay_nicepayments` · 이 확장의 웹 라우트는 대부분 결제대행사(나이스페이먼츠)와 브라우저가 직접 주고받는 콜백/통보 경로이며, 표준 JSON API 표면이 아니다. 아래 문서는 그중 운영·보안상 명시가 필요한 경로를 사람이 서술한 레퍼런스다.
|
||||
|
||||
| 문서 | 도메인 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보 수신 경로와 발신 서버(IP) 확인 |
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
|
||||
@@ -178,6 +178,28 @@ class Plugin extends AbstractPlugin
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* IP 화이트리스트(VbankNotifyIpWhitelist)는 나이스페이먼츠 서버 발신 가상계좌 통보
|
||||
* webhook 라우트에만 부착합니다. 브라우저 POST 콜백(payment.callback)은 사용자 IP 라
|
||||
* 대상에서 제외 — 결제 회귀 방지. 코어 ExtensionMiddlewareGate 가 요청 시점에 실행합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
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 [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\PayNicepayments\Controllers\PaymentCallbackController;
|
||||
use Plugins\Sirsoft\PayNicepayments\Http\Middleware\VbankNotifyIpWhitelist;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -16,22 +16,22 @@ use Plugins\Sirsoft\PayNicepayments\Http\Middleware\VbankNotifyIpWhitelist;
|
||||
|
|
||||
*/
|
||||
|
||||
Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class])
|
||||
Route::withoutMiddleware([ValidateCsrfToken::class])
|
||||
->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'])
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Middleware;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use Plugins\Sirsoft\PayNicepayments\Http\Middleware\VbankNotifyIpWhitelist;
|
||||
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* VbankNotifyIpWhitelist self-gate 타게팅 정밀도 회귀 테스트.
|
||||
*
|
||||
* IP 화이트리스트는 나이스페이먼츠 서버 발신 가상계좌 통보 webhook(payment.vbank-notify)에만
|
||||
* 부착되고, 브라우저 POST 콜백(payment.callback)에는 부착되지 않아야 한다. self 로 뭉치면
|
||||
* 콜백까지 IP 차단돼 결제 실패. 미들웨어 자체가 testing 환경에서 IP 검사를 우회하므로(403 재현 불가),
|
||||
* 게이트 매칭 여부(registry 인덱스)로 타게팅 정밀도를 직접 관측한다.
|
||||
*/
|
||||
class VbankNotifyIpWhitelistGateTest extends PluginTestCase
|
||||
{
|
||||
private function resolveWeb(string $routeName): array
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
return $this->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 가드가 브라우저 콜백에 잘못 매칭되면 결제 콜백이 차단됩니다 (정밀도 회귀).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Extension;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Http\Middleware\ExtensionMiddlewareGate;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 게이트 그룹 등록 파싱 통합 테스트.
|
||||
*
|
||||
* bootstrap/app.php 가 `ExtensionMiddlewareGate::class.':web,after_core'` 형태의
|
||||
* 파라미터+그룹 문자열로 게이트를 등록하는데, 프로젝트 내 이 형태의 선례가 없어
|
||||
* Laravel MiddlewareNameResolver + Pipeline::parsePipeString 이 실제로
|
||||
* handle($req,$next,'web','after_core') 로 파싱하는지 실요청으로 검증한다.
|
||||
*/
|
||||
class ExtensionMiddlewareGateIntegrationTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects gate_param_string_parses_group_and_timing_on_real_request
|
||||
*/
|
||||
public function test_gate_param_string_parses_group_and_timing_on_real_request(): void
|
||||
{
|
||||
IntegrationSpyMiddleware::$seen = [];
|
||||
IntegrationSpyMiddleware::$ran = false;
|
||||
|
||||
// registry 를 mock — after_core/web 조회 시에만 매칭 미들웨어를 반환.
|
||||
$registry = Mockery::mock(ExtensionMiddlewareRegistryInterface::class);
|
||||
$registry->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<int, string> */
|
||||
public static array $seen = [];
|
||||
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
self::$ran = true;
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Extension;
|
||||
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어 인덱스 테스트.
|
||||
*
|
||||
* 활성 확장의 getMiddleware() 선언을 수집해 라우트명·URI 술어로 매칭하는
|
||||
* ExtensionMiddlewareRegistry 를 검증합니다 (target 카탈로그 전값 / groups 펼침 /
|
||||
* timing 분리 / 비활성 제외 / 검증 거부 / 캐시 무효화).
|
||||
*/
|
||||
class ExtensionMiddlewareRegistryTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 모듈/플러그인의 미들웨어 선언으로 registry 를 구성하고 새 인스턴스를 반환합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $moduleMiddleware 모듈 getMiddleware() 반환값
|
||||
* @param array<int, array<string, mixed>> $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<int, array<string, mixed>> $middleware getMiddleware() 반환값
|
||||
*/
|
||||
public function __construct(
|
||||
private string $identifier,
|
||||
private array $middleware,
|
||||
) {}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Http\Middleware;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Http\Middleware\ExtensionMiddlewareGate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 미들웨어 self-gate 실행기 테스트.
|
||||
*
|
||||
* 게이트가 registry 매칭 결과에 따라 확장 미들웨어를 Pipeline 으로 실행하거나
|
||||
* 즉시 통과하는지, 무명 라우트·timing·실행 순서·예외 격리를 검증합니다.
|
||||
*/
|
||||
class ExtensionMiddlewareGateTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* registry 를 mock 하고 지정 매칭 결과를 반환하는 게이트를 만듭니다.
|
||||
*
|
||||
* @param array<int, class-string> $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<int, string> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 가 병행
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user