Files
Gnuboard7/tests/Unit/Support/Routing/DualExtensionRouteTest.php
T
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00

216 lines
9.0 KiB
PHP

<?php
namespace Tests\Unit\Support\Routing;
use App\Support\Routing\DualRouteProxy;
use Illuminate\Http\Request;
use Illuminate\Routing\Route as RoutingRoute;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
/**
* 자산 URL 이중 모드 라우트 등록 가드 (이슈 #486 단위 A).
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서 동적 응답이
* nginx 에 가로채이지 않도록, 모든 확장자 붙은 동적 엔드포인트는 확장자 없는
* 형태를 함께 제공해야 한다. 한쪽이 조용히 누락되면 그 화면만 죽으므로
* "전수 등록" 자체를 테스트로 못박는다.
*/
class DualExtensionRouteTest extends TestCase
{
/**
* 이중 등록이 요구되는 전체 라우트 이름 (계획서 #486 §1 의 23개 + 프로브).
*
* 신규 확장자 엔드포인트를 추가하면 이 목록에도 추가되어야 한다.
*
* 데이터 프로바이더 대신 평면 목록으로 두고 각 테스트가 내부에서 순회한다.
* 프로바이더로 케이스를 분리하면 케이스마다 애플리케이션이 부팅되어
* DB 커넥션이 케이스 수만큼 열리고, 스위트 동시 실행 시 커넥션이 고갈된다.
*
* @return array<int, string>
*/
private static function dualRouteNames(): array
{
return [
// Public — 템플릿
'api.public.templates.routes',
'api.public.templates.config',
'api.public.templates.assets',
'api.public.templates.components',
'api.public.templates.language',
// Public — 레이아웃
'api.public.layouts.preview.serve',
'api.public.layouts.serve',
// Public — 모듈
'api.public.modules.bundle.js',
'api.public.modules.bundle.css',
'api.public.modules.assets',
'api.public.modules.components',
// Public — 플러그인
'api.public.plugins.bundle.js',
'api.public.plugins.bundle.css',
'api.public.plugins.assets',
'api.public.plugins.components',
// Public — 감지 프로브
'api.public.system.asset-probe',
// Admin — 레이아웃 편집기
'api.admin.templates.editor-components',
'api.admin.templates.editor-routes',
'api.admin.templates.editor-spec',
'api.admin.templates.editor-lang',
'api.admin.templates.editor-permission-candidates',
'api.admin.templates.editor-css',
'api.admin.templates.editor-seo-candidates',
'api.admin.templates.editor-broadcast-catalog',
];
}
/**
* 모든 대상 엔드포인트가 두 형태로 등록되어 있어야 한다.
*/
public function test_엔드포인트가_확장자_형태와_확장자_없는_형태로_모두_등록된다(): void
{
foreach (self::dualRouteNames() as $name) {
$extension = Route::getRoutes()->getByName($name);
$extensionless = Route::getRoutes()->getByName($name.DualRouteProxy::EXTENSIONLESS_NAME_SUFFIX);
$this->assertNotNull($extension, "확장자 형태 라우트 미등록: {$name}");
$this->assertNotNull(
$extensionless,
"확장자 없는 형태 라우트 미등록: {$name} — 정적 최적화 블록이 있는 서버에서 이 엔드포인트가 죽는다"
);
}
}
/**
* 두 형태는 동일한 컨트롤러 액션과 미들웨어로 들어가야 한다.
*
* 한쪽에만 permission 미들웨어가 붙으면 확장자 없는 형태가 권한 우회 통로가 된다.
*/
public function test_두_형태의_액션과_미들웨어가_동일하다(): void
{
foreach (self::dualRouteNames() as $name) {
$extension = Route::getRoutes()->getByName($name);
$extensionless = Route::getRoutes()->getByName($name.DualRouteProxy::EXTENSIONLESS_NAME_SUFFIX);
$this->assertSame(
$extension->getActionName(),
$extensionless->getActionName(),
"두 형태의 컨트롤러 액션 불일치: {$name}"
);
$this->assertSame(
$extension->gatherMiddleware(),
$extensionless->gatherMiddleware(),
"두 형태의 미들웨어 불일치: {$name} — 확장자 없는 형태가 권한 가드를 우회할 수 있다"
);
}
}
/**
* 확장자 형태 URI 는 실제로 해당 확장자로 끝나야 한다.
*/
public function test_확장자_형태_주소가_정적_확장자로_끝난다(): void
{
$suffixed = [
'api.public.templates.routes' => '.json',
'api.public.layouts.serve' => '.json',
'api.public.modules.bundle.js' => '.js',
'api.public.plugins.bundle.css' => '.css',
'api.admin.templates.editor-css' => '.css',
'api.public.system.asset-probe' => '.js',
];
foreach ($suffixed as $name => $suffix) {
$uri = Route::getRoutes()->getByName($name)->uri();
$this->assertStringEndsWith($suffix, $uri, "확장자 형태 URI 가 {$suffix} 로 끝나지 않음: {$name}");
}
}
/**
* 확장자 없는 형태 URI 의 마지막 세그먼트에는 정적 확장자가 남아있지 않아야 한다.
*
* 이것이 이번 이슈의 본질이다 — 확장자가 남아있으면 nginx 정적 블록이 그대로 가로챈다.
*/
public function test_확장자_없는_형태_주소에_정적_확장자가_없다(): void
{
foreach (self::dualRouteNames() as $name) {
$uri = Route::getRoutes()
->getByName($name.DualRouteProxy::EXTENSIONLESS_NAME_SUFFIX)
->uri();
$lastSegment = basename($uri);
$this->assertDoesNotMatchRegularExpression(
'/\.(js|mjs|css|json|map|png|jpe?g|svg|webp|gif|ico|woff2?|ttf|otf|eot)$/i',
$lastSegment,
"확장자 없는 형태에 정적 확장자가 남아있음: {$name} → {$uri}"
);
}
}
/**
* `.json` 요청은 확장자 형태 라우트로 매칭되어야 한다 (등록 순서 가드).
*
* `layouts/{templateIdentifier}/{layoutName}` 의 layoutName 정규식은 `.` 를 포함해
* greedy 하므로, 확장자 없는 형태가 먼저 등록되면 `.json` 요청까지 삼킨다.
* 매크로가 확장자 형태를 먼저 등록한다는 불변식을 실제 매칭으로 검증한다.
*/
public function test_json_요청이_확장자_없는_라우트에_삼켜지지_않는다(): void
{
$cases = [
'/api/layouts/sirsoft-basic/home.json' => 'api.public.layouts.serve',
'/api/templates/sirsoft-basic/routes.json' => 'api.public.templates.routes',
'/api/templates/sirsoft-basic/components.json' => 'api.public.templates.components',
'/api/modules/bundle.js' => 'api.public.modules.bundle.js',
];
foreach ($cases as $uri => $expectedName) {
$matched = $this->matchRouteName('GET', $uri);
$this->assertSame(
$expectedName,
$matched,
"{$uri} 가 확장자 형태가 아닌 다른 라우트에 매칭됨 (등록 순서 역전 의심)"
);
}
}
/**
* 확장자 없는 요청은 확장자 없는 라우트로 매칭되어야 한다.
*/
public function test_확장자_없는_요청이_확장자_없는_라우트로_매칭된다(): void
{
$cases = [
'/api/layouts/sirsoft-basic/home' => 'api.public.layouts.serve.extensionless',
'/api/layouts/preview/'.str_repeat('a', 8).'-aaaa-aaaa-aaaa-'.str_repeat('a', 12) => 'api.public.layouts.preview.serve.extensionless',
'/api/templates/sirsoft-basic/routes' => 'api.public.templates.routes.extensionless',
'/api/modules/bundle/js' => 'api.public.modules.bundle.js.extensionless',
'/api/plugins/bundle/css' => 'api.public.plugins.bundle.css.extensionless',
'/api/templates/assets/sirsoft-basic' => 'api.public.templates.assets.extensionless',
'/api/system/asset-probe' => 'api.public.system.asset-probe.extensionless',
];
foreach ($cases as $uri => $expectedName) {
$this->assertSame($expectedName, $this->matchRouteName('GET', $uri), "매칭 불일치: {$uri}");
}
}
/**
* 주어진 요청에 매칭되는 라우트의 이름을 반환합니다.
*
* @param string $method HTTP 메서드
* @param string $uri 요청 URI
* @return string|null 매칭된 라우트 이름 (미매칭 시 null)
*/
private function matchRouteName(string $method, string $uri): ?string
{
$request = Request::create($uri, $method);
/** @var RoutingRoute $route */
$route = Route::getRoutes()->match($request);
return $route->getName();
}
}