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` 를 직접 보는 등록 계약 테스트를 둔다. 치환 배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은 목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
453 lines
18 KiB
PHP
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;
|
|
}
|
|
}
|