Files
Gnuboard7/tests/Feature/Template/TemplateAssetCssUrlRewriteTest.php
HeuJung 5d14d01d50 fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
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` 를 직접 보는 등록 계약 테스트를 둔다. 치환
배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은
목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
2026-09-03 07:42:28 +09:00

201 lines
8.0 KiB
PHP

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