Merge pull request from gnuboard:HeuJung/issue631

fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
This commit is contained in:
정정홍
2026-09-03 07:50:59 +09:00
committed by GitHub
31 changed files with 2037 additions and 122 deletions
+17 -1
View File
@@ -380,6 +380,22 @@ pending 은 저장소 B 의 base 가 된다 — `setLocal` 이 `currentSnapshot
전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성".
### 조건부로만 열리는 라우트군의 게이트 (디버그·개발 라우트)
특정 조건에서만 열려야 하는 라우트군은 판정을 핸들러 안이 아니라 그룹 미들웨어에 둔다. 게이트가 핸들러마다 흩어져 있으면 라우트를 추가할 때 함께 적는 것을 잊게 되고, 빠뜨려도 예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이 유일한 증상이다.
| 금지 | 올바른 사용 |
|------|------------|
| 디버그 라우트 핸들러 안에서 `DebugGate::isEnabled()` 로 개별 판정 | `bootstrap/app.php` 의 그룹 래퍼(`Route::middleware(['api', 'debug.gate'])`)가 단일 부착 |
| catch-all 제외 패턴에 예약 프리픽스 누락 (`_boost`·`modules`) | `(?!admin)(?!api)(?!plugins)(?!_boost)(?!modules)` 전수 제외 — shadow 를 보호로 삼지 않는다 |
| 게이트 부착을 행위 테스트(403 이 나오는지)로만 확인 | 라우트군 전체의 `gatherMiddleware()` 에 게이트 별칭이 있는지 단언하는 등록 계약 테스트 + 모집단 가드 |
| 그룹 게이트가 라우트 캐시에도 구워질 것이라 가정 | 캐시 상태에서의 차단도 검증 — 라우트 캐시는 확장 수명주기 지점에서 자동 생성되어 오히려 흔한 상태다 |
| `withRouting(channels: ...)` 로 채널 정의를 로드 | 프로바이더에서 `require routes/channels.php` — `channels:` 인자는 `Broadcast::routes()`(게이트 없는 `/broadcasting/auth`)까지 자동 등록해 킬스위치 우회로를 만든다 |
catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 라우트는 도달 불가라 게이트가 없어도 증상이 없고, 제외 패턴이 한 줄 바뀌는 순간 무방비로 노출된다 — 실제로 `_boost` GET 4종이 그 상태였고, 같은 그룹의 `DELETE clear` 는 shadow 밖이라 운영 환경·`APP_DEBUG=false` 에서 미인증 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128). 등록 계약 축을 행위 테스트로 대체할 수 없는 이유도 같다: 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않는다.
정적 검사가 디버그 라우트 파일의 개별 게이트를 차단하며, 부착·행위·캐시 축과 방송 인증 라우트 단일성은 테스트가 잠근다. 상세: [routing.md](docs/backend/routing.md) "디버그·개발 라우트는 그룹 단위로 게이트한다".
### 목록 컨텍스트 왕복 (list context round-trip)
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
@@ -497,7 +513,7 @@ pending 은 저장소 B 의 base 가 된다 — `setLocal` 이 `currentSnapshot
| 사용자 추가 에셋 URL 을 `ext.cache_version` 으로 무효화 | 파일 서명(수정 시각) — 확장 캐시 버전은 운영자가 파일을 고쳤다고 오르지 않는다 |
| `custom/` 보존을 rename 경로에만 적용 | 교체 **두 경로 모두**(rename · 제자리 동기화 폴백) — 한쪽만 고치면 Windows 잠금 상황에서만 조용히 사라진다 |
동봉 자산은 배포 산출물이므로 `sourceMappingURL` 참조를 남기지 않는다(`.map` 은 gitignore 대상이라 404 가 된다). 인라인 여부는 "없으면 조작 불능인가" 로 가른다 — 아이콘 폰트는 인라인, 글꼴·장식 아이콘은 파일 분리(자산 URL 이 쿼리 형태가 되는 서버에서 CSS 내부 상대 `url()` 이 해석되지 않는 조합이 남는다).
동봉 자산은 배포 산출물이므로 `sourceMappingURL` 참조를 남기지 않는다(`.map` 은 gitignore 대상이라 404 가 된다). 인라인 여부는 "없으면 조작 불능인가" 로 가른다 — 아이콘 폰트는 인라인, 글꼴·장식 아이콘은 파일 분리. 분리한 자산을 CSS 가 상대 경로로 가리켜도 된다: 확장 자산 CSS 는 서빙 시점에 내부 상대 참조가 절대 자산 URL 로 치환된다(`ServesRewritableCssAssets`). 치환이 없으면 쿼리 형태(`?file=`) 서버에서 그 참조가 조용히 404 가 된다.
사용자 추가 에셋(`custom/`)은 **출처에 의존하지 않는 서술자**로 해석하고 `core.assets.custom_assets` 필터 훅을 해석기 끝에 둔다. 소비자(뷰 컴포저·프론트 로더·서빙)가 출처를 보면, 나중에 다른 출처(템플릿 환경설정의 화면 입력 등)가 붙을 때 평행 경로가 생기고 "운영자 CSS 가 어디서 오는가" 의 SSoT 가 둘로 갈린다.
+5
View File
@@ -42,10 +42,15 @@
- 화면 동작으로 외부 라이브러리를 호출하는 기능에 안전장치를 더했습니다. 임의 코드 실행에 쓰일 수 있는 내장 함수 호출과, 호출 결과를 화면 값에 옮길 때 프로그램 내부 구조를 건드리는 이름은 거부됩니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
- 디버그 도구 엔드포인트 전체에 디버그 모드 확인을 적용했습니다. 종전에는 8개 중 3개에만 확인이 있어, 디버그 모드가 꺼진 운영 사이트에서도 로그인 없이 요청하면 저장된 디버그 데이터를 통째로 삭제할 수 있었습니다. 이제 확인은 개별 엔드포인트가 아니라 디버그 도구 전체에 한 번에 걸리므로, 앞으로 추가되는 엔드포인트도 자동으로 보호됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 웹소켓 채널 인증 주소가 로그인 확인 없는 형태로 하나 더 등록되어 있던 문제를 수정했습니다. 화면에서는 쓰이지 않는 주소였지만 「웹소켓 사용 안 함」 설정을 우회할 수 있었습니다. 이제 로그인과 설정을 함께 확인하는 주소 하나만 남으며, 채널별 권한 확인은 종전과 동일하게 동작합니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
### Fixed
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
- 디버그 모드에서도 디버그 도구의 조회 주소(상태·액션 이력·캐시·변경 감지)가 사용자 화면에 가려져 응답하지 못하던 문제를 수정했습니다. 사이트가 미리 예약해 둔 주소는 사용자 화면 처리에서 제외됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다.
- 스타일 안에서 글꼴·이미지를 상대 경로로 가리키는 확장이 있으면, 그 확장의 스타일이 화면에 하나도 적용되지 않던 문제를 수정했습니다. 여러 확장의 스타일을 하나로 합쳐 전달하는 과정에서 그런 확장을 통째로 빼고 있었고, 빠졌다는 사실은 어디에도 표시되지 않았습니다. 이제 빼는 대신 그 경로를 올바른 주소로 바꿔 함께 전달합니다.
- 확장을 활성화할 때 스크립트가 이미 있으면 그 확장의 스타일(CSS)까지 함께 건너뛰던 문제를 수정했습니다. 스타일만 제공하는 확장은 활성화해도 스타일이 적용되지 않았습니다.
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesExtensionBundles;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\ModuleService;
@@ -19,6 +20,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicModuleController extends PublicBaseController
{
use ServesExtensionBundles;
use ServesRewritableCssAssets;
public function __construct(
private readonly ModuleService $moduleService,
@@ -89,7 +91,14 @@ class PublicModuleController extends PublicBaseController
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'modules',
$identifier,
$path,
31536000
);
}
/**
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesExtensionBundles;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\PluginService;
@@ -19,6 +20,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicPluginController extends PublicBaseController
{
use ServesExtensionBundles;
use ServesRewritableCssAssets;
public function __construct(
private readonly PluginService $pluginService,
@@ -89,7 +91,14 @@ class PublicPluginController extends PublicBaseController
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'plugins',
$identifier,
$path,
31536000
);
}
/**
@@ -7,6 +7,7 @@ use App\Enums\ExtensionStatus;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
use App\Models\TemplateLayoutAttachment;
use App\Services\TemplateLayoutAttachmentService;
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicTemplateController extends PublicBaseController
{
use ClearsTemplateCaches;
use ServesRewritableCssAssets;
public function __construct(
private TemplateService $templateService,
@@ -146,8 +148,17 @@ class PublicTemplateController extends PublicBaseController
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함).
// CSS 는 안의 상대 참조를 절대 자산 URL 로 치환해 내보낸다 — 확장자 없는 모드에서
// 상대 해석이 어긋나 글꼴·아이콘이 404 가 되기 때문이다.
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'templates',
$identifier,
$path,
31536000
);
}
/**
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Support\AssetCssUrlRewriter;
use App\Support\AssetUrl;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 확장 자산 서빙에서 CSS 안의 상대 참조를 절대 자산 URL 로 바꿔 내보냅니다.
*
* 배경:
* `general.asset_url_mode` 가 `extensionless` 면 자산 URL 이
* `/api/templates/assets/{id}?file=vendor%2Fx%2Fa.css` 형태가 된다. 브라우저는 CSS 안의
* 상대 `url()` 을 스타일시트 URL 의 **디렉토리** 기준으로 푸는데, 이 형태에서는 디렉토리가
* `/api/templates/assets/` 라서 `./woff2/f.woff2` 가 엉뚱한 곳을 가리킨다. 확장자 모드나
* 정적 게시본에서는 경로 형태라 정상 해석되므로, 어긋남은 이 조합에서만 나타난다.
*
* 증상은 404 하나뿐이다 — 글꼴은 기본 서체로 대체되고 아이콘은 빈칸이 되며, 서버 로그에는
* 정상 요청으로 남는다. 그래서 운영자에게는 원인을 특정할 단서가 없다.
*
* 모드와 무관하게 항상 치환하는 이유:
* 확장자 모드에서도 결과 URL 은 브라우저가 상대 해석으로 얻던 것과 같은 주소다. 모드에
* 따라 치환 여부를 가르면 두 경로가 서로 다른 코드로 갈라져, 정작 깨지는 쪽만 검증에서
* 빠지기 쉽다. 한 경로로 두고 두 모드를 같은 테스트로 잠근다.
*
* @see AssetCssUrlRewriter 치환 규칙(대상·비대상 판정)
*/
trait ServesRewritableCssAssets
{
/**
* 확장 자산 응답을 만듭니다 — CSS 면 상대 참조를 치환해 내보냅니다.
*
* CSS 가 아니면 종전 `fileResponse()` 와 동일하게 동작합니다.
*
* @param string $filePath 실제 파일 절대 경로
* @param string $mimeType MIME 타입
* @param string $extensionType `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $requestedPath 확장 기준 요청 경로 (CSS 상대 참조의 해석 기준)
* @param int $maxAge 캐시 유지 시간 (초)
* @return BinaryFileResponse|Response 자산 응답 (If-None-Match 일치 시 304)
*/
protected function rewritableAssetResponse(
string $filePath,
string $mimeType,
string $extensionType,
string $identifier,
string $requestedPath,
int $maxAge = 31536000
): BinaryFileResponse|Response {
if (! $this->isCssAsset($mimeType, $requestedPath)) {
return $this->fileResponse($filePath, $mimeType, $maxAge);
}
$css = @file_get_contents($filePath);
// 읽기에 실패하면 치환을 포기하고 원본을 그대로 서빙한다 — 치환은 편의 장치이므로
// 그 실패가 자산 자체를 못 내보내는 사유가 되어서는 안 된다.
if ($css === false) {
return $this->fileResponse($filePath, $mimeType, $maxAge);
}
// 서브리소스에는 CSS 자신이 받은 캐시 버전을 그대로 승계한다. 버전이 오르면 CSS URL
// 이 바뀌어 재요청되고, 그 안의 서브리소스 URL 도 같은 버전을 달고 나가므로 두 계층의
// 무효화 시점이 어긋나지 않는다.
$version = request()->query('v');
$version = is_string($version) && $version !== '' ? $version : null;
$rewritten = AssetCssUrlRewriter::rewrite(
$css,
$requestedPath,
static fn (string $path): string => AssetUrl::extensionApiAsset($extensionType, $identifier, $path, $version)
);
// ETag 는 **내보내는 본문** 기준이어야 한다. 파일 stat 기준으로 잡으면 URL 모드가
// 바뀌어 본문이 달라져도 같은 ETag 가 나와 브라우저가 옛 본문을 계속 쓴다.
$etag = md5($rewritten);
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}, immutable"
: 'no-cache';
return response($rewritten, 200, [
'Content-Type' => $mimeType,
'Expires' => gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT',
'ETag' => $etag,
'Cache-Control' => $cacheControl,
]);
}
/**
* 이 자산이 CSS 인지 판정합니다.
*
* MIME 과 확장자를 함께 본다 — 서빙 계층이 돌려주는 MIME 은 환경에 따라
* `text/plain` 으로 떨어질 수 있고, 그때 확장자가 유일한 단서다.
*
* @param string $mimeType MIME 타입
* @param string $path 확장 기준 요청 경로
* @return bool CSS 여부
*/
private function isCssAsset(string $mimeType, string $path): bool
{
if (str_contains(strtolower($mimeType), 'css')) {
return true;
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'css';
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use App\Support\DevTools\DebugGate;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 디버그 모드가 꺼져 있으면 요청을 403 으로 차단합니다 (DevTools 엔드포인트 게이트).
*
* 왜 미들웨어인가:
* 이 게이트는 원래 `routes/devtools.php` 의 **핸들러 안**에 `if (! DebugGate::isEnabled())`
* 블록으로 들어 있었다. 그러다 보니 8개 라우트 중 POST 3종에만 붙고 GET 4종·DELETE 1종은
* 빠졌다 — 라우트를 추가할 때 게이트를 함께 적는 것을 잊으면 그 라우트만 조용히 열린다.
* 실제로 `DELETE /_boost/g7-debug/clear` 는 production·`APP_DEBUG=false` 에서도 미인증
* 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128).
*
* 게이트가 하나라도 빠지면 예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이
* 유일한 증상이다. 그래서 판정을 그룹 미들웨어 **단일 지점**으로 올려 라우트 추가가 게이트
* 부착과 분리될 수 없게 만든다. 부착 지점은 `bootstrap/app.php` 의 devtools 래퍼 하나다.
*
* 판정 SSoT 는 `DebugGate::isEnabled()` 를 그대로 쓴다 (`config('app.debug')` 또는 관리자
* 환경설정 `debug.mode`). 응답 형태도 종전 핸들러 블록과 동일하게 유지한다 — 이미 배포된
* 브라우저 인젝션 스크립트와 MCP 도구가 이 shape 을 읽는다.
*/
class EnsureDebugMode
{
/**
* 요청을 처리합니다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response HTTP 응답 (디버그 모드 OFF 시 403 JSON)
*/
public function handle(Request $request, Closure $next): Response
{
if (! DebugGate::isEnabled()) {
return response()->json([
'status' => 'error',
'message' => __('devtools.debug_disabled'),
], 403);
}
return $next($request);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
/**
* 브로드캐스트 채널 인가 정의를 로드합니다 (`routes/channels.php`).
*
* 왜 프레임워크 기본 경로를 쓰지 않는가:
* `bootstrap/app.php` 의 `withRouting(channels: ...)` 인자는 두 가지를 한꺼번에 한다 —
* ① `routes/channels.php` 로드(원하는 것) ② `Broadcast::routes()` 자동 호출(원치 않는 것).
* ②가 등록하는 `GET|POST /broadcasting/auth` 에는 어떤 게이트도 없어서, 웹소켓 사용 OFF
* (`broadcasting.default === 'null'`, 공개#50) 킬스위치를 통째로 우회하는 경로가 된다.
* 프론트(`WebSocketManager`)는 게이트된 `/api/broadcasting/auth` 만 호출하므로 그 라우트는
* 死라우트이면서 우회로이기만 했다 — production 에서 미인증 POST 가 200 을 받았다(공개#128).
*
* 그래서 `channels:` 인자를 떼어 ②의 자동 등록을 끊고, ①만 이 프로바이더가 담당한다.
* 인증 엔드포인트의 SSoT 는 `routes/api.php` 의 `api.broadcasting.auth` 하나다
* (`auth:sanctum` + 킬스위치 가드).
*
* 라우트 캐시 안전:
* `Broadcast::channel()` 은 라우트가 아니라 브로드캐스터의 인가 콜백 등록이라 라우트 캐시와
* 무관하다. 매 부팅마다 실행되어야 하며, 누락되면 예외 없이 **모든 private 채널 구독이 403**
* 이 된다(콜백이 없는 채널은 거부되므로).
*/
class BroadcastServiceProvider extends ServiceProvider
{
/**
* 채널 인가 정의를 등록합니다.
*
* @return void
*/
public function boot(): void
{
$channels = base_path('routes/channels.php');
if (is_file($channels)) {
require $channels;
}
}
}
+37 -51
View File
@@ -7,6 +7,7 @@ use App\Extension\PluginManager;
use App\Extension\Storage\CoreStorageDriver;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\TemplateComposer;
use App\Support\AssetCssUrlRewriter;
use App\Support\AssetUrl;
use Illuminate\Support\Facades\Log;
@@ -64,9 +65,12 @@ class ExtensionBundleService
* 필터/정렬을 쓰도록 하는 SSoT. 순서 제어는 오직 manifest
* `loading.priority` 숫자 오름차순뿐이며 특정 확장 이름 하드코딩은 없다(제약 1).
*
* `cssRelPath` 는 확장 루트 기준 상대 경로다 — 병합 시 CSS 안의 상대 참조를 그 CSS 가
* 놓인 위치 기준으로 풀어야 하는데, 절대 경로만으로는 확장 루트를 되짚을 수 없다.
*
* @param string $type 'module' | 'plugin'
* @return array<string, array{jsAbsPath: ?string, cssAbsPath: ?string, priority: int}>
* identifier => 절대경로/우선순위 (priority 오름차순 정렬)
* @return array<string, array{jsAbsPath: ?string, cssAbsPath: ?string, cssRelPath: ?string, priority: int}>
* identifier => 절대경로/상대경로/우선순위 (priority 오름차순 정렬)
*/
public function getOrderedGlobalAssetPaths(string $type): array
{
@@ -98,9 +102,14 @@ class ExtensionBundleService
continue;
}
// CSS 안의 상대 참조를 풀려면 그 CSS 가 확장 안에서 **어디에 놓였는지**가 필요하다.
// 절대 경로만으로는 확장 루트를 되짚을 수 없으므로 선언된 상대 경로를 함께 싣는다.
$cssRelPath = $extension->getBuiltAssetPaths()['css'] ?? null;
$ordered[$extension->getIdentifier()] = [
'jsAbsPath' => $jsAbsPath,
'cssAbsPath' => $cssAbsPath,
'cssRelPath' => $cssRelPath,
'priority' => (int) ($loadingConfig['priority'] ?? 100),
];
}
@@ -164,9 +173,12 @@ class ExtensionBundleService
/**
* 확장 타입의 CSS 번들 문자열을 생성합니다.
*
* priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. 상대경로
* `url(...)` 참조가 있는 CSS 는 병합 시 경로가 깨지므로 번들에서 제외하고
* 경고 로그를 남긴다(안전장치 — 현재 번들 CSS 는 url() 0건).
* priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. CSS 안의 상대
* `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 — 병합본의 주소는
* 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다.
*
* 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을
* 쓴다. 두 경로가 서로 다른 코드로 갈라지면 한쪽만 고쳐진 채 남는다.
*
* @param string $type 'module' | 'plugin'
* @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열)
@@ -175,6 +187,8 @@ class ExtensionBundleService
{
$ordered = $this->getOrderedGlobalAssetPaths($type);
$isProduction = app()->environment('production');
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
$version = $this->getCurrentVersion();
$segments = [];
foreach ($ordered as $identifier => $paths) {
@@ -195,16 +209,24 @@ class ExtensionBundleService
continue;
}
// 상대경로 url() 참조가 있으면 병합 시 폰트/이미지 경로가 깨진다.
// 절대/data URI 는 안전하므로 상대경로만 검출해 해당 CSS 제외.
if ($this->hasRelativeUrl($content)) {
Log::warning('확장 CSS 에 상대경로 url() 존재 — 번들에서 제외(개별 폴백 유지)', [
'type' => $type,
'identifier' => $identifier,
]);
continue;
}
// 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적
// 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데,
// 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다.
//
// 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이
// 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets)
// 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던
// "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다.
$content = AssetCssUrlRewriter::rewrite(
$content,
(string) ($paths['cssRelPath'] ?? ''),
fn (string $path): string => AssetUrl::extensionApiAsset(
$typeSegment,
$identifier,
$path,
$version
)
);
$segments[] = $this->processCssSourceMap($content, $isProduction);
} catch (\Throwable $e) {
@@ -581,42 +603,6 @@ class ExtensionBundleService
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $content) ?? $content;
}
/**
* CSS 내용에 상대경로 url() 참조가 있는지 확인합니다.
*
* 절대 URL(http/https), 루트 절대경로(/), data URI 는 병합에 안전하므로
* 그 외의 url() 참조만 상대경로로 간주한다.
*
* @param string $css CSS 내용
* @return bool 상대경로 url() 이 하나라도 있으면 true
*/
private function hasRelativeUrl(string $css): bool
{
if (! preg_match_all('/url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $css, $matches)) {
return false;
}
foreach ($matches[1] as $url) {
$url = trim($url);
if ($url === '') {
continue;
}
$isAbsolute = str_starts_with($url, 'http://')
|| str_starts_with($url, 'https://')
|| str_starts_with($url, '//')
|| str_starts_with($url, '/')
|| str_starts_with($url, 'data:');
if (! $isAbsolute) {
return true;
}
}
return false;
}
/**
* 번들 디스크용 스토리지 드라이버를 반환합니다(StorageInterface 경유).
*
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Support;
/**
* 확장 자산으로 서빙되는 CSS 안의 **상대 경로 참조**를 절대 자산 URL 로 바꿉니다.
*
* 왜 필요한가:
* 자산 URL 은 두 모드로 나간다 (`general.asset_url_mode`).
* - `extension` → `/api/templates/assets/{id}/vendor/x/1.0/a.css`
* - `extensionless` → `/api/templates/assets/{id}?file=vendor%2Fx%2F1.0%2Fa.css`
*
* 브라우저는 CSS 안의 상대 `url()` 을 **그 스타일시트 URL의 디렉토리** 기준으로 푼다.
* 확장자 모드에서는 디렉토리가 `.../vendor/x/1.0/` 이라 `./woff2/f.woff2` 가 제대로 풀리지만,
* 확장자 없는 모드에서는 경로의 마지막 세그먼트가 식별자(`{id}`)이고 파일명은 쿼리에 있으므로
* 디렉토리가 `/api/templates/assets/` 로 잡힌다 — `./woff2/f.woff2` 가
* `/api/templates/assets/woff2/f.woff2` 라는 존재하지 않는 주소가 된다.
*
* 이 실패는 서버 로그에 아무 흔적을 남기지 않는다. 요청은 정상 404 이고, 화면은 글꼴이
* 기본 서체로 대체되거나 아이콘이 빈칸으로 보일 뿐이라 운영자가 원인을 특정할 단서가 없다.
* 실제로 사용자 템플릿의 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다.
*
* 왜 서빙 시점인가:
* 최종 URL 은 런타임 모드와 캐시 버전이 정한다 — 빌드 시점에는 알 수 없다. 그리고 동봉
* 자산은 제3자 산출물이라 원본을 손대면 상류 갱신 때마다 재작업이 된다. 그래서 원본은
* 상대 경로 그대로 두고, 내보내는 순간에만 해석한다.
*
* 대상이 아닌 것:
* 절대 URL(`https://`, `//`), 루트 상대(`/`), `data:`/`about:` 등 스킴 참조, 빈 참조.
* 정적 게시본(`/build/ext/{v}/...`)은 웹서버가 직접 서빙하고 경로 형태라 상대 해석이
* 정상이므로 이 경로를 타지 않는다.
*/
class AssetCssUrlRewriter
{
/** `url(...)` 참조 — 따옴표 3종(없음/홑/겹)을 모두 받는다 */
private const URL_RE = '/\burl\(\s*(["\']?)(.*?)\1\s*\)/s';
/** `@import "..."` / `@import \'...\'` (url() 없이 문자열만 오는 형태) */
private const IMPORT_RE = '/@import\s+(["\'])(.*?)\1/s';
/**
* CSS 안의 상대 참조를 절대 자산 URL 로 치환합니다.
*
* @param string $css 원본 CSS
* @param string $cssPath 확장 기준 CSS 경로 (서빙 요청에 쓰인 것과 같은 좌표계)
* @param callable(string): string $urlFor 확장 기준 경로 → 절대 자산 URL 변환기
* @return string 치환된 CSS
*/
public static function rewrite(string $css, string $cssPath, callable $urlFor): string
{
$baseDir = self::baseDirectory($cssPath);
$replace = function (array $m) use ($baseDir, $urlFor): string {
$quote = $m[1];
$ref = trim($m[2]);
$resolved = self::resolve($ref, $baseDir);
if ($resolved === null) {
return $m[0];
}
[$path, $fragment] = $resolved;
$url = $urlFor($path).$fragment;
// 따옴표가 없던 참조도 겹따옴표로 감싼다 — 생성된 URL 은 `?`·`&` 를 포함할 수
// 있는데, 따옴표 없는 url() 토큰에서 그 문자들은 CSS 문법상 허용되지 않는다.
$quote = $quote !== '' ? $quote : '"';
return str_starts_with($m[0], '@import')
? '@import '.$quote.$url.$quote
: 'url('.$quote.$url.$quote.')';
};
$css = preg_replace_callback(self::URL_RE, $replace, $css) ?? $css;
return preg_replace_callback(self::IMPORT_RE, $replace, $css) ?? $css;
}
/**
* 참조가 상대 경로인지 판정하고, 확장 기준 절대 경로로 해석합니다.
*
* @param string $ref CSS 안의 원본 참조
* @param array<int, string> $baseDir CSS 가 놓인 디렉토리 세그먼트
* @return array{0: string, 1: string}|null `[확장 기준 경로, 프래그먼트]` 또는 대상 아님이면 null
*/
private static function resolve(string $ref, array $baseDir): ?array
{
if ($ref === '') {
return null;
}
// 루트 상대(`/x`) · 프로토콜 상대(`//host/x`) · 스킴 참조(`https:`, `data:`, `#`) 는 그대로 둔다.
if ($ref[0] === '/' || $ref[0] === '#' || preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $ref) === 1) {
return null;
}
// 프래그먼트는 보존하고(레거시 `#iefix` 등), 참조 자신의 쿼리는 버린다 —
// 생성되는 자산 URL 이 자기 캐시 버전 쿼리를 갖는다.
$fragment = '';
if (($hash = strpos($ref, '#')) !== false) {
$fragment = substr($ref, $hash);
$ref = substr($ref, 0, $hash);
}
if (($q = strpos($ref, '?')) !== false) {
$ref = substr($ref, 0, $q);
}
if ($ref === '') {
return null;
}
$segments = $baseDir;
foreach (explode('/', $ref) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}
if ($segments === []) {
return null;
}
return [implode('/', $segments), $fragment];
}
/**
* CSS 경로가 놓인 디렉토리 세그먼트를 구합니다.
*
* @param string $cssPath 확장 기준 CSS 경로
* @return array<int, string> 디렉토리 세그먼트 (루트면 빈 배열)
*/
private static function baseDirectory(string $cssPath): array
{
$parts = explode('/', trim(str_replace('\\', '/', $cssPath), '/'));
array_pop($parts);
return array_values(array_filter($parts, static fn (string $p): bool => $p !== '' && $p !== '.'));
}
}
+19
View File
@@ -397,6 +397,25 @@ class AssetUrl
return $url.self::versionQuery($version);
}
/**
* 확장 자산의 **API 서빙 URL** 을 생성합니다 (정적 게시본 분기 없음).
*
* 정적 게시본을 건너뛰는 이유: 이 메서드의 호출자는 CSS 서빙 컨트롤러다. 게시본이
* 활성이면 그 CSS 자체가 웹서버에서 경로 형태로 나가 컨트롤러에 도달하지 않으므로,
* 여기 도달했다는 것은 이 요청에 게시본이 적용되지 않았다는 뜻이다. 한 스타일시트
* 안에서 서빙 경로가 갈리지 않도록 API 형태로 통일한다.
*
* @param string $type `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $path 확장 기준 파일 경로
* @param int|string|null $version 캐시 무효화 버전
* @return string 생성된 URL (현재 모드 반영)
*/
public static function extensionApiAsset(string $type, string $identifier, string $path, int|string|null $version = null): string
{
return self::asset($type, $identifier, $path, $version);
}
/**
* 확장 자산 URL 을 생성하는 공통 구현.
*
+11 -3
View File
@@ -11,6 +11,7 @@ use App\Http\Middleware\CheckTemplateDependencies;
use App\Http\Middleware\CheckUserStatus;
use App\Http\Middleware\DatabaseCredentialGuard;
use App\Http\Middleware\EnforceIdentityPolicy;
use App\Http\Middleware\EnsureDebugMode;
use App\Http\Middleware\ExtensionMiddlewareGate;
use App\Http\Middleware\GzipEncodeResponse;
use App\Http\Middleware\MaintenanceModePage;
@@ -69,11 +70,17 @@ $app = Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
then: function () {
// DevTools 라우트 (디버그 모드에서만 활성화)
Route::middleware('api')
// DevTools 라우트 — 디버그 모드 게이트를 **그룹 단위**로 건다.
//
// 이 한 줄이 `routes/devtools.php` 의 8개 라우트(GET·POST·DELETE) 전부를 덮는다.
// 게이트를 핸들러 안에 흩어 두면 라우트를 추가할 때마다 함께 적어야 하고, 빠뜨려도
// 예외·로그가 남지 않아 그 라우트만 조용히 열린다(공개#128 — DELETE clear 가
// production 에서 미인증 200 으로 storage/debug-dump 를 지웠다).
//
// `api` 그룹 전체가 아니라 devtools 래퍼에만 붙이는 것이 유일한 안전 지점이다.
Route::middleware(['api', 'debug.gate'])
->group(base_path('routes/devtools.php'));
},
)
@@ -159,6 +166,7 @@ $app = Application::configure(basePath: dirname(__DIR__))
'seo' => SeoMiddleware::class,
'identity.policy' => EnforceIdentityPolicy::class,
'extension.middleware' => ExtensionMiddlewareGate::class,
'debug.gate' => EnsureDebugMode::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+33 -15
View File
@@ -1,19 +1,37 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\AuthServiceProvider;
use App\Providers\BladeServiceProvider;
use App\Providers\BroadcastServiceProvider;
use App\Providers\CoreServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\InstallerRuntimeServiceProvider;
use App\Providers\LanguagePackServiceProvider;
use App\Providers\ModuleRouteServiceProvider;
use App\Providers\ModuleServiceProvider;
use App\Providers\PluginRouteServiceProvider;
use App\Providers\PluginServiceProvider;
use App\Providers\ScoutServiceProvider;
use App\Providers\SettingsServiceProvider;
use App\Providers\TranslationServiceProvider;
use App\Seo\SeoServiceProvider;
return [
App\Providers\InstallerRuntimeServiceProvider::class, // 설치 진행 중 runtime.php 로 동적 설정 주입 (.env 무수정)
App\Providers\SettingsServiceProvider::class, // DB 연결 전 JSON 설정 로드
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\BladeServiceProvider::class,
App\Providers\CoreServiceProvider::class,
App\Providers\ModuleServiceProvider::class,
App\Providers\PluginServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\ModuleRouteServiceProvider::class,
App\Providers\PluginRouteServiceProvider::class,
App\Providers\TranslationServiceProvider::class,
App\Providers\LanguagePackServiceProvider::class,
App\Providers\ScoutServiceProvider::class,
App\Seo\SeoServiceProvider::class,
InstallerRuntimeServiceProvider::class, // 설치 진행 중 runtime.php 로 동적 설정 주입 (.env 무수정)
SettingsServiceProvider::class, // DB 연결 전 JSON 설정 로드
AppServiceProvider::class,
AuthServiceProvider::class,
BroadcastServiceProvider::class, // routes/channels.php 채널 인가 정의 로드 (Broadcast::routes() 자동 등록은 배제)
BladeServiceProvider::class,
CoreServiceProvider::class,
ModuleServiceProvider::class,
PluginServiceProvider::class,
EventServiceProvider::class,
ModuleRouteServiceProvider::class,
PluginRouteServiceProvider::class,
TranslationServiceProvider::class,
LanguagePackServiceProvider::class,
ScoutServiceProvider::class,
SeoServiceProvider::class,
];
+19
View File
@@ -162,6 +162,25 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
두 형태는 **모두 영구 유지**됩니다. 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨집니다.
#### CSS 응답의 상대 참조 치환
확장 자산 엔드포인트(`/api/{templates|modules|plugins}/assets/…`)가 **CSS 를 서빙할 때**는 본문 안의
상대 참조를 절대 자산 URL 로 바꿔 내보냅니다. 대상은 `url(...)` 과 `@import "…"` 이며, 절대 URL ·
프로토콜 상대(`//host/x`) · `data:` 같은 스킴 참조는 원문 그대로 둡니다.
브라우저는 CSS 안의 상대 참조를 그 스타일시트 URL 의 **디렉토리** 기준으로 해석합니다. 확장자 없는
형태에서는 경로의 마지막 세그먼트가 확장 식별자이고 파일 경로가 쿼리에 있으므로 기준 디렉토리가
`/api/{타입}/assets/` 로 잡히고, `url('./woff2/f.woff2')` 가 실재하지 않는 주소를 가리킵니다. 치환은
이 어긋남을 서빙 시점에 해소합니다.
치환된 본문은 두 가지 계약을 따릅니다.
- **ETag 는 내보내는 본문 기준**입니다. 자산 URL 모드가 바뀌면 본문이 달라지므로 ETag 도 함께 바뀝니다.
- **서브리소스는 CSS 가 받은 `v` 를 승계**합니다. 캐시 버전이 오르면 두 계층이 같은 시점에 무효화됩니다.
CSS 가 아닌 자산은 바이트 그대로 서빙됩니다. 정적 게시본(`/build/ext/{v}/…`)은 웹서버가 직접 경로
형태로 서빙하므로 상대 해석이 원래 정상이며 이 경로를 타지 않습니다.
어느 형태를 쓸지는 서버 환경에 따라 결정되며, 다음 프로브 엔드포인트로 판정합니다.
| 메서드 | URI | 인증/권한 | 설명 |
+66
View File
@@ -13,6 +13,8 @@
4. 권한: permission: 또는 Middleware에서 체크
5. REST 패턴: index, store, show, update, destroy
6. .js/.css/.json/.map 로 끝나는 라우트 = dualSuffix/dualSuffixSegment/dualAsset 매크로 필수
7. catch-all 은 예약 프리픽스를 전수 제외 (가려진 라우트는 200 SPA 셸이라 단서가 없다)
8. 조건부로만 열리는 라우트군의 게이트 = 그룹 미들웨어 단일 지점 (핸들러 개별 게이트 금지)
```
---
@@ -170,6 +172,67 @@ SPA catch-all(`routes/web.php` 의 admin·user 그룹)은 등록되지 않은
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
엔드포인트별 변환 규칙 표와 프로브 판정 절차: [API 레퍼런스 진입점](./api/README.md) "자산 URL 이중 모드".
### catch-all 은 예약 프리픽스를 전수 제외한다
catch-all 은 그 뒤에 등록된 라우트를 **가린다**. Laravel 은 등록 순서대로 매칭하므로, 예약 프리픽스가
제외 목록에 없으면 그 경로의 GET 요청이 catch-all 에 먼저 걸려 SPA 셸을 받는다. 문제는 그 응답이
예외도 404 도 아닌 **200** 이라는 점이다 — 요청한 쪽에는 "라우트가 가려졌다" 는 단서가 전혀 없고,
서버 로그에도 정상 요청으로 남는다.
더 나쁜 것은 그 shadow 가 **보호처럼 보인다**는 점이다. 가려진 라우트는 도달 불가라 열려 있어도
증상이 없으므로, 게이트 누락이 있어도 드러나지 않는다. 제외 목록이 한 줄 바뀌는 순간 그 라우트가
게이트 없이 노출된다 — 보호를 등록 순서라는 우연에 맡긴 상태다.
```php
// ❌ 예약 프리픽스가 빠져 있다 — 그 경로의 GET 라우트가 조용히 가려진다
->where('any', '(?!admin)(?!api)(?!plugins)'.StaticExtensionPattern::catchAllExclusion());
// ✅ 예약 프리픽스 전수 제외
->where('any', '(?!admin)(?!api)(?!plugins)(?!_boost)(?!modules)'.StaticExtensionPattern::catchAllExclusion());
```
예약 프리픽스를 추가하는 확장·기능은 이 목록에 자기 프리픽스를 함께 넣는다. 스코프가 좁은 catch-all
(`admin/` 하위만 받는 admin catch-all 등)은 애초에 다른 프리픽스를 삼키지 않으므로 대상이 아니다.
### 디버그·개발 라우트는 그룹 단위로 게이트한다
디버그 도구처럼 "특정 조건에서만 열려야 하는" 라우트군은 판정을 **핸들러 안이 아니라 그룹
미들웨어**에 둔다. 핸들러마다 적으면 라우트를 추가할 때 게이트를 함께 적는 것을 잊게 되고, 빠뜨려도
예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이 유일한 증상이다.
실제로 그렇게 흩어져 있던 8개 라우트 중 게이트는 3개에만 있었고, 빠진 쪽에 디버그 데이터를 통째로
지우는 파괴적 엔드포인트가 있었다. 운영 환경·디버그 OFF 상태에서도 미인증 요청이 200 으로 통과했다.
```php
// bootstrap/app.php — 부착은 이 한 곳뿐이다
Route::middleware(['api', 'debug.gate'])
->group(base_path('routes/devtools.php'));
```
```php
// ❌ 핸들러 안의 개별 게이트 — 새 라우트에서 빠뜨리게 된다
Route::delete('clear', function () {
if (! DebugGate::isEnabled()) {
return response()->json(['status' => 'error'], 403);
}
// ...
});
// ✅ 게이트는 그룹이 부여하고, 핸들러는 본래 일만 한다
Route::delete('clear', function () {
// ...
});
```
부착 지점이 하나면 그 지점의 훼손이 곧바로 드러난다 — 라우트군 전체의 `gatherMiddleware()` 에 게이트
별칭이 들어 있는지를 단언하는 **등록 계약 테스트**를 함께 둔다. 이 축은 행위 테스트로 대체할 수 없다:
가려져 있거나 조건이 맞지 않는 라우트는 행위상 "막힌 것처럼" 보이므로, 게이트 부착 여부와 구분되지
않기 때문이다. 그 테스트에는 모집단이 비었을 때 공허하게 통과하지 않도록 건수 가드를 둔다.
게이트 미들웨어는 라우트와 함께 라우트 캐시에 구워지므로 캐시 상태에서도 유지된다. 다만 그 사실을
가정하지 말고 캐시 상태에서의 차단도 함께 검증한다 — 라우트 캐시는 확장 설치·활성화 등 수명주기
지점에서 자동 생성되므로 오히려 흔한 상태다.
---
## 권한 체크
@@ -472,12 +535,15 @@ FQCN 정적 호출도 안전하다. 문제가 되는 것은 오토로드 경로
- [ ] 인증이 필요한 라우트에 `auth:sanctum` 미들웨어 적용
- [ ] 관리자 라우트에 `admin` 미들웨어 적용
- [ ] FormRequest의 `authorize()` 메서드에서 권한 체크하지 않음
- [ ] 예약 프리픽스를 새로 쓴다면 SPA catch-all 제외 목록에 추가
- [ ] 조건부로만 열리는 라우트군은 그룹 미들웨어로 게이트 (핸들러 안 개별 판정 금지)
### 라우트 테스트 확인사항
- [ ] 인증 없이 접근 시 401 반환
- [ ] 권한 없이 접근 시 403 반환
- [ ] 올바른 권한으로 접근 시 성공
- [ ] 그룹 게이트를 둔 라우트군은 `gatherMiddleware()` 부착을 단언하는 등록 계약 테스트 동반 (모집단 가드 포함)
---
+10 -5
View File
@@ -48,11 +48,16 @@ public/build/ext/{cache_version}/
템플릿의 `dist/**` 는 정적 게시 대상이므로 동봉 자산도 웹서버가 직접 서빙합니다.
운영자가 덧붙이는 자산(`custom/`)도 **모듈·플러그인·템플릿 모두 같은 방식으로 게시됩니다.**
게시하지 않으면 이 자산만 API 경로에 남는데, 그 경로에서는 CSS 내부 상대 `url()` 이 해석되지
않습니다 — 쿼리 형태(`?file=`)는 기준 URL 이 `/api/{타입}/assets/` 라 `url('./font.woff2')` 가
그 디렉토리를 가리키고, 확장자 형태는 정적 최적화 서버가 먼저 가로챕니다. 즉 정적 확장자
URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, "폰트·이미지를 `custom/` 에 두고 상대
경로로 참조" 를 성립시키는 방법은 게시뿐입니다.
게시하지 않으면 이 자산만 API 경로에 남습니다. 이 경로의 CSS 는 서빙 시점에 내부 상대 참조가
절대 자산 URL 로 치환되어 나가므로, `url('./font.woff2')` 같은 참조도 그대로 해석됩니다 —
치환이 없으면 쿼리 형태(`?file=`)에서 기준 URL 이 `/api/{타입}/assets/` 로 잡혀 엉뚱한 주소를
가리킵니다. 치환은 `url()` 과 `@import "…"` 를 대상으로 하며, 절대 URL·프로토콜 상대·`data:`
같은 스킴 참조는 원문 그대로 둡니다.
치환은 CSS 안의 참조만 해결합니다. 레이아웃·컴포넌트가 자산 주소를 직접 조립하는 자리는
여전히 자산 URL 헬퍼를 거쳐야 하며, 정적 확장자 URL 은 **public 아래 실제 파일일 때만** 200 이
되므로 그 형태를 하드코딩하지 않습니다.
갱신 축도 확장 자산과 같습니다. 운영자가 파일을 고치면 뷰 컴포저가 파일 서명 변화를 감지해
확장 캐시 버전을 올리고, 그 단일 지점이 재게시까지 예약합니다. 그 요청은 서술자를 새 버전으로
+9 -1
View File
@@ -510,10 +510,18 @@ GET /api/plugins/bundle.css?v={version}
| same-origin | 번들 URL 은 `/api/...` 만 (CDN 금지 — gdpr preblocker 자기차단 방지) | `extension-bundle-url-same-origin` |
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
| CSS url() | 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) | - |
| CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) |
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
| 빈 번들 판정 | 선언 0개면 빈 200, 선언은 있는데 결과가 0이면 **503** | (계약 테스트) |
### 병합 CSS 의 상대 참조
병합본은 `/api/{타입}/bundle.css`(또는 정적 게시본)로 서빙되는데, 그 주소는 **어느 확장의 `dist` 디렉토리도 아니다.** 브라우저는 CSS 안의 상대 `url()` 을 스타일시트 URL 의 디렉토리 기준으로 풀므로, 상대 참조는 URL 모드와 무관하게 반드시 어긋난다. 그래서 병합 시점에 각 확장의 절대 자산 URL 로 치환한다 — 개별 자산 서빙과 **같은 해석 규칙**을 쓴다. 두 경로가 서로 다른 코드로 갈라지면 한쪽만 고쳐진 채 남는다.
치환 대신 그런 CSS 를 가진 확장을 번들에서 제외하지 않는다. 번들 URL 이 내려오면 프론트는 개별 로딩 경로를 아예 타지 않으므로, 제외는 곧 **그 확장의 스타일이 하나도 적용되지 않음**을 뜻한다. 개별 로딩 폴백은 번들 URL 자체가 없을 때만 동작한다.
이 실패는 예외도 서버 로그 흔적도 남기지 않는다 — 정상 404 로 기록되고 화면에서는 글꼴이 기본 서체로 대체되거나 아이콘이 빈칸이 될 뿐이다.
### 디스크 캐시 실패와 빈 번들 (응답 계약)
번들 디스크 캐시는 **최적화**다. `ext-bundles` 디스크는 `throw => true` 라 권한 문제(먼저 touch 한 프로세스가 `0700` 으로 독점하는 경우 등)에서 `UnableToWriteFile` 이 그대로 올라오는데, 그것이 공개 엔드포인트의 500 이 되면 **모든 확장의 프론트엔드 JS/CSS 가 통째로 나가지 못한다.** 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답한다(코어 캐시 드라이버의 fail-soft 와 같은 원칙). 예방 지점은 디스크 선언이다 — `config/filesystems.php` 의 `ext-bundles` 에 `permissions`(dir `0775` / file `0664`)를 선언해 Flysystem 이 root 를 `0700` 으로 만들지 않게 한다.
+16 -25
View File
@@ -6,6 +6,14 @@
* 디버깅 데이터 덤프 및 로그 전송 엔드포인트
* 디버그 모드가 활성화된 경우에만 동작
*
* 디버그 모드 게이트:
* 이 파일 안에는 게이트가 없다. `bootstrap/app.php` 의 devtools 래퍼가
* `Route::middleware(['api', 'debug.gate'])` 로 **그룹 전체**에 `EnsureDebugMode` 를 건다.
* 핸들러 안에 `DebugGate::isEnabled()` 를 다시 적지 말 것 — 그렇게 흩어 두었던 탓에 8개
* 라우트 중 POST 3종에만 붙고 GET 4종·DELETE clear 는 빠져 있었고, 그중 clear 는
* production 에서 미인증 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128).
* 게이트 누락은 예외도 로그도 남기지 않아 그 엔드포인트가 정상 응답하는 것이 유일한 증상이다.
*
* 라우트 캐시 안전성:
* `route:cache` 가 걸리면 이 파일은 **로드되지 않는다** — `RouteServiceProvider::boot()` 이
* `loadCachedRoutes()` 로 분기하고 클로저만 `SerializableClosure` 로 복원된다. 따라서 이
@@ -21,7 +29,6 @@
use App\Support\DevTools\BrowserLogWriter;
use App\Support\DevTools\DebugDumpWriter;
use App\Support\DevTools\DebugGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
@@ -52,13 +59,6 @@ use Illuminate\Support\Facades\Route;
*/
Route::post('_boost/browser-logs', function (Request $request): JsonResponse {
if (! DebugGate::isEnabled()) {
return response()->json([
'status' => 'error',
'message' => __('devtools.debug_disabled'),
], 403);
}
return BrowserLogWriter::handle($request);
})->name('boost.browser-logs');
@@ -71,9 +71,14 @@ Route::post('_boost/browser-logs', function (Request $request): JsonResponse {
| 상태 덤프, 로그 전송 등 디버깅 관련 엔드포인트를 제공합니다.
|
| 이 그룹은 자체 미들웨어를 선언하지 않는다 — `bootstrap/app.php` 가 이 파일 전체를
| `api` 그룹으로 감싸므로 `->middleware('api')` 를 덧붙이면 같은 그룹이 두 번 지정된다
| (`Router::uniqueMiddleware()` 가 중복을 제거해 동작은 같지만, 이 그룹만 별도 미들웨어가
| 필요하다는 오해를 남긴다). 위 `_boost/browser-logs` 라우트도 같은 규율을 따른다.
| `['api', 'debug.gate']` 로 감싸므로, 디버그 모드 게이트를 포함한 미들웨어는 그 단일 지점이
| 부여한다. 여기서 `->middleware('api')` 를 덧붙이면 같은 그룹이 두 번 지정되고
| (`Router::uniqueMiddleware()` 가 중복을 제거해 동작은 같다), `->middleware('debug.gate')` 를
| 라우트마다 붙이면 그룹 부착의 의미가 사라져 빠뜨린 라우트가 다시 생긴다.
| 위 `_boost/browser-logs` 라우트도 같은 래퍼 안에 있어 동일한 게이트를 공유한다.
|
| 등록 계약(모든 `_boost` 라우트의 `gatherMiddleware()` 에 `debug.gate` 포함)은
| `tests/Feature/DevTools/DevtoolsRouteGateContractTest.php` 가 강제한다.
|
*/
@@ -85,13 +90,6 @@ Route::prefix('_boost/g7-debug')->group(function () {
* 섹션별 분할 전송을 지원합니다.
*/
Route::post('dump-state', function (Request $request): JsonResponse {
if (! DebugGate::isEnabled()) {
return response()->json([
'status' => 'error',
'message' => __('devtools.debug_disabled'),
], 403);
}
// 테스트 요청 처리
if ($request->boolean('test')) {
return response()->json([
@@ -127,13 +125,6 @@ Route::prefix('_boost/g7-debug')->group(function () {
* 디버그 로그, 에러, 프로파일 데이터를 저장합니다.
*/
Route::post('log', function (Request $request): JsonResponse {
if (! DebugGate::isEnabled()) {
return response()->json([
'status' => 'error',
'message' => __('devtools.debug_disabled'),
], 403);
}
$debugDir = storage_path('debug-dump');
if (! File::isDirectory($debugDir)) {
+7 -1
View File
@@ -49,6 +49,12 @@ Route::get('/sitemap-{n}.xml.gz', [SitemapController::class, 'child'])->whereNum
// User 라우트 - user 템플릿 의존성 검증 + SEO 봇 감지
Route::middleware(['template.dependencies:user', 'seo'])
->group(function () {
// `where('any', ...)` 의 예약 프리픽스는 전수 제외한다 — catch-all 이 GET 을 먼저
// 삼키면 그 뒤에 등록된 라우트가 도달 불가가 되는데, 예외도 404 도 아닌 **SPA 셸 200**
// 이라 요청한 쪽은 무엇이 잘못됐는지 알 방법이 없다. `_boost`(DevTools) 가 실제로 이렇게
// 가려져 있었고, "우연히 안전" 한 상태를 게이트 대신 의지하게 만들었다(공개#128).
// `modules` 는 향후 모듈이 web GET 라우트를 소유할 때 같은 일이 반복되지 않도록 미리
// 비워 둔다. `admin` catch-all(위)은 `admin/` 스코프 한정이라 대상이 아니다.
Route::get('/{any?}', function (Request $request) {
// 미등록 경로는 SPA 셸 본문 + HTTP 404 (soft 404 방지 — 공개#47).
$path = '/'.ltrim($request->getPathInfo(), '/');
@@ -57,5 +63,5 @@ Route::middleware(['template.dependencies:user', 'seo'])
}
return view('app');
})->where('any', '(?!admin)(?!api)(?!plugins)'.StaticExtensionPattern::catchAllExclusion());
})->where('any', '(?!admin)(?!api)(?!plugins)(?!_boost)(?!modules)'.StaticExtensionPattern::catchAllExclusion());
});
+68 -1
View File
@@ -6,8 +6,10 @@ use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Contracts\Broadcasting\Factory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route as RouteFacade;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
@@ -105,6 +107,71 @@ class BroadcastingAuthTest extends TestCase
$this->assertFalse((bool) $callback($user));
}
/**
* `broadcasting/auth` 로 끝나는 라우트는 게이트된 `api/broadcasting/auth` 하나뿐이어야 한다.
*
* 배경 (이슈 #128 F4):
* `bootstrap/app.php` 의 `withRouting(channels: ...)` 인자는 ApplicationBuilder 가
* `withBroadcasting()` 을 자동 호출하게 만들고, 그 결과 `booted` 에서 `Broadcast::routes()`
* 가 **게이트 없는** `GET|POST /broadcasting/auth` 를 등록했다. 프론트는 이 경로를 쓰지
* 않으므로(`WebSocketManager` 는 `/api/broadcasting/auth` 만 호출) 死라우트인 동시에,
* 웹소켓 킬스위치(`broadcasting.default === 'null'`, 공개#50)를 통째로 우회하는 경로였다.
* production 에서 미인증 POST 가 200 을 받았다.
*
* 채널 정의(`routes/channels.php`)는 `App\Providers\BroadcastServiceProvider` 가
* `require` 로 계속 로드하므로 인가 콜백은 그대로 살아 있다 — 아래 채널 테스트가 그 증거다.
*/
public function test_only_the_gated_api_broadcasting_auth_route_is_registered(): void
{
$found = [];
foreach (RouteFacade::getRoutes() as $route) {
$uri = $route->uri();
if ($uri === 'broadcasting/auth' || str_ends_with($uri, '/broadcasting/auth')) {
$found[$uri] = $route->gatherMiddleware();
}
}
// 모집단 가드 — 라우트가 하나도 안 잡히면 이 단언은 공허참이 된다.
$this->assertArrayHasKey(
'api/broadcasting/auth',
$found,
'게이트된 api/broadcasting/auth 라우트가 등록되어 있지 않습니다.'
);
$this->assertSame(
['api/broadcasting/auth'],
array_keys($found),
'게이트 없는 broadcasting/auth 라우트가 등록되어 있습니다 (킬스위치 우회로): '
.implode(', ', array_keys($found))
);
$this->assertContains(
'auth:sanctum',
$found['api/broadcasting/auth'],
'api/broadcasting/auth 에 auth:sanctum 이 붙어 있지 않습니다.'
);
}
/**
* 채널 정의(`routes/channels.php`)가 여전히 로드되어야 한다.
*
* `withRouting(channels: ...)` 인자를 제거했으므로 채널 등록은 전적으로
* `BroadcastServiceProvider::boot()` 의 `require` 에 달려 있다. 이 로드가 사라지면
* 예외 없이 **모든 private 채널 구독이 403** 이 된다 — 콜백이 없는 채널은 거부되기 때문.
*/
public function test_channel_definitions_are_loaded_by_broadcast_service_provider(): void
{
foreach (['App.Models.User.{id}', 'core.admin.dashboard', 'core.admin.seo.sitemap'] as $channel) {
$this->assertInstanceOf(
\Closure::class,
$this->channelCallback($channel),
"채널 정의가 로드되지 않았습니다: {$channel}"
);
}
}
/**
* 등록된 브로드캐스트 채널의 인가 콜백을 반환합니다.
*
@@ -113,7 +180,7 @@ class BroadcastingAuthTest extends TestCase
*/
private function channelCallback(string $channel): \Closure
{
$broadcaster = app(\Illuminate\Contracts\Broadcasting\Factory::class)->connection();
$broadcaster = app(Factory::class)->connection();
$ref = new \ReflectionClass($broadcaster);
while ($ref !== false && ! $ref->hasProperty('channels')) {
@@ -4,6 +4,7 @@ namespace Tests\Feature\DevTools;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Support\DevTools\DebugGate;
use Illuminate\Support\Facades\File;
use Mockery;
use Tests\TestCase;
@@ -21,9 +22,61 @@ use Tests\TestCase;
* 1. app.debug=false + debug.mode=true → 200 (두 번째 항이 실제로 평가되어야 한다)
* 2. app.debug=false + debug.mode=false → 403 (500 이 아니어야 한다)
* 3. app.debug=true → 200 (환경설정 조회 없이 통과)
*
* 게이트 적용 범위 (이슈 #128):
* 게이트는 원래 POST 3종의 **핸들러 안**에만 있었다. GET 4종과 `DELETE clear` 는 게이트가
* 없었고, 그중 `clear` 는 `File::cleanDirectory(storage/debug-dump)` 를 수행하는 파괴적
* 엔드포인트다 — production·APP_DEBUG=false 에서도 미인증 요청이 200 으로 통과했다.
* GET 4종은 `routes/web.php` 의 User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을
* 뿐이라(우연한 shadow) 라우트가 노출되는 순간 그대로 열린다.
*
* 이제 게이트는 `bootstrap/app.php` 의 devtools 래퍼가 그룹 미들웨어(`debug.gate`)로
* 단일 적용하므로, 아래 테스트들은 GET·POST·DELETE 전 메서드가 같은 판정을 공유함을
* 행위로 확인한다. 부착 자체(등록 계약)는 DevtoolsRouteGateContractTest 가 본다.
*/
class DevtoolsDebugGateTest extends TestCase
{
/**
* `storage/debug-dump` 백업 디렉토리 절대경로 (미백업 시 null).
*
* `DELETE clear` 는 실 `storage_path('debug-dump')` 를 비운다 — 경로가 핸들러에
* 고정되어 있어 테스트가 다른 곳을 가리킬 수 없다. 개발자가 브라우저에서 받아 둔
* 덤프를 테스트가 날려 버리지 않도록 클래스 단위로 스냅샷·복원한다.
* 선례: DevtoolsRouteCacheTest 의 PROBE_FILE 스냅샷.
*/
private static ?string $dumpBackupDir = null;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$dumpDir = dirname(__DIR__, 3).'/storage/debug-dump';
if (! is_dir($dumpDir)) {
return;
}
$backupDir = dirname(__DIR__, 3).'/storage/framework/testing/debug-dump-'.uniqid();
self::copyDirectory($dumpDir, $backupDir);
self::$dumpBackupDir = $backupDir;
}
public static function tearDownAfterClass(): void
{
// PHPUnit 은 테스트 실패·예외와 무관하게 이 훅을 호출한다.
if (self::$dumpBackupDir !== null) {
$dumpDir = dirname(__DIR__, 3).'/storage/debug-dump';
self::removeDirectory($dumpDir);
self::copyDirectory(self::$dumpBackupDir, $dumpDir);
self::removeDirectory(self::$dumpBackupDir);
self::$dumpBackupDir = null;
}
parent::tearDownAfterClass();
}
protected function tearDown(): void
{
Mockery::close();
@@ -95,6 +148,138 @@ class DevtoolsDebugGateTest extends TestCase
$this->assertTrue(DebugGate::isEnabled());
}
/**
* `DELETE clear` 는 디버그 OFF 에서 403 이어야 하고, 덤프 파일을 지우지 않아야 한다.
*
* 수정 전: 미인증 200 + `storage/debug-dump` 전체 삭제 (게이트 전무).
* 상태 코드만 단언하면 "403 을 돌려주면서 이미 지운" 회귀를 놓치므로 파일 불변까지 본다.
*/
public function test_clear_returns_403_and_keeps_dump_files_when_debug_disabled(): void
{
config(['app.debug' => false]);
$this->mockConfigRepositoryDebugMode(false);
$sentinel = $this->seedDumpSentinel();
$before = count(File::allFiles(storage_path('debug-dump')));
$response = $this->deleteJson('/_boost/g7-debug/clear');
$response->assertStatus(403);
$response->assertJson(['status' => 'error']);
$this->assertNotSame(
'devtools.debug_disabled',
$response->json('message'),
'번역 키가 해석되지 않았습니다 (lang/{ko,en}/devtools.php 누락).'
);
$this->assertNotEmpty($response->json('message'));
$this->assertFileExists(
$sentinel,
'DELETE clear 가 403 을 돌려주기 전에 이미 덤프를 삭제했습니다 (게이트가 핸들러 안에 있으면 발생).'
);
$this->assertSame(
$before,
count(File::allFiles(storage_path('debug-dump'))),
'DELETE clear 가 403 임에도 덤프 파일 수가 변했습니다.'
);
}
/**
* `DELETE clear` 는 디버그 ON 에서는 종전대로 동작해야 한다 (게이트 이관이 기능을 죽이지 않는다).
*
* 실 `storage/debug-dump` 를 비우지만 클래스 teardown 이 백업본으로 복원한다.
*/
public function test_clear_succeeds_when_debug_enabled(): void
{
config(['app.debug' => true]);
$sentinel = $this->seedDumpSentinel();
$response = $this->deleteJson('/_boost/g7-debug/clear');
$response->assertStatus(200);
$response->assertJson(['status' => 'success']);
$this->assertFileDoesNotExist($sentinel, 'debug ON 인데 clear 가 덤프를 지우지 않았습니다.');
}
/**
* MCP 조회용 GET 4종도 디버그 OFF 에서 403 JSON 이어야 한다.
*
* 수정 전: 게이트 부재 + `routes/web.php` User catch-all shadow 로 **HTML 404**.
* "404 니까 안전하다" 는 우연이었다 — 제외 패턴이 `_boost` 를 빠뜨린 결과일 뿐,
* catch-all 이 바뀌면 그대로 열린다. 그래서 403 JSON 을 단언해 게이트 도달을 증명한다.
*/
public function test_get_endpoints_return_403_json_when_debug_disabled(): void
{
config(['app.debug' => false]);
$this->mockConfigRepositoryDebugMode(false);
foreach (['state', 'actions', 'cache', 'change-detection'] as $endpoint) {
$response = $this->getJson('/_boost/g7-debug/'.$endpoint);
$response->assertStatus(403);
$response->assertJson(['status' => 'error']);
$this->assertNotSame(
'devtools.debug_disabled',
$response->json('message'),
"번역 키가 해석되지 않았습니다 ({$endpoint})."
);
}
}
/**
* GET 4종은 디버그 ON 에서 정상 응답한다 (게이트만 걸고 기능은 유지 — PO 결정).
*/
public function test_get_endpoints_respond_when_debug_enabled(): void
{
config(['app.debug' => true]);
foreach (['state', 'actions', 'cache', 'change-detection'] as $endpoint) {
$response = $this->getJson('/_boost/g7-debug/'.$endpoint);
$response->assertStatus(200);
$this->assertContains(
$response->json('status'),
['success', 'no_data'],
"{$endpoint} 응답의 status 가 예상 밖입니다."
);
}
}
/**
* `log` 엔드포인트도 동일 게이트를 사용한다 (POST 3종 중 나머지 하나).
*/
public function test_log_returns_403_when_debug_disabled(): void
{
config(['app.debug' => false]);
$this->mockConfigRepositoryDebugMode(false);
$response = $this->postJson('/_boost/g7-debug/log', ['type' => 'debug', 'data' => []]);
$response->assertStatus(403);
$response->assertJson(['status' => 'error']);
}
/**
* 덤프 디렉토리에 이번 테스트용 표식 파일을 만든다.
*
* @return string 생성된 표식 파일 절대경로
*/
private function seedDumpSentinel(): string
{
$dir = storage_path('debug-dump');
if (! File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
$path = $dir.'/gate-sentinel.json';
File::put($path, json_encode(['sentinel' => true]));
return $path;
}
/**
* `ConfigRepositoryInterface` 를 `debug.mode` 고정값으로 대체한다.
*
@@ -113,4 +298,57 @@ class DevtoolsDebugGateTest extends TestCase
$this->app->instance(ConfigRepositoryInterface::class, $repository);
}
/**
* 디렉토리를 재귀 복사한다 (Laravel 부트스트랩에 의존하지 않는 순수 구현).
*
* @param string $from 원본 디렉토리
* @param string $to 대상 디렉토리
* @return void
*/
private static function copyDirectory(string $from, string $to): void
{
if (! is_dir($from)) {
return;
}
if (! is_dir($to) && ! @mkdir($to, 0755, true) && ! is_dir($to)) {
return;
}
foreach (scandir($from) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$src = $from.'/'.$entry;
$dst = $to.'/'.$entry;
is_dir($src) ? self::copyDirectory($src, $dst) : @copy($src, $dst);
}
}
/**
* 디렉토리를 재귀 삭제한다.
*
* @param string $dir 삭제할 디렉토리 절대경로
* @return void
*/
private static function removeDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir.'/'.$entry;
is_dir($path) ? self::removeDirectory($path) : @unlink($path);
}
@rmdir($dir);
}
}
@@ -251,6 +251,83 @@ class DevtoolsRouteCacheTest extends TestCase
}
}
/**
* 그룹 게이트는 라우트 캐시 상태에서도 살아 있어야 한다 (이슈 #128).
*
* 게이트를 핸들러 안에서 그룹 미들웨어로 올렸으므로, 이제 게이트 적용은 **미들웨어가
* 라우트와 함께 캐시에 구워지는가**에 달려 있다. 구워지지 않으면 캐시된 사이트에서만
* `DELETE clear` 가 다시 열리는데 — 라우트 캐시는 확장 설치·활성화 등 수명주기 지점에서
* 자동 생성되므로 그쪽이 오히려 흔한 상태다 — 예외도 로그도 없이 파괴적 엔드포인트가
* 미인증 200 을 돌려준다.
*
* `storage/debug-dump` 를 실제로 지우지 않는 것까지 확인한다: 자식 프로세스는 실
* storage 를 쓰므로, 게이트가 없으면 이 테스트 한 번으로 개발자 덤프가 사라진다.
*/
public function test_debug_gate_blocks_destructive_clear_under_route_cache(): void
{
$dumpDir = base_path('storage/debug-dump');
$before = is_dir($dumpDir) ? count(glob($dumpDir.'/*') ?: []) : 0;
$result = $this->dispatch('DELETE', '/_boost/g7-debug/clear', '', debug: false);
$this->assertTrue(
$result['routesAreCached'],
'라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).'
);
$this->assertStringContainsString('route-cache-', $result['cachedRoutesPath']);
// 403 이 "debug on 인데도 막혔다" 가 아니라 "debug off 라서 막혔다" 임을 고정한다.
$this->assertFalse(
$result['appDebug'],
'자식 프로세스가 APP_DEBUG=false 로 부팅되지 않았습니다 (하네스 인자 전달 실패).'
);
$this->assertSame(
403,
$result['status'],
"라우트 캐시 상태에서도 DELETE clear 는 403 이어야 합니다.
body: {$result['body']}"
);
$after = is_dir($dumpDir) ? count(glob($dumpDir.'/*') ?: []) : 0;
$this->assertSame(
$before,
$after,
'DELETE clear 가 차단되었는데도 덤프 파일 수가 변했습니다 (게이트가 삭제 뒤에 걸렸습니다).'
);
}
/**
* 디버그 ON 에서는 캐시 상태에서도 GET 조회가 도달해야 한다 (게이트가 상시 차단이 아님).
*
* `_boost/g7-debug/state` 는 `routes/web.php` 의 User catch-all 제외 패턴에 `_boost` 가
* 들어가면서 비로소 도달 가능해진 경로다. 캐시된 라우트에도 그 제외가 구워지는지 본다.
*/
public function test_gated_get_endpoint_is_reachable_under_route_cache_when_debug_enabled(): void
{
$result = $this->dispatch('GET', '/_boost/g7-debug/state');
$this->assertTrue($result['routesAreCached']);
$this->assertTrue($result['appDebug']);
$this->assertSame(
200,
$result['status'],
"라우트 캐시 상태에서 GET state 가 200 이어야 합니다.
body: {$result['body']}"
);
// 하네스는 본문을 4000자로 자른다 (`state` 덤프는 그보다 훨씬 크다) — 전체 JSON 파싱은
// 원리상 실패하므로 앞머리로 판정한다. 여기서 가려야 하는 것은 "DevTools JSON 인가
// SPA 셸 HTML 인가" 뿐이다.
$this->assertMatchesRegularExpression(
'/^\{"status":"(success|no_data)"/',
$result['body'],
'SPA 셸 HTML 이 아니라 DevTools JSON 이어야 합니다 (catch-all shadow 회귀).'
);
$this->assertStringNotContainsString('<!DOCTYPE', $result['body']);
}
/**
* 임시 경로에 라우트 캐시를 굽는다 (클래스당 1회).
*
@@ -285,9 +362,10 @@ class DevtoolsRouteCacheTest extends TestCase
* @param string $method HTTP 메서드
* @param string $uri 요청 URI
* @param string $body JSON 본문
* @param bool $debug 자식 프로세스의 APP_DEBUG (false 면 DevTools 그룹 게이트가 차단해야 한다)
* @return array<string, mixed> 하네스 dispatch 결과
*/
private function dispatch(string $method, string $uri, string $body = ''): array
private function dispatch(string $method, string $uri, string $body = '', bool $debug = true): array
{
$this->bakeOnce();
@@ -299,6 +377,7 @@ class DevtoolsRouteCacheTest extends TestCase
$method,
$uri,
$body === '' ? '' : base64_encode($body),
$debug ? 'on' : 'off',
]);
return $this->parseHarnessOutput($output);
@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature\DevTools;
use Illuminate\Support\Facades\Route as RouteFacade;
use Tests\TestCase;
/**
* DevTools(`_boost`) 라우트의 **게이트 부착 계약** 테스트 (이슈 #128).
*
* 왜 행위 테스트만으로 부족한가:
* 게이트가 빠진 라우트는 예외도 로그도 남기지 않는다 — 그 엔드포인트가 정상 응답하는 것이
* 유일한 증상이다. 게다가 GET 4종은 `routes/web.php` 의 User SPA catch-all 이 등록 순서상
* 앞서 가려 주고 있어서, 제외 패턴이 되돌아가면 행위 테스트조차 "SPA 200" 을 받고 무엇이
* 빠졌는지 말해 주지 못한다. 그래서 **등록 시점의 미들웨어 부착 자체**를 계약으로 고정한다.
*
* 두 축:
* 1. 등록 계약 — 모든 `_boost` 라우트의 `gatherMiddleware()` 에 `debug.gate` 가 있다.
* 새 라우트를 `routes/devtools.php` 에 추가해도 그룹 미들웨어가 자동으로 덮으므로,
* 이 단언이 깨지는 것은 부착 지점(`bootstrap/app.php` devtools 래퍼)이 훼손된 경우다.
* 2. 소스 계약 — `routes/devtools.php` 에 `DebugGate::isEnabled()` 가 다시 나타나지 않는다.
* 개별 게이트가 재유입되면 "어떤 라우트는 붙고 어떤 라우트는 안 붙는" 상태로 되돌아간다.
*
* 선례: IdentityPermissionContractTest(name → gatherMiddleware 계약),
* ExtensionRouteActiveGateTest(모집단 가드), ExtensionRouteCacheInvalidationTest(stripComments).
*/
class DevtoolsRouteGateContractTest extends TestCase
{
/** 그룹 게이트 미들웨어 별칭 (`bootstrap/app.php` alias 등록) */
private const GATE_ALIAS = 'debug.gate';
/**
* `_boost` 로 시작하는 모든 라우트에 그룹 게이트가 붙어 있어야 한다.
*/
public function test_every_boost_route_carries_the_debug_gate_middleware(): void
{
$boostRoutes = [];
foreach (RouteFacade::getRoutes() as $route) {
if (str_starts_with($route->uri(), '_boost')) {
$boostRoutes[$route->uri().' ['.implode('|', $route->methods()).']'] = $route->gatherMiddleware();
}
}
// 모집단 가드 — `_boost` 라우트가 하나도 안 잡히면 아래 foreach 는 공허참이 된다.
$this->assertNotEmpty(
$boostRoutes,
'_boost 라우트가 하나도 등록되지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다 '
.'(bootstrap/app.php 의 devtools 그룹 등록을 확인하세요).'
);
foreach ($boostRoutes as $label => $middleware) {
$this->assertContains(
self::GATE_ALIAS,
$middleware,
"{$label} 에 ".self::GATE_ALIAS.' 가 붙어 있지 않습니다. 게이트 없는 DevTools 라우트는 '
.'production 에서도 미인증 접근을 허용하며, 예외·로그를 남기지 않아 정상 응답이 '
.'유일한 증상입니다. 게이트는 개별 라우트가 아니라 bootstrap/app.php 의 devtools '
.'래퍼(Route::middleware([\'api\', \'debug.gate\'])) 가 단일 지점에서 부여합니다.'
);
}
}
/**
* 알려진 8개 엔드포인트가 실제로 등록되어 있어야 한다.
*
* 위 계약은 "등록된 것 전부" 를 보므로, 라우트가 통째로 사라져도 통과한다. 8개 URI 를
* 명시해 두어 삭제·오타로 인한 소실을 함께 잡는다. GET 4종은 SPA catch-all shadow 때문에
* 행위 테스트로 등록 여부를 구분할 수 없어서, 이 단언이 유일한 통로다.
*/
public function test_known_devtools_endpoints_are_registered(): void
{
$expected = [
'_boost/browser-logs' => 'POST',
'_boost/g7-debug/dump-state' => 'POST',
'_boost/g7-debug/log' => 'POST',
'_boost/g7-debug/state' => 'GET',
'_boost/g7-debug/actions' => 'GET',
'_boost/g7-debug/cache' => 'GET',
'_boost/g7-debug/change-detection' => 'GET',
'_boost/g7-debug/clear' => 'DELETE',
];
$registered = [];
foreach (RouteFacade::getRoutes() as $route) {
foreach ($route->methods() as $method) {
$registered[$route->uri()][] = $method;
}
}
foreach ($expected as $uri => $method) {
$this->assertArrayHasKey($uri, $registered, "DevTools 라우트가 등록되지 않았습니다: {$uri}");
$this->assertContains(
$method,
$registered[$uri],
"{$uri} 에 {$method} 메서드가 등록되지 않았습니다."
);
}
}
/**
* `routes/devtools.php` 는 개별 게이트를 다시 들이지 않아야 한다.
*
* 주석·문자열은 걷어내고 실행 코드만 본다 — 이 파일의 헤더 docblock 은 "여기에
* `DebugGate::isEnabled()` 를 적지 말라" 고 **설명하기 위해** 그 심볼을 언급하므로,
* 원문 그대로 검사하면 정상 상태가 위반으로 잡힌다.
*/
public function test_devtools_route_file_has_no_inline_debug_gate(): void
{
$source = file_get_contents(base_path('routes/devtools.php'));
$this->assertIsString($source);
$code = $this->stripCommentsAndStrings($source);
$this->assertStringNotContainsString(
'DebugGate::isEnabled',
$code,
'routes/devtools.php 에 개별 디버그 게이트가 다시 들어왔습니다. 게이트는 '
.'bootstrap/app.php 의 devtools 그룹 미들웨어(debug.gate) 가 단일 지점에서 담당합니다 '
.'— 핸들러마다 적으면 새 라우트에서 빠뜨리게 되고, 그 라우트만 조용히 열립니다.'
);
}
/**
* 소스에서 주석과 문자열 리터럴 본문을 제거합니다.
*
* 실행되는 코드만 판정 대상이다. 어휘 분석으로 토큰을 걷어낸다.
*
* @param string $source 대상 소스
* @return string 주석·문자열이 제거된 소스
*/
private function stripCommentsAndStrings(string $source): string
{
$stripped = '';
foreach (token_get_all($source) as $token) {
if (is_array($token)) {
if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, T_INLINE_HTML], true)) {
continue;
}
$stripped .= $token[1];
continue;
}
$stripped .= $token;
}
return $stripped;
}
}
@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature\Extension;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Services\ExtensionBundleService;
use Illuminate\Support\Facades\Route as RouteFacade;
use ReflectionMethod;
use Tests\TestCase;
/**
* 확장 CSS 를 내보내는 **모든 경로**가 상대 참조 치환을 거친다는 계약.
*
* 왜 행위 테스트만으로 부족한가:
* 치환이 빠진 경로는 예외도 로그도 남기지 않는다 — 글꼴이 기본 서체로 대체되고 아이콘이
* 빈칸이 될 뿐이며, 서버에는 정상 404 로 기록된다. 그래서 어느 한 경로에서 치환이 빠져도
* 그 사실이 어디에도 드러나지 않는다.
*
* 그리고 확장 자산 서빙은 템플릿·모듈·플러그인 **세 경로**가 같은 트레이트를 공유하는데,
* 실제 왕복을 재는 테스트는 템플릿 한 곳뿐이었다. 그 상태에서 모듈이나 플러그인 컨트롤러가
* 종전 `fileResponse()` 로 되돌아가도 저장소의 테스트는 전부 초록이었다.
*
* 그래서 모집단을 **저장소에서 도출한다** — 손으로 적은 목록은 네 번째 경로가 생기는 순간
* 조용히 낡는다. 라우트 이름 규약(`api.public.{type}.assets`, `api.public.{type}.bundle.css`)
* 으로 등록된 것 전부를 훑고, 각 경로가 치환 지점을 경유하는지 본다.
*
* 선례: DevtoolsRouteGateContractTest(등록 계약 + 모집단 가드).
*/
class ExtensionAssetCssRewriteContractTest extends TestCase
{
/** 개별 자산 서빙이 CSS 를 내보낼 때 반드시 거쳐야 하는 지점 */
private const SERVING_ENTRYPOINT = 'rewritableAssetResponse';
/** 병합 번들이 CSS 를 내보낼 때 반드시 거쳐야 하는 지점 */
private const REWRITER = 'AssetCssUrlRewriter::rewrite';
/**
* 확장 자산 서빙 라우트 전부가 치환 경로를 거쳐야 한다.
*
* 모집단은 `api.public.*.assets` 로 등록된 라우트에서 도출한다 — 지금은 템플릿·모듈·
* 플러그인 셋이지만, 네 번째가 추가되면 열거를 고치지 않아도 자동으로 검사 대상이 된다.
*/
public function test_every_extension_asset_route_serves_css_through_the_rewriter(): void
{
$actions = $this->actionsForRouteNameSuffix('.assets');
// 모집단 가드 — 하나도 안 잡히면 아래 foreach 는 공허참이 된다.
$this->assertNotEmpty(
$actions,
'확장 자산 서빙 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다 '
.'(routes/api.php 의 Route::dualAsset(...)->name(\'api.public.*.assets\') 등록을 확인하세요).'
);
// 지금 알려진 세 확장 유형이 실제로 모집단에 들어왔는지 — 이름 규약이 바뀌어 일부가
// 조용히 빠지는 것을 막는다.
foreach (['templates', 'modules', 'plugins'] as $type) {
$this->assertNotEmpty(
array_filter(array_keys($actions), static fn (string $name): bool => str_contains($name, ".{$type}.")),
"api.public.{$type}.assets 라우트가 모집단에 없습니다 — 이름 규약이 바뀌었거나 라우트가 사라졌습니다."
);
}
foreach ($actions as $routeName => $action) {
$source = $this->methodSource($action[0], $action[1]);
$this->assertStringContainsString(
self::SERVING_ENTRYPOINT,
$source,
"{$routeName} ({$action[0]}::{$action[1]}) 이 ".self::SERVING_ENTRYPOINT.'() 를 거치지 않습니다. '
.'CSS 안의 상대 참조가 그대로 나가면 확장자 없는 URL 모드에서 글꼴·아이콘이 404 가 되는데, '
.'그 실패는 예외도 서버 로그 흔적도 남기지 않습니다.'
);
}
}
/**
* 자산 서빙 컨트롤러는 치환 트레이트를 실제로 보유해야 한다.
*
* 위 단언은 호출문만 본다 — 트레이트가 빠지면 호출문이 남아 있어도 치명적 오류가 되므로,
* 보유 여부를 함께 고정한다.
*/
public function test_asset_serving_controllers_use_the_rewrite_trait(): void
{
$actions = $this->actionsForRouteNameSuffix('.assets');
$this->assertNotEmpty($actions, '확장 자산 서빙 라우트가 하나도 잡히지 않았습니다.');
foreach ($actions as $routeName => $action) {
$this->assertContains(
ServesRewritableCssAssets::class,
array_values(class_uses_recursive($action[0])),
"{$routeName} 의 컨트롤러 {$action[0]} 가 ServesRewritableCssAssets 트레이트를 쓰지 않습니다."
);
}
}
/**
* 병합 CSS 번들도 같은 치환 규칙을 거쳐야 한다.
*
* 병합본의 주소(`/api/{type}/bundle.css`, 정적 게시본)는 어느 확장의 dist 디렉토리도
* 아니므로 상대 해석이 반드시 어긋난다. 종전에는 상대 참조를 가진 CSS 를 번들에서
* **제외**했는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로
* 제외 = 그 확장의 스타일이 하나도 적용되지 않음이었다.
*/
public function test_css_bundle_route_builds_through_the_rewriter(): void
{
$actions = $this->actionsForRouteNameSuffix('.bundle.css');
$this->assertNotEmpty(
$actions,
'병합 CSS 번들 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다.'
);
$source = $this->methodSource(ExtensionBundleService::class, 'buildCssBundle');
$this->assertStringContainsString(
self::REWRITER,
$source,
'ExtensionBundleService::buildCssBundle() 이 '.self::REWRITER.' 를 거치지 않습니다. '
.'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.'
);
}
/**
* 라우트 이름 접미사로 컨트롤러 액션 모집단을 도출합니다.
*
* @param string $suffix 라우트 이름 접미사 (예: `.assets`)
* @return array<string, array{0: class-string, 1: string}> 라우트명 => [컨트롤러, 메서드]
*/
private function actionsForRouteNameSuffix(string $suffix): array
{
$actions = [];
foreach (RouteFacade::getRoutes() as $route) {
$name = $route->getName();
if (! is_string($name) || ! str_starts_with($name, 'api.public.') || ! str_ends_with($name, $suffix)) {
continue;
}
$controller = $route->getAction('controller');
if (! is_string($controller) || ! str_contains($controller, '@')) {
continue;
}
[$class, $method] = explode('@', $controller, 2);
$actions[$name] = [$class, $method];
}
return $actions;
}
/**
* 메서드 본문 소스를 읽습니다.
*
* @param class-string $class 클래스
* @param string $method 메서드명
* @return string 메서드 소스
*/
private function methodSource(string $class, string $method): string
{
$reflection = new ReflectionMethod($class, $method);
$file = $reflection->getFileName();
$this->assertIsString($file, "{$class}::{$method} 의 소스 파일을 찾을 수 없습니다.");
$lines = file($file);
$this->assertIsArray($lines, "{$file} 을 읽을 수 없습니다.");
return implode('', array_slice(
$lines,
$reflection->getStartLine() - 1,
$reflection->getEndLine() - $reflection->getStartLine() + 1
));
}
}
@@ -0,0 +1,220 @@
<?php
namespace Tests\Feature\Extension;
use App\Enums\ExtensionStatus;
use App\Models\Module;
use App\Models\Plugin;
use App\Support\AssetUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 모듈·플러그인 자산 CSS 의 **상대 참조 치환** 왕복 계약.
*
* 템플릿 경로는 TemplateAssetCssUrlRewriteTest 가 잠근다. 셋은 같은 트레이트를 공유하지만,
* 실제로 그 트레이트를 **거치는지**는 경로마다 따로 배선되어 있다 — 어느 한 컨트롤러가
* 종전 `fileResponse()` 로 되돌아가도 그 사실은 어디에도 드러나지 않는다. 배선 자체는
* ExtensionAssetCssRewriteContractTest 가 모집단 도출로 잠그고, 여기서는 그 배선이 실제
* 왕복까지 성립하는지(치환된 주소가 그 파일을 정말 돌려주는지)를 확인한다.
*
* 확장자 없는 모드가 결함이 실제로 나타나던 조합이므로 그 모드를 주 축으로 두고, 확장자
* 모드도 같은 경로를 태워 두 모드의 결과를 함께 고정한다.
*/
class ExtensionAssetCssUrlRewriteTest extends TestCase
{
use RefreshDatabase;
/** @var array<int, string> 정리 대상 디렉토리 */
private array $createdPaths = [];
protected function tearDown(): void
{
AssetUrl::forceMode(null);
foreach ($this->createdPaths as $path) {
$this->deleteDirectory($path);
}
parent::tearDown();
}
/**
* 모듈 — 확장자 없는 모드에서 상대 참조가 치환되고 그 주소가 파일을 돌려준다.
*/
public function test_module_extensionless_mode_rewrites_and_resolves(): void
{
$identifier = $this->makeModule();
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$css = $this->get("/api/modules/assets/{$identifier}?file=".rawurlencode('dist/css/style.css'))
->assertOk()
->getContent();
$fontUrl = "/api/modules/assets/{$identifier}?file=".rawurlencode('dist/woff2/f.woff2');
$this->assertStringContainsString($fontUrl, $css, '모듈 CSS 의 상대 참조가 치환되지 않았습니다.');
$this->assertStringNotContainsString("url('../woff2/f.woff2')", $css);
// 왕복 — 문자열만 맞고 서빙이 404 면 화면 증상은 그대로다.
$this->assertSame('FONTBYTES', $this->get($fontUrl)->assertOk()->streamedContent());
}
/**
* 모듈 — 확장자 모드도 같은 경로를 타고 경로 형태로 치환된다.
*/
public function test_module_extension_mode_rewrites_and_resolves(): void
{
$identifier = $this->makeModule();
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
$css = $this->get("/api/modules/assets/{$identifier}/dist/css/style.css")
->assertOk()
->getContent();
$fontUrl = "/api/modules/assets/{$identifier}/dist/woff2/f.woff2";
$this->assertStringContainsString($fontUrl, $css);
$this->assertSame('FONTBYTES', $this->get($fontUrl)->assertOk()->streamedContent());
}
/**
* 플러그인 — 확장자 없는 모드에서 상대 참조가 치환되고 그 주소가 파일을 돌려준다.
*/
public function test_plugin_extensionless_mode_rewrites_and_resolves(): void
{
$identifier = $this->makePlugin();
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$css = $this->get("/api/plugins/assets/{$identifier}?file=".rawurlencode('dist/css/style.css'))
->assertOk()
->getContent();
$fontUrl = "/api/plugins/assets/{$identifier}?file=".rawurlencode('dist/woff2/f.woff2');
$this->assertStringContainsString($fontUrl, $css, '플러그인 CSS 의 상대 참조가 치환되지 않았습니다.');
$this->assertStringNotContainsString("url('../woff2/f.woff2')", $css);
$this->assertSame('FONTBYTES', $this->get($fontUrl)->assertOk()->streamedContent());
}
/**
* 플러그인 — 확장자 모드도 같은 경로를 타고 경로 형태로 치환된다.
*/
public function test_plugin_extension_mode_rewrites_and_resolves(): void
{
$identifier = $this->makePlugin();
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
$css = $this->get("/api/plugins/assets/{$identifier}/dist/css/style.css")
->assertOk()
->getContent();
$fontUrl = "/api/plugins/assets/{$identifier}/dist/woff2/f.woff2";
$this->assertStringContainsString($fontUrl, $css);
$this->assertSame('FONTBYTES', $this->get($fontUrl)->assertOk()->streamedContent());
}
/**
* 절대·루트상대 참조는 두 확장 유형에서도 손대지 않는다.
*/
public function test_absolute_references_are_never_rewritten_for_extensions(): void
{
$identifier = $this->makeModule();
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$css = $this->get("/api/modules/assets/{$identifier}?file=".rawurlencode('dist/css/style.css'))
->assertOk()
->getContent();
$this->assertStringContainsString('https://cdn.example.com/x.png', $css);
$this->assertStringContainsString('/build/ext/1/y.png', $css);
}
/**
* 활성 모듈과 그 자산 파일을 만듭니다.
*
* @return string 모듈 식별자
*/
private function makeModule(): string
{
$identifier = 'test-css-module';
Module::factory()->create([
'identifier' => $identifier,
'status' => ExtensionStatus::Active->value,
]);
$this->writeAssets(base_path("modules/{$identifier}"));
return $identifier;
}
/**
* 활성 플러그인과 그 자산 파일을 만듭니다.
*
* @return string 플러그인 식별자
*/
private function makePlugin(): string
{
$identifier = 'test-css-plugin';
Plugin::factory()->create([
'identifier' => $identifier,
'status' => ExtensionStatus::Active->value,
]);
$this->writeAssets(base_path("plugins/{$identifier}"));
return $identifier;
}
/**
* 확장 디렉토리에 CSS·서브리소스를 만듭니다.
*
* 상대 해석은 경로 계층이 있어야 의미가 있으므로 실제 디렉토리 구조를 만든다.
*
* @param string $root 확장 루트 절대 경로
*/
private function writeAssets(string $root): void
{
$this->createdPaths[] = $root;
@mkdir($root.'/dist/css', 0755, true);
@mkdir($root.'/dist/woff2', 0755, true);
file_put_contents(
$root.'/dist/css/style.css',
"@font-face{src:url('../woff2/f.woff2')}\n"
."b{background:url('https://cdn.example.com/x.png')}\n"
."c{background:url('/build/ext/1/y.png')}\n"
);
file_put_contents($root.'/dist/woff2/f.woff2', 'FONTBYTES');
}
/**
* 디렉토리를 재귀 삭제합니다.
*
* @param string $dir 대상 디렉토리
*/
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir) ?: [], ['.', '..']) as $item) {
$path = $dir.'/'.$item;
is_dir($path) ? $this->deleteDirectory($path) : @unlink($path);
}
@rmdir($dir);
}
}
@@ -258,7 +258,10 @@ class VendoredAssetServingTest extends TestCase
#[Test]
public function 빌드_산출물_정리가_동봉_자산을_보존한다(): void
{
$workspace = storage_path('framework/testing/prune-'.uniqid());
// `_bundled` 아래에 둔다 — 정리는 소스 디렉토리에서만 수행된다(#619). 활성 경로에서는
// 경고만 남기고 아무것도 지우지 않으므로, 그 경로에 두면 이 테스트가 재려는 것 자체가
// 실행되지 않는다.
$workspace = storage_path('framework/testing/_bundled/prune-'.uniqid());
File::ensureDirectoryExists($workspace.'/dist/js');
File::ensureDirectoryExists($workspace.'/dist/vendor/lib/1.0.0');
File::put($workspace.'/dist/js/old.js', '// old');
@@ -269,6 +272,17 @@ class VendoredAssetServingTest extends TestCase
{
use PrunesBuildOutput;
/**
* Artisan 커맨드의 출력 메서드 대역.
*
* 트레이트가 활성 경로에서 경고를 내보내므로 대역에도 있어야 한다 — 없으면
* 그 분기에 닿는 순간 치명적 오류가 나서 무엇이 어긋났는지 드러나지 않는다.
*
* @param string $string 메시지
* @param int|string|null $verbosity 출력 수준
*/
public function warn($string, $verbosity = null): void {}
/**
* 정리를 실행합니다.
*
@@ -461,12 +461,12 @@ class ModuleAssetServingTest extends TestCase
'Content-Type should start with text/css'
);
// BinaryFileResponse 는 스트리밍이라 본문이 버퍼에 없다 — 어떤 파일을 서빙했는지로
// 확인한다. dist/ 의 동명 파일이 아니라 custom/ 의 파일이어야 한다.
$served = $response->baseResponse->getFile()->getRealPath();
$this->assertSame(
realpath($this->testModulePath.'/custom/custom.css'),
$served,
// dist/ 의 동명 파일이 아니라 custom/ 의 파일이어야 한다 — 내용으로 확인한다.
// CSS 응답은 상대 참조 치환을 거치므로 본문이 버퍼에 실린다(BinaryFileResponse 아님).
// 파일 경로 대신 내용을 보는 편이 "무엇이 나갔는가" 를 직접 재는 것이기도 하다.
$this->assertStringContainsString(
'.operator { color: red; }',
$response->getContent(),
'custom/ 의 파일이 서빙되어야 한다'
);
}
@@ -0,0 +1,200 @@
<?php
namespace Tests\Feature\Template;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use App\Support\AssetUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 확장 자산 CSS 서빙의 **상대 참조 치환** 계약.
*
* 배경:
* `general.asset_url_mode` 가 `extensionless` 면 CSS 주소가
* `/api/templates/assets/{id}?file=vendor%2F…%2Fa.css` 형태다. 브라우저는 CSS 안의 상대
* `url()` 을 스타일시트 URL 의 **디렉토리** 기준으로 푸는데, 이 형태에서는 디렉토리가
* `/api/templates/assets/` 라서 `./woff2/f.woff2` 가 존재하지 않는 주소가 된다.
*
* 증상은 404 하나뿐이라 서버 로그에 흔적이 없다 — 글꼴은 기본 서체로 대체되고 아이콘은
* 빈칸이 되며, 운영자에게는 원인을 특정할 단서가 없다. 그래서 행위(치환된 주소가 실제로
* 그 파일을 돌려주는가)까지 왕복으로 단언한다.
*/
class TemplateAssetCssUrlRewriteTest extends TestCase
{
use RefreshDatabase;
private string $identifier;
private string $distPath;
protected function setUp(): void
{
parent::setUp();
$this->identifier = 'test-css-rewrite-'.uniqid();
Template::factory()->create([
'identifier' => $this->identifier,
'status' => ExtensionStatus::Active->value,
]);
$this->distPath = base_path("templates/{$this->identifier}/dist");
// 실제 디렉토리 구조를 만든다 — 상대 해석은 경로 계층이 있어야 의미가 있다.
mkdir($this->distPath.'/vendor/pkg/1.0/css', 0755, true);
mkdir($this->distPath.'/vendor/pkg/1.0/woff2', 0755, true);
mkdir($this->distPath.'/vendor/pkg/flags', 0755, true);
file_put_contents(
$this->distPath.'/vendor/pkg/1.0/css/style.css',
"@font-face{src:url('../woff2/f.woff2')}\n"
."a{background:url(../../flags/kr.svg)}\n"
."b{background:url('https://cdn.example.com/x.png')}\n"
."c{background:url('/build/ext/1/y.png')}\n"
);
file_put_contents($this->distPath.'/vendor/pkg/1.0/woff2/f.woff2', 'FONTBYTES');
file_put_contents($this->distPath.'/vendor/pkg/flags/kr.svg', '<svg/>');
file_put_contents($this->distPath.'/vendor/pkg/1.0/css/app.js', "var a='./not-a-css-ref';");
}
protected function tearDown(): void
{
AssetUrl::forceMode(null);
if (isset($this->identifier) && is_dir(base_path("templates/{$this->identifier}"))) {
$this->deleteDirectory(base_path("templates/{$this->identifier}"));
}
parent::tearDown();
}
/**
* 확장자 없는 모드 — 상대 참조가 쿼리 형태 절대 URL 로 치환되고, 그 URL 이 실제로 파일을 준다.
*
* 이 모드가 결함이 실제로 나타나던 조합이다.
*/
public function test_extensionless_mode_rewrites_relative_urls_and_they_resolve(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$css = $this->get('/api/templates/assets/'.$this->identifier.'?file='.rawurlencode('vendor/pkg/1.0/css/style.css'))
->assertOk()
->getContent();
$fontUrl = '/api/templates/assets/'.$this->identifier.'?file='.rawurlencode('vendor/pkg/1.0/woff2/f.woff2');
$flagUrl = '/api/templates/assets/'.$this->identifier.'?file='.rawurlencode('vendor/pkg/flags/kr.svg');
$this->assertStringContainsString($fontUrl, $css, '하위 디렉토리 참조가 치환되지 않았습니다.');
$this->assertStringContainsString($flagUrl, $css, '상위 디렉토리 참조가 치환되지 않았습니다.');
// 결함의 지문 — 치환 전에는 이 주소가 나갔고 404 였다.
$this->assertStringNotContainsString("url('../woff2/f.woff2')", $css);
// 왕복 — 치환된 주소가 실제로 그 파일을 돌려줘야 한다. 문자열만 맞고 서빙이 404 면
// 화면 증상은 고쳐지지 않는다.
$this->assertSame('FONTBYTES', $this->get($fontUrl)->assertOk()->streamedContent());
}
/**
* 확장자 모드 — 같은 참조가 경로 형태 절대 URL 로 치환되고, 그 URL 도 파일을 준다.
*
* 이 모드는 상대 해석만으로도 정상이었지만, 두 모드가 서로 다른 코드로 갈라지지 않도록
* 같은 경로를 태우고 결과를 함께 고정한다.
*/
public function test_extension_mode_rewrites_to_path_form_and_they_resolve(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
$css = $this->get('/api/templates/assets/'.$this->identifier.'/vendor/pkg/1.0/css/style.css')
->assertOk()
->getContent();
$fontUrl = '/api/templates/assets/'.$this->identifier.'/vendor/pkg/1.0/woff2/f.woff2';
$this->assertStringContainsString($fontUrl, $css);
$this->assertSame('FONTBYTES', $this->get($fontUrl)->assertOk()->streamedContent());
}
/**
* 절대·스킴 참조는 두 모드 모두에서 원문 그대로 남는다.
*/
public function test_absolute_references_are_never_rewritten(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$css = $this->get('/api/templates/assets/'.$this->identifier.'?file='.rawurlencode('vendor/pkg/1.0/css/style.css'))
->assertOk()
->getContent();
$this->assertStringContainsString('https://cdn.example.com/x.png', $css);
$this->assertStringContainsString('/build/ext/1/y.png', $css);
}
/**
* CSS 가 아닌 자산은 손대지 않는다.
*
* 치환은 CSS 문법 안에서만 의미가 있다 — JS·이미지 본문에 같은 규칙을 적용하면 내용이
* 훼손된다.
*/
public function test_non_css_assets_are_served_byte_identical(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$body = $this->get('/api/templates/assets/'.$this->identifier.'?file='.rawurlencode('vendor/pkg/1.0/css/app.js'))
->assertOk()
->streamedContent();
$this->assertSame("var a='./not-a-css-ref';", $body);
}
/**
* ETag 는 내보내는 본문 기준이라 모드가 바뀌면 함께 바뀐다.
*
* 파일 stat 기준으로 잡으면 모드가 바뀌어 본문이 달라져도 같은 ETag 가 나와, 브라우저가
* 옛 본문(어긋난 주소)을 계속 쓴다. 그 회귀는 캐시가 비워지기 전까지 드러나지 않는다.
*/
public function test_etag_tracks_the_rewritten_body_not_the_file_stat(): void
{
AssetUrl::forceMode(AssetUrl::MODE_EXTENSIONLESS);
$extensionless = $this->get('/api/templates/assets/'.$this->identifier.'?file='.rawurlencode('vendor/pkg/1.0/css/style.css'))
->assertOk()
->headers->get('ETag');
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
$extension = $this->get('/api/templates/assets/'.$this->identifier.'/vendor/pkg/1.0/css/style.css')
->assertOk()
->headers->get('ETag');
$this->assertNotNull($extensionless);
$this->assertNotNull($extension);
$this->assertNotSame($extension, $extensionless, '모드가 달라 본문이 다른데 ETag 가 같습니다.');
// 같은 모드의 재요청은 304 로 응답해야 한다 (캐싱 계약 유지).
$this->get(
'/api/templates/assets/'.$this->identifier.'/vendor/pkg/1.0/css/style.css',
['If-None-Match' => $extension]
)->assertStatus(304);
}
/**
* 디렉토리를 재귀 삭제합니다.
*
* @param string $dir 대상 디렉토리
*/
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir) ?: [], ['.', '..']) as $item) {
$path = $dir.'/'.$item;
is_dir($path) ? $this->deleteDirectory($path) : @unlink($path);
}
@rmdir($dir);
}
}
@@ -17,7 +17,11 @@ use Illuminate\Support\Facades\Artisan;
*
* 사용법:
* php tests/Fixtures/DevTools/route-cache-harness.php bake <cacheRelDir>
* php tests/Fixtures/DevTools/route-cache-harness.php dispatch <cacheRelDir> <METHOD> <URI> [<base64Body>]
* php tests/Fixtures/DevTools/route-cache-harness.php dispatch <cacheRelDir> <METHOD> <URI> [<base64Body>] [on|off]
*
* 여섯째 인자는 자식 프로세스의 `APP_DEBUG` 다 (기본 `on`). `off` 는 DevTools 그룹 게이트가
* 라우트 캐시 상태에서도 살아 있는지(미들웨어가 캐시에 함께 구워지는지) 확인하는 축이다 —
* 게이트를 그룹 미들웨어로 올린 뒤 캐시 경로에서만 빠지면 예외 없이 그대로 열린다.
*
* 본문은 **base64 로 인코딩해서** 넘긴다. Windows 의 `escapeshellarg()` 는 인자 안의
* 큰따옴표를 공백으로 치환하므로 JSON 을 그대로 넘기면 `{"test":true}` 가 `{ test :true}` 로
@@ -73,11 +77,15 @@ if (! is_dir($cacheAbsDir) && ! @mkdir($cacheAbsDir, 0755, true) && ! is_dir($ca
exit(2);
}
// dispatch 의 여섯째 인자로 디버그 모드를 고른다 (bake 는 항상 on — 굽기는 게이트와 무관).
$debugFlag = ($mode === 'dispatch' && ($argvLocal[6] ?? 'on') === 'off') ? 'false' : 'true';
// 함정 2: $_SERVER/$_ENV 에 직접 주입 (Env::disablePutenv 대응).
$envOverrides = [
'APP_ENV' => 'testing',
// 게이트 단축평가 통과 + 예외 본문을 JSON 으로 확보한다.
'APP_DEBUG' => 'true',
// 기본값 true — 게이트 단축평가 통과 + 예외 본문을 JSON 으로 확보한다.
// 'off' 로 넘기면 게이트가 실제로 차단하는지(403)를 캐시 상태에서 검증한다.
'APP_DEBUG' => $debugFlag,
'APP_ROUTES_CACHE' => $cacheRelDir.'/routes-v7.php',
'APP_CONFIG_CACHE' => $cacheRelDir.'/config.php',
'APP_PACKAGES_CACHE' => $cacheRelDir.'/packages.php',
@@ -194,6 +202,9 @@ harnessEmit([
'mode' => 'dispatch',
'method' => $method,
'uri' => $uri,
// 부팅된 앱이 실제로 의도한 디버그 상태인지 — 이걸 단언하지 않으면 403 테스트가
// "debug on 인데도 403" 인지 "debug off 라서 403" 인지 구분하지 못한다.
'appDebug' => (bool) config('app.debug'),
'routesAreCached' => $app->routesAreCached(),
'cachedRoutesPath' => str_replace('\\', '/', $app->getCachedRoutesPath()),
'status' => $status,
@@ -77,7 +77,7 @@ class ExtensionBundleServiceTest extends TestCase
* hasAssets/getAssetLoadingConfig/getBuiltAssetAbsolutePaths/getIdentifier 를
* 노출하는 가짜 확장 인스턴스를 만든다.
*/
private function fakeExtension(string $identifier, int $priority, ?string $jsPath, ?string $cssPath, string $strategy = 'global'): object
private function fakeExtension(string $identifier, int $priority, ?string $jsPath, ?string $cssPath, string $strategy = 'global', ?string $cssRelPath = null): object
{
$ext = Mockery::mock();
$ext->shouldReceive('hasAssets')->andReturn(true);
@@ -97,6 +97,13 @@ class ExtensionBundleServiceTest extends TestCase
}
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn($paths);
// 확장 루트 기준 상대 경로 — CSS 상대 참조 해석의 기준점
$relative = [];
if ($cssRelPath !== null) {
$relative['css'] = $cssRelPath;
}
$ext->shouldReceive('getBuiltAssetPaths')->andReturn($relative);
return $ext;
}
@@ -125,6 +132,9 @@ class ExtensionBundleServiceTest extends TestCase
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
);
$ext->shouldReceive('getBuiltAssetPaths')->andReturn(
array_map(fn () => 'dist/css/module.css', $assets)
);
return $ext;
}
@@ -306,20 +316,58 @@ class ExtensionBundleServiceTest extends TestCase
$this->assertStringContainsString('window.GOOD=1', $js);
}
public function test_css_with_relative_url_is_excluded(): void
/**
* 상대 참조를 가진 CSS 는 **제외되지 않고 치환되어** 병합된다.
*
* 종전 계약은 그 확장을 번들에서 통째로 제외하는 것이었고 주석은 "개별 폴백 유지" 라고
* 적었지만, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않는다
* (TemplateApp.loadExtensionAssets). 즉 제외 = 그 확장 스타일이 하나도 적용되지 않음
* 이었고, 오류도 로그 흔적도 화면 경고도 남지 않았다.
*/
public function test_css_with_relative_url_is_rewritten_not_excluded(): void
{
$safe = $this->writeFixture('safe.css', '.a{color:red}');
$relative = $this->writeFixture('rel.css', '.b{background:url(./img/x.png)}');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-safe' => $this->fakeExtension('ext-safe', 10, null, $safe),
'ext-rel' => $this->fakeExtension('ext-rel', 20, null, $relative),
'ext-safe' => $this->fakeExtension('ext-safe', 10, null, $safe, cssRelPath: 'dist/css/module.css'),
'ext-rel' => $this->fakeExtension('ext-rel', 20, null, $relative, cssRelPath: 'dist/css/module.css'),
]);
$css = $this->service()->buildCssBundle('module');
// 두 확장 모두 병합에 남는다 — 제외가 사라졌다는 것이 이 축의 핵심이다.
$this->assertStringContainsString('.a{color:red}', $css);
$this->assertStringContainsString('.b{background:', $css);
// 상대 참조는 그대로 나가지 않는다 (그 주소는 병합본 기준으로 풀려 404 가 된다).
$this->assertStringNotContainsString('url(./img/x.png)', $css);
// 치환 결과는 그 확장의 절대 자산 URL 이며, CSS 가 놓인 디렉토리 기준으로 풀린다.
$this->assertStringContainsString('/api/modules/assets/ext-rel', $css);
$this->assertStringContainsString('dist/css/img/x.png', rawurldecode($css));
}
/**
* 절대·루트상대·data URI 참조는 병합에서도 손대지 않는다.
*/
public function test_css_bundle_leaves_absolute_references_untouched(): void
{
$path = $this->writeFixture('abs.css',
'.a{background:url(https://cdn.example.com/x.png)}'
.'.b{background:url(/build/ext/1/y.png)}'
.'.c{background:url(data:image/gif;base64,AA==)}'
);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-abs' => $this->fakeExtension('ext-abs', 10, null, $path, cssRelPath: 'dist/css/module.css'),
]);
$css = $this->service()->buildCssBundle('module');
$this->assertStringContainsString('url(https://cdn.example.com/x.png)', $css);
$this->assertStringContainsString('url(/build/ext/1/y.png)', $css);
$this->assertStringContainsString('url(data:image/gif;base64,AA==)', $css);
}
public function test_empty_bundle_returns_empty_path(): void
@@ -0,0 +1,173 @@
<?php
namespace Tests\Unit\Support;
use App\Support\AssetCssUrlRewriter;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* `AssetCssUrlRewriter` — CSS 안 상대 참조의 절대 URL 치환 규칙.
*
* 이 축이 깨지면 증상은 404 하나뿐이다 — 글꼴이 기본 서체로 대체되고 아이콘이 빈칸이 되며
* 서버 로그에는 정상 요청으로 남는다. 그래서 해석 규칙(상대·상위·따옴표·비대상 판정)을
* 항목별로 고정한다.
*/
class AssetCssUrlRewriterTest extends TestCase
{
/** 테스트용 URL 생성기 — 확장 기준 경로를 그대로 드러내 해석 결과를 검증 가능하게 한다 */
private static function urlFor(): callable
{
return static fn (string $path): string => '/ASSET/'.$path;
}
/**
* 상대 참조는 CSS 가 놓인 디렉토리 기준으로 해석된다.
*
* @param string $ref CSS 안의 원본 참조
* @param string $expected 기대되는 확장 기준 경로
*/
#[DataProvider('relativeReferenceProvider')]
public function test_relative_references_resolve_against_the_css_directory(string $ref, string $expected): void
{
$css = "a{background:url('{$ref}')}";
$result = AssetCssUrlRewriter::rewrite($css, 'vendor/pkg/1.0/css/style.css', self::urlFor());
$this->assertStringContainsString('/ASSET/'.$expected, $result, "참조 '{$ref}' 해석이 어긋났습니다.");
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function relativeReferenceProvider(): array
{
return [
'명시적 현재 디렉토리' => ['./a.woff2', 'vendor/pkg/1.0/css/a.woff2'],
'암묵적 현재 디렉토리' => ['a.woff2', 'vendor/pkg/1.0/css/a.woff2'],
'하위 디렉토리' => ['./woff2/a.woff2', 'vendor/pkg/1.0/css/woff2/a.woff2'],
'상위 한 단계' => ['../flags/kr.svg', 'vendor/pkg/1.0/flags/kr.svg'],
'상위 두 단계' => ['../../shared/a.svg', 'vendor/pkg/shared/a.svg'],
'중간에 현재 표기 혼합' => ['./../flags/./kr.svg', 'vendor/pkg/1.0/flags/kr.svg'],
];
}
/**
* 따옴표 3종(없음·홑·겹)을 모두 인식한다.
*
* 따옴표가 없던 참조는 겹따옴표로 감싸 내보낸다 — 생성 URL 이 `?`·`&` 를 포함할 수 있는데
* 따옴표 없는 `url()` 토큰에서 그 문자들은 CSS 문법상 허용되지 않는다.
*/
public function test_all_quote_forms_are_recognized_and_output_is_quoted(): void
{
$css = 'a{background:url(x.svg)}b{background:url("x.svg")}c{background:url(\'x.svg\')}';
$result = AssetCssUrlRewriter::rewrite($css, 'css/style.css', self::urlFor());
$this->assertSame(3, substr_count($result, '/ASSET/css/x.svg'), '세 형태 모두 치환되어야 합니다.');
$this->assertStringNotContainsString('url(/ASSET', $result, '따옴표 없는 출력이 남아 있습니다.');
}
/**
* 절대 참조·스킴 참조는 대상이 아니다.
*
* @param string $ref 건드리면 안 되는 참조
*/
#[DataProvider('untouchedReferenceProvider')]
public function test_absolute_and_scheme_references_are_left_alone(string $ref): void
{
$css = "a{background:url('{$ref}')}";
$result = AssetCssUrlRewriter::rewrite($css, 'css/style.css', self::urlFor());
$this->assertStringNotContainsString('/ASSET/', $result, "참조 '{$ref}' 는 치환 대상이 아닙니다.");
$this->assertStringContainsString($ref, $result, "참조 '{$ref}' 원문이 보존되어야 합니다.");
}
/**
* @return array<string, array{0: string}>
*/
public static function untouchedReferenceProvider(): array
{
return [
'루트 상대' => ['/build/ext/1/a.woff2'],
'프로토콜 상대' => ['//cdn.example.com/a.woff2'],
'https 절대' => ['https://cdn.example.com/a.woff2'],
'data URI' => ['data:font/woff2;base64,AAAA'],
'프래그먼트 전용' => ['#gradient'],
];
}
/**
* 프래그먼트는 보존하고, 참조 자신의 쿼리는 버린다.
*
* 생성되는 자산 URL 이 자기 캐시 버전 쿼리를 갖기 때문이다 — 원본 쿼리를 함께 남기면
* 두 쿼리가 겹쳐 서빙 계층이 파일 경로를 잘못 읽는다.
*/
public function test_fragment_is_preserved_and_reference_query_is_dropped(): void
{
$css = "a{src:url('./f.woff2?v=9#iefix')}";
$result = AssetCssUrlRewriter::rewrite($css, 'css/style.css', self::urlFor());
$this->assertStringContainsString('/ASSET/css/f.woff2#iefix', $result);
$this->assertStringNotContainsString('v=9', $result);
}
/**
* `@import "..."` 문자열 형태도 치환된다.
*/
public function test_bare_import_strings_are_rewritten(): void
{
$css = '@import "./base.css"; a{color:red}';
$result = AssetCssUrlRewriter::rewrite($css, 'css/style.css', self::urlFor());
$this->assertStringContainsString('@import "/ASSET/css/base.css"', $result);
}
/**
* CSS 가 루트에 있으면 상대 참조는 루트 기준으로 해석된다.
*/
public function test_root_level_css_resolves_from_the_extension_root(): void
{
$css = "a{background:url('img/a.svg')}";
$result = AssetCssUrlRewriter::rewrite($css, 'style.css', self::urlFor());
$this->assertStringContainsString('/ASSET/img/a.svg', $result);
}
/**
* 치환 대상이 없는 CSS 는 한 글자도 바뀌지 않는다.
*
* 이 단언이 없으면 정규식이 본문을 건드리는 회귀가 조용히 통과한다.
*/
public function test_css_without_relative_references_is_returned_unchanged(): void
{
$css = "a{color:red}\n/* url('should-not-match-in-comment') is prose */\nb{content:'x'}";
// 주석 안의 참조는 치환된다(CSS 파서가 아니라 텍스트 치환이므로) — 그 사실을 포함해
// 참조가 정말 하나도 없는 입력으로 불변을 확인한다.
$plain = 'a{color:red}b{content:"x"}@media (min-width:1px){c{display:none}}';
$this->assertSame($plain, AssetCssUrlRewriter::rewrite($plain, 'css/style.css', self::urlFor()));
$this->assertNotSame('', AssetCssUrlRewriter::rewrite($css, 'css/style.css', self::urlFor()));
}
/**
* 상위 참조가 확장 루트를 넘어가도 루트 밖으로 나가지 않는다.
*
* 넘어간 만큼은 소진되고 루트 기준으로 정착한다 — 서빙 계층의 경로 검증에 그대로 걸리도록
* 두는 것이 목적이며, 여기서 `../` 를 URL 에 실어 보내면 안 된다.
*/
public function test_upward_traversal_cannot_escape_the_extension_root(): void
{
$css = "a{background:url('../../../../../../etc/passwd')}";
$result = AssetCssUrlRewriter::rewrite($css, 'css/style.css', self::urlFor());
$this->assertStringContainsString('/ASSET/etc/passwd', $result);
$this->assertStringNotContainsString('..', $result, '상위 참조가 URL 에 그대로 실렸습니다.');
}
}