Files
Gnuboard7/tests/Feature/Extension/VendoredAssetServingTest.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

453 lines
18 KiB
PHP

<?php
namespace Tests\Feature\Extension;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
use App\Models\Plugin;
use App\Models\Template;
use App\Rules\SafeTemplatePath;
use App\Seo\SeoRenderer;
use App\Services\ExtensionStaticCacheService;
use App\Services\PluginService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use App\Support\CustomAssets;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\File;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 동봉 자산 · 사용자 추가 자산의 서빙 테스트
*
* 확장이 담은 구동 자산(`dist/vendor/**`)과 운영자가 넣은 자산(`custom/**`)이 실제로
* 서빙되는지, 그리고 **비활성 확장은 서빙되지 않는지**를 본다. 자산 404 는 예외를
* 남기지 않고 화면 기능만 사라지므로, 경로 규약이 어긋나면 배포본에서만 드러난다.
*
* DB 대신 저장소를 모킹한다 — 검증 대상은 경로 해석과 활성 게이트이지 DB 스키마가 아니다.
*/
class VendoredAssetServingTest extends TestCase
{
/** 테스트용 가짜 템플릿 식별자 */
private const FAKE_TEMPLATE = 'g7test-serving';
/** 테스트용 가짜 플러그인 식별자 */
private const FAKE_PLUGIN = 'g7test-serving-plugin';
protected function setUp(): void
{
parent::setUp();
CustomAssets::flushCache();
// 템플릿: dist/vendor(동봉) + custom(운영자)
File::ensureDirectoryExists(base_path('templates/'.self::FAKE_TEMPLATE.'/dist/vendor/lib/1.0.0/css'));
File::put(base_path('templates/'.self::FAKE_TEMPLATE.'/dist/vendor/lib/1.0.0/css/lib.css'), '/* vendored */');
File::ensureDirectoryExists(base_path('templates/'.self::FAKE_TEMPLATE.'/custom'));
File::put(base_path('templates/'.self::FAKE_TEMPLATE.'/custom/custom.css'), '/* operator */');
File::put(base_path('templates/'.self::FAKE_TEMPLATE.'/custom/notes.txt'), 'not an asset');
// 플러그인: 확장 루트 기준
File::ensureDirectoryExists(base_path('plugins/'.self::FAKE_PLUGIN.'/dist/vendor/lib/1.0.0'));
File::put(base_path('plugins/'.self::FAKE_PLUGIN.'/dist/vendor/lib/1.0.0/lib.js'), '// vendored');
}
protected function tearDown(): void
{
File::deleteDirectory(base_path('templates/'.self::FAKE_TEMPLATE));
File::deleteDirectory(base_path('plugins/'.self::FAKE_PLUGIN));
CustomAssets::flushCache();
AssetUrl::forceMode(null);
Mockery::close();
parent::tearDown();
}
/**
* 저장되지 않은 템플릿 모델을 만듭니다.
*
* @param string $status 확장 상태 값
* @return Template 모델 인스턴스
*/
private function makeTemplate(string $status): Template
{
$template = new Template;
$template->identifier = self::FAKE_TEMPLATE;
$template->status = $status;
return $template;
}
/**
* 저장되지 않은 플러그인 모델을 만듭니다.
*
* @param string $status 확장 상태 값
* @return Plugin 모델 인스턴스
*/
private function makePlugin(string $status): Plugin
{
$plugin = new Plugin;
$plugin->identifier = self::FAKE_PLUGIN;
$plugin->status = $status;
return $plugin;
}
/**
* 지정 상태의 템플릿을 돌려주는 서비스를 만듭니다.
*
* @param string $status 확장 상태 값
* @return TemplateService 모킹된 저장소를 물린 서비스
*/
private function templateService(string $status): TemplateService
{
$repository = Mockery::mock(TemplateRepositoryInterface::class);
$repository->shouldReceive('findByIdentifier')
->andReturn($this->makeTemplate($status));
$this->app->instance(TemplateRepositoryInterface::class, $repository);
return $this->app->make(TemplateService::class);
}
/**
* 지정 상태의 플러그인을 돌려주는 서비스를 만듭니다.
*
* @param string $status 확장 상태 값
* @return PluginService 모킹된 저장소를 물린 서비스
*/
private function pluginService(string $status): PluginService
{
$repository = Mockery::mock(PluginRepositoryInterface::class);
$repository->shouldReceive('findByIdentifier')
->andReturn($this->makePlugin($status));
$this->app->instance(PluginRepositoryInterface::class, $repository);
return $this->app->make(PluginService::class);
}
/**
* @scenario asset_class=vendored, outcome=loaded
*
* @effects runtime_asset_served_same_origin
*/
#[Test]
public function 템플릿_동봉_자산은_dist_이하로_해석된다(): void
{
$result = $this->templateService(ExtensionStatus::Active->value)
->getAssetFilePath(self::FAKE_TEMPLATE, 'vendor/lib/1.0.0/css/lib.css');
$this->assertTrue($result['success'], (string) ($result['error'] ?? ''));
$this->assertSame('text/css', $result['mimeType']);
$this->assertStringContainsString('/dist/vendor/', str_replace('\\', '/', $result['filePath']));
}
/**
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_loaded_after_extension_bundles
*/
#[Test]
public function 템플릿_운영자_자산은_dist_밖의_custom_으로_해석된다(): void
{
// custom/ 은 빌드 산출물이 아니라 사람이 넣은 파일이고 확장 교체가 보존하는
// 대상이라 dist/ 안에 둘 수 없다 — 서빙도 그 비대칭을 알아야 한다.
$result = $this->templateService(ExtensionStatus::Active->value)
->getAssetFilePath(self::FAKE_TEMPLATE, 'custom/custom.css');
$this->assertTrue($result['success'], (string) ($result['error'] ?? ''));
$normalized = str_replace('\\', '/', $result['filePath']);
$this->assertStringContainsString('/'.self::FAKE_TEMPLATE.'/custom/custom.css', $normalized);
$this->assertStringNotContainsString('/dist/', $normalized);
}
/**
* @scenario custom_source=convention_scan
*
* @effects inactive_extension_custom_not_served
*/
#[Test]
public function 비활성_템플릿의_자산은_서빙되지_않는다(): void
{
$result = $this->templateService(ExtensionStatus::Inactive->value)
->getAssetFilePath(self::FAKE_TEMPLATE, 'vendor/lib/1.0.0/css/lib.css');
$this->assertFalse($result['success']);
$this->assertSame('template_not_found', $result['error']);
}
/**
* @scenario custom_source=convention_scan, custom_asset=static_file
*
* @effects custom_disallowed_extension_blocked
*/
#[Test]
public function 허용되지_않은_확장자는_서빙되지_않는다(): void
{
$result = $this->templateService(ExtensionStatus::Active->value)
->getAssetFilePath(self::FAKE_TEMPLATE, 'custom/notes.txt');
$this->assertFalse($result['success']);
$this->assertSame('file_type_not_allowed', $result['error']);
}
/**
* @effects custom_path_traversal_blocked
*/
#[Test]
public function 경로_이탈은_차단된다(): void
{
$result = $this->templateService(ExtensionStatus::Active->value)
->getAssetFilePath(self::FAKE_TEMPLATE, '../../../.env');
$this->assertFalse($result['success']);
}
#[Test]
public function 플러그인_동봉_자산은_확장_루트_기준으로_해석된다(): void
{
$result = $this->pluginService(ExtensionStatus::Active->value)
->getAssetFilePath(self::FAKE_PLUGIN, 'dist/vendor/lib/1.0.0/lib.js');
$this->assertTrue($result['success'], (string) ($result['error'] ?? ''));
$this->assertSame('application/javascript', $result['mimeType']);
}
#[Test]
public function 비활성_플러그인의_자산은_서빙되지_않는다(): void
{
$result = $this->pluginService(ExtensionStatus::Inactive->value)
->getAssetFilePath(self::FAKE_PLUGIN, 'dist/vendor/lib/1.0.0/lib.js');
$this->assertFalse($result['success']);
$this->assertSame('plugin_not_found', $result['error']);
}
/**
* @effects runtime_asset_served_same_origin
*/
#[Test]
public function 자산_url_은_두_모드_모두_same_origin_이다(): void
{
foreach (['extension', 'extensionless'] as $mode) {
AssetUrl::forceMode($mode);
$url = AssetUrl::templateAsset(self::FAKE_TEMPLATE, 'vendor/lib/1.0.0/css/lib.css', null, false);
$this->assertStringStartsWith('/api/templates/assets/', $url, "모드: {$mode}");
$this->assertDoesNotMatchRegularExpression('#^(https?:)?//#', $url, "모드: {$mode}");
}
}
/**
* 빌드 산출물 정리는 동봉 자산을 지우지 않는다.
*
* vite 의 `emptyOutDir` 를 끄고 정리 책임을 빌드 커맨드로 옮긴 이유가 이것이다.
* 정리 규칙이 vendor 를 함께 지우면 매 빌드마다 동봉 자산이 사라지고, 그 사실은
* 배포본을 열어보기 전까지 드러나지 않는다.
*
*
* @effects vendored_asset_survives_rebuild
*/
#[Test]
public function 빌드_산출물_정리가_동봉_자산을_보존한다(): void
{
// `_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');
File::put($workspace.'/dist/index.d.ts', 'export {};');
File::put($workspace.'/dist/vendor/lib/1.0.0/lib.js', '// vendored');
$command = new class
{
use PrunesBuildOutput;
/**
* Artisan 커맨드의 출력 메서드 대역.
*
* 트레이트가 활성 경로에서 경고를 내보내므로 대역에도 있어야 한다 — 없으면
* 그 분기에 닿는 순간 치명적 오류가 나서 무엇이 어긋났는지 드러나지 않는다.
*
* @param string $string 메시지
* @param int|string|null $verbosity 출력 수준
*/
public function warn($string, $verbosity = null): void {}
/**
* 정리를 실행합니다.
*
* @param string $path 확장 루트 경로
* @return array<int, string> 삭제된 항목
*/
public function run(string $path): array
{
return $this->pruneBuildOutput($path);
}
};
$removed = $command->run($workspace);
$this->assertFileExists($workspace.'/dist/vendor/lib/1.0.0/lib.js', '동봉 자산이 빌드 정리에 삭제됐습니다.');
$this->assertFileDoesNotExist($workspace.'/dist/js/old.js');
$this->assertFileDoesNotExist($workspace.'/dist/index.d.ts');
$this->assertNotContains('vendor', $removed);
File::deleteDirectory($workspace);
}
/**
* 봇 렌더 경로의 스타일시트도 same-origin 자산으로 해석된다.
*
* 검색엔진이 보는 화면이 사용자 화면과 다른 자산을 쓰면, 자체 제공 전환이 그 경로에서만
* 무력화된다 — 그리고 그 사실은 봇 화면을 따로 열어보기 전까지 드러나지 않는다.
*
*
* @effects bot_render_path_uses_same_origin_asset, runtime_asset_served_same_origin
*/
#[Test]
public function 봇_렌더_경로의_스타일시트가_same_origin_으로_해석된다(): void
{
$renderer = $this->app->make(SeoRenderer::class);
$method = new \ReflectionMethod($renderer, 'resolveConfigStylesheets');
$method->setAccessible(true);
$resolved = $method->invoke($renderer, [
'vendor/font-awesome/6.4.0/css/all.inlined.css',
'https://cdn.example.com/keep-as-is.css',
'/api/templates/assets/t/already-absolute.css',
], self::FAKE_TEMPLATE);
$this->assertStringStartsWith('/api/templates/assets/', $resolved[0]);
$this->assertStringNotContainsString('/dist/', $resolved[0]);
// 절대 URL 과 이미 절대 경로인 항목은 그대로 둔다 (판정 범위를 넘지 않는다)
$this->assertSame('https://cdn.example.com/keep-as-is.css', $resolved[1]);
$this->assertSame('/api/templates/assets/t/already-absolute.css', $resolved[2]);
}
/**
* 자산 요청 검증의 컨테인먼트 기준이 **실제로 읽는 디렉토리**와 일치한다.
*
* 템플릿 자산은 `dist/` 이하가 기본이지만 `custom/` 만은 그 밖에 있다. 검증 기준을
* `dist` 로 고정하면 `custom/**` 은 realpath 가 실패해 문자열 접두 비교로만 통과하므로,
* 검증한 경로와 읽는 경로가 서로 다른 상태가 된다. 그 상태는 오류를 남기지 않는다 —
* 룰이 realpath 존재를 필수로 바꾸는 순간 custom 서빙만 조용히 404 가 된다.
*
* @effects custom_asset_containment_base_matches_serving_root
*/
#[Test]
public function 자산_요청_검증_기준_경로가_서빙_경로와_일치한다(): void
{
$base = fn (string $path): string => (function () {
$rules = $this->rules();
foreach ($rules['path'] as $rule) {
if ($rule instanceof SafeTemplatePath) {
$property = new \ReflectionProperty($rule, 'basePath');
$property->setAccessible(true);
return $property->getValue($rule);
}
}
return '';
})->call($this->makeAssetRequest($path));
// custom/ 은 확장 루트 기준 — TemplateService::getAssetFilePath 와 같은 분기
$this->assertSame(
base_path('templates/'.self::FAKE_TEMPLATE),
$base('custom/custom.css')
);
// 그 외에는 종전대로 dist 기준
$this->assertSame(
base_path('templates/'.self::FAKE_TEMPLATE.'/dist'),
$base('vendor/font-awesome/6.4.0/css/all.inlined.css')
);
}
/**
* 정적 게시가 운영자 소유 자산(`custom/`)을 자기 원본 루트로 싣는다.
*
* 게시하지 않으면 이 자산만 API 경로에 남는데, 그 경로에서는 CSS 내부 상대 `url()`
* 이 해석되지 않는다 — 문서가 안내하는 "폰트·이미지를 custom/ 에 두고 상대 경로로
* 참조" 가 성립하지 않는다. 정적 확장자 URL 은 public 아래 실제 파일일 때만 200 이
* 되므로 게시가 유일한 성립 조건이다.
*
* @effects custom_asset_published_with_extension_assets
*/
#[Test]
public function 정적_게시가_운영자_자산을_자기_루트로_싣는다(): void
{
$service = $this->app->make(ExtensionStaticCacheService::class);
$method = new \ReflectionMethod($service, 'publishTemplate');
$method->setAccessible(true);
// publishTemplate 이 custom 원본을 **자기 호출로** 게시하는지 — 소스에 두 번째
// publishDistAssets 호출이 있고 그 대상이 custom 루트임을 구조로 확인한다.
$source = file_get_contents(app_path('Services/ExtensionStaticCacheService.php'));
$this->assertStringContainsString(
'base_path("templates/{$identifier}/".CustomAssets::DIRECTORY)',
$source,
'custom 원본을 게시하는 호출이 없습니다 — 상대 url() 이 다시 깨집니다.'
);
$this->assertStringContainsString(
'excludeCustom: false',
$source,
'custom 원본 게시에서 custom 제외 가드가 꺼져 있지 않습니다 — 하위가 조용히 누락됩니다.'
);
}
/**
* dist 원본으로 들어온 `custom/` 하위는 여전히 건너뛴다.
*
* 운영자 파일은 자기 원본 루트로 게시되므로, dist 를 통해 한 번 더 실리면 같은
* 파일이 두 경로에 놓여 어느 쪽이 유효한지가 갈린다.
*
* @effects custom_asset_published_with_extension_assets
*/
#[Test]
public function dist_원본에서는_운영자_디렉토리를_건너뛴다(): void
{
$service = $this->app->make(ExtensionStaticCacheService::class);
$method = new \ReflectionMethod($service, 'isCustomAssetPath');
$method->setAccessible(true);
$this->assertTrue($method->invoke($service, CustomAssets::DIRECTORY.'/custom.css'));
$this->assertTrue($method->invoke($service, CustomAssets::DIRECTORY.'/fonts/MyFont.woff2'));
$this->assertTrue($method->invoke($service, CustomAssets::DIRECTORY));
// 이름이 겹치는 정상 산출물까지 막지 않는다
$this->assertFalse($method->invoke($service, 'css/custom-theme.css'));
$this->assertFalse($method->invoke($service, 'vendor/font-awesome/6.4.0/css/all.inlined.css'));
}
/**
* 테스트용 자산 요청을 만든다 (라우트 파라미터 해석까지 재현).
*/
private function makeAssetRequest(string $path): ServeTemplateAssetRequest
{
$request = ServeTemplateAssetRequest::create(
'/api/templates/assets/'.self::FAKE_TEMPLATE.'/'.$path
);
$route = new Route(['GET'], '/api/templates/assets/{identifier}/{path}', []);
$route->parameters = ['identifier' => self::FAKE_TEMPLATE, 'path' => $path];
$request->setRouteResolver(fn () => $route);
$request->merge(['identifier' => self::FAKE_TEMPLATE, 'path' => $path]);
return $request;
}
}