https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무 전수조사에서 확정한 결함군을 닫는다. ## 디버그 라우트 게이트 (F1~F5) 게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에 `File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고, production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은 User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴 구조였다. 게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로 올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다. `withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는 제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치 우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다. ## 확장 자산 CSS 상대 참조 (범위 추가 — 결정) `asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준 디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가 존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서 통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로 제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다. 둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이 정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류 갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 — 갈라지면 한쪽만 고쳐진 채 남는다. 이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가 정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다. ## 회귀 잠금 되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입, `channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들 치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다. 게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환 배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은 목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
221 lines
7.3 KiB
PHP
221 lines
7.3 KiB
PHP
<?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);
|
|
}
|
|
}
|