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:
HeuJung
2026-07-16 10:22:51 +09:00
parent 8b9bfd0c91
commit c0e0d82459
57 changed files with 2427 additions and 205 deletions
+1
View File
@@ -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 커맨드로 수행
```
+1
View File
@@ -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;
}
+42 -11
View File
@@ -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
{
+31
View File
@@ -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);
}
}
+16
View File
@@ -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();
}
}
+16
View File
@@ -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);
}
}
+5
View File
@@ -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);
}
/**
+13
View File
@@ -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 {
+70
View File
@@ -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
+1
View File
@@ -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 |
#### 동적 권한/역할/메뉴 보존 규칙
+2
View File
@@ -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. 라우트 파일 작성
+2
View File
@@ -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'])
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plugins/sirsoft-gdpr",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -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": {}
+31 -4
View File
@@ -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
@@ -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"
+33 -10
View File
@@ -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'])
@@ -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 가 병행
+12 -3
View File
@@ -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]