Files
Gnuboard7/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php
T
HeuJung c0e0d82459 refactor(extension): 확장 미들웨어 선언적 self-gate 정규화
확장이 SP Kernel 미들웨어 그룹을 직접 조작하거나 라우트 파일에 미들웨어
FQCN 을 직접 부착하던 임시 방식을, 확장이 부착 대상(targets)을 명시 선언하고
코어가 요청 시점에 라우트명·URI 로 매칭해 실행하는 self-gate 로 정규화.
번들 7건(ecommerce/gdpr/pay 3종) 이전, audit 룰 3건·문서·버전 동기화 동반.

부수적으로 generate-skills.cjs 가 재생성 시 신의성실(부수의무) 섹션을 소실
시키던 회귀를 수정 — 스킬별 불릿을 스크립트 SSoT 로 편입해 재생성에도 보존.
2026-07-16 10:22:51 +09:00

572 lines
18 KiB
PHP

<?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);
}
}