feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)

공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
This commit is contained in:
HeuJung committed 2026-08-27 16:47:14 +09:00
1 parent 72f2e94c1a
commit 3945b6f1b3
1042 files changed
+107435 -947

No files matched your search

@@ -0,0 +1,391 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Contracts\Extension\CacheInterface;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\CustomAssetService;
use App\Services\ExtensionStaticCacheService;
use App\Support\CustomAssets;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;
/**
* 사용자 추가 에셋(`custom/`) 관리 API 테스트
*
* 운영자가 화면에서 자기 CSS·JS·폰트·이미지를 넣고 고치는 경로를 잠근다. 특히
* **권한 경계**와 **경로 탈출** 두 축은 실패해도 오류가 남지 않는 종류라(정상 200 이
* 나가는 것이 유일한 증상) 테스트가 유일한 방어선이다.
*/
class AdminExtensionCustomAssetControllerTest extends TestCase
{
use RefreshDatabase;
private const TEMPLATE = 'sirsoft-admin_basic';
private User $manager;
private string $managerToken;
private User $outsider;
private string $outsiderToken;
private string $customDir;
protected function setUp(): void
{
parent::setUp();
$managePermission = Permission::firstOrCreate([
'identifier' => 'core.extensions.custom_assets.manage',
], [
'name' => '커스텀 자산 관리',
'display_name' => '커스텀 자산 관리',
'type' => 'admin',
]);
// 레이아웃 편집 권한만 가진 역할 — "레이아웃을 고칠 수 있다" 가 곧 "사이트 전역
// 스크립트를 올릴 수 있다" 가 되지 않는지 확인하는 대조군이다.
$layoutPermission = Permission::firstOrCreate([
'identifier' => 'core.templates.layouts.edit',
], [
'name' => '레이아웃 편집',
'display_name' => '레이아웃 편집',
'type' => 'admin',
]);
$managerRole = Role::firstOrCreate(['identifier' => 'custom-asset-manager'], [
'name' => 'Custom Asset Manager',
'display_name' => 'Custom Asset Manager',
'is_default' => false,
]);
$managerRole->permissions()->syncWithoutDetaching([$managePermission->id]);
$layoutRole = Role::firstOrCreate(['identifier' => 'layout-only'], [
'name' => 'Layout Only',
'display_name' => 'Layout Only',
'is_default' => false,
]);
$layoutRole->permissions()->syncWithoutDetaching([$layoutPermission->id]);
$this->manager = User::factory()->create();
$this->manager->roles()->syncWithoutDetaching([$managerRole->id]);
$this->managerToken = $this->manager->createToken('manager')->plainTextToken;
$this->outsider = User::factory()->create();
$this->outsider->roles()->syncWithoutDetaching([$layoutRole->id]);
$this->outsiderToken = $this->outsider->createToken('outsider')->plainTextToken;
$this->customDir = (string) CustomAssets::directory('templates', self::TEMPLATE);
$this->cleanupCustomDir();
}
protected function tearDown(): void
{
$this->cleanupCustomDir();
parent::tearDown();
}
/**
* 테스트가 만든 파일만 지웁니다 (운영자 파일 보존).
*/
private function cleanupCustomDir(): void
{
foreach (glob($this->customDir.DIRECTORY_SEPARATOR.'__t123-*') ?: [] as $path) {
@unlink($path);
}
@rmdir($this->customDir.DIRECTORY_SEPARATOR.'__t123-dir');
}
/**
* 관리 API URL 을 만듭니다.
*
* 세 타입이 한 엔드포인트를 공유하므로 테스트도 타입을 인자로 받는다 — URL 을 케이스마다
* 손으로 조립하면 한 타입만 고쳐 놓고 나머지는 옛 경로를 두드리게 된다.
*
* @param string $suffix `/custom-assets…` 이후 경로
* @param string $type 확장 타입 (`template` | `module` | `plugin`)
* @param string|null $identifier 확장 식별자 (기본: 대상 템플릿)
* @return string URL
*/
private function url(string $suffix, string $type = 'template', ?string $identifier = null): string
{
return '/api/admin/extensions/'.$type.'/'.($identifier ?? self::TEMPLATE).$suffix;
}
/**
* @return array<string, string>
*/
private function headers(string $token): array
{
return ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'];
}
public function test_저장_조회_목록_삭제_왕복(): void
{
// @scenario manage_actor=with_permission, manage_action=save
// @effects custom_asset_editor_save_invalidates_published_copy
// @scenario manage_actor=with_permission, manage_action=read
// @effects custom_asset_manage_requires_dedicated_permission
// @scenario manage_actor=with_permission, manage_action=delete
// @effects custom_asset_manage_requires_dedicated_permission
$path = '__t123-overrides.css';
$save = $this->putJson(
$this->url('/custom-assets/content'),
['path' => $path, 'content' => 'body { color: red; }'],
$this->headers($this->managerToken)
);
$save->assertStatus(200);
$this->assertFileExists($this->customDir.DIRECTORY_SEPARATOR.$path);
$index = $this->getJson(
$this->url('/custom-assets'),
$this->headers($this->managerToken)
);
$index->assertStatus(200);
$files = $index->json('data.files');
$paths = array_column($files, 'path');
$this->assertContains($path, $paths);
// `loaded` 는 그 파일이 실제로 페이지에 실리는지다. 서술자에 상대 경로 필드가
// 없어 한때 언제나 false 였고, 그러면 화면이 모든 파일을 "실리지 않음" 으로
// 표시해 운영자가 규약 스캔이 고장났다고 읽는다 (E2E 가 먼저 잡았다).
$row = collect($files)->firstWhere('path', $path);
$this->assertTrue($row['loaded'], '규약 스캔이 자동으로 싣는 css 인데 loaded 가 false 입니다.');
$show = $this->getJson(
$this->url('/custom-assets/content?path='.urlencode($path)),
$this->headers($this->managerToken)
);
$show->assertStatus(200);
$this->assertSame('body { color: red; }', $show->json('data.content'));
$delete = $this->deleteJson(
$this->url('/custom-assets?path='.urlencode($path)),
[],
$this->headers($this->managerToken)
);
$delete->assertStatus(200);
$this->assertFileDoesNotExist($this->customDir.DIRECTORY_SEPARATOR.$path);
}
public function test_레이아웃_편집_권한만으로는_접근할_수_없다(): void
{
// 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행된다.
// 레이아웃 편집 권한이 곧 이 권한이 되면 그 확대는 조용히 일어난다.
// @scenario manage_actor=layout_edit_only, manage_action=list
// @effects custom_asset_manage_requires_dedicated_permission
// @scenario manage_actor=layout_edit_only, manage_action=read
// @effects custom_asset_manage_requires_dedicated_permission
// @scenario manage_actor=layout_edit_only, manage_action=save
// @effects custom_asset_manage_requires_dedicated_permission
foreach ([
['getJson', $this->url('/custom-assets'), []],
['getJson', $this->url('/custom-assets/content?path=a.css'), []],
] as [$method, $url, $payload]) {
$response = $this->{$method}($url, $this->headers($this->outsiderToken));
$response->assertStatus(403);
}
$this->putJson(
$this->url('/custom-assets/content'),
['path' => '__t123-evil.css', 'content' => 'x'],
$this->headers($this->outsiderToken)
)->assertStatus(403);
$this->assertFileDoesNotExist($this->customDir.DIRECTORY_SEPARATOR.'__t123-evil.css');
}
public function test_비인증_요청은_거부된다(): void
{
// @scenario manage_actor=unauthenticated, manage_action=list
// @effects custom_asset_manage_requires_dedicated_permission
$this->getJson($this->url('/custom-assets'), ['Accept' => 'application/json'])
->assertStatus(401);
}
/**
* @return array<string, array{0: string}>
*/
public static function 탈출_경로_제공자(): array
{
return [
'상위 디렉토리' => ['../evil.css'],
'중간 상위' => ['a/../../evil.css'],
'절대 경로' => ['/etc/passwd.css'],
'역슬래시 상위' => ['..\\evil.css'],
'빈 세그먼트' => ['a//b.css'],
];
}
/**
* @dataProvider 탈출_경로_제공자
*/
public function test_경로_탈출은_422_로_차단된다(string $path): void
{
// @scenario manage_actor=with_permission, manage_action=save
// @effects custom_asset_manage_path_traversal_blocked
$this->putJson(
$this->url('/custom-assets/content'),
['path' => $path, 'content' => 'x'],
$this->headers($this->managerToken)
)->assertStatus(422);
}
public function test_편집_불가_확장자는_본문_저장이_거부된다(): void
{
// @scenario manage_actor=with_permission, manage_action=save
// @effects custom_asset_manage_binary_rejects_text_save
// 바이너리를 텍스트 편집기에 열어 저장하면 내용이 손상된다 — 그 경로를 막는다.
$this->putJson(
$this->url('/custom-assets/content'),
['path' => '__t123-font.woff2', 'content' => 'x'],
$this->headers($this->managerToken)
)->assertStatus(422);
}
public function test_업로드는_허용_확장자만_받는다(): void
{
// @scenario manage_actor=with_permission, manage_action=upload
// @effects custom_asset_manage_upload_extension_whitelist
$rejected = $this->post(
$this->url('/custom-assets/upload'),
['file' => UploadedFile::fake()->create('__t123-shell.php', 4)],
$this->headers($this->managerToken)
);
$rejected->assertStatus(422);
$this->assertFileDoesNotExist($this->customDir.DIRECTORY_SEPARATOR.'__t123-shell.php');
$accepted = $this->post(
$this->url('/custom-assets/upload'),
['file' => UploadedFile::fake()->image('__t123-logo.png')],
$this->headers($this->managerToken)
);
$accepted->assertStatus(200);
$this->assertFileExists($this->customDir.DIRECTORY_SEPARATOR.'__t123-logo.png');
}
public function test_목록_응답이_편집기_메타를_함께_싣는다(): void
{
// @scenario manage_actor=with_permission, manage_action=list
// @effects custom_asset_manage_requires_dedicated_permission
$response = $this->getJson(
$this->url('/custom-assets'),
$this->headers($this->managerToken)
);
$response->assertStatus(200);
$this->assertSame(CustomAssetService::EDITABLE_EXTENSIONS, $response->json('data.editable_extensions'));
$this->assertSame(CustomAssetService::MAX_TEXT_BYTES, $response->json('data.max_text_bytes'));
$this->assertSame(CustomAssetService::MAX_UPLOAD_BYTES, $response->json('data.max_upload_bytes'));
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function 확장_타입_제공자(): array
{
return [
'템플릿' => ['template', self::TEMPLATE],
'모듈' => ['module', 'sirsoft-page'],
'플러그인' => ['plugin', 'sirsoft-gdpr'],
];
}
/**
* 세 타입이 같은 강도로 동작해야 한다.
*
* 타입별로 경로·검증이 갈리면 그중 약한 쪽이 조용한 우회로가 되고, 반대로 한 타입만
* 동작하면 운영자는 "모듈에서는 왜 안 되는지" 를 알 수 없다.
*
* @dataProvider 확장_타입_제공자
*/
public function test_세_타입_모두_같은_왕복을_지원한다(string $type, string $identifier): void
{
// @scenario manage_actor=with_permission, manage_action=save
// @effects custom_asset_manage_all_extension_types_parity
$dir = (string) CustomAssets::directory($type.'s', $identifier);
$path = '__t123-parity.css';
try {
$this->putJson(
$this->url('/custom-assets/content', $type, $identifier),
['path' => $path, 'content' => 'body { color: blue; }'],
$this->headers($this->managerToken)
)->assertStatus(200);
$this->assertFileExists($dir.DIRECTORY_SEPARATOR.$path);
$index = $this->getJson(
$this->url('/custom-assets', $type, $identifier),
$this->headers($this->managerToken)
);
$index->assertStatus(200);
$this->assertContains($path, array_column($index->json('data.files'), 'path'));
// 권한 경계도 타입과 무관하게 같아야 한다.
//
// 한 테스트 안에서 토큰을 바꿔 요청하려면 가드를 먼저 잊어야 한다 — 앱이
// 요청 사이에 재부팅되지 않아 `auth:sanctum` 가드가 앞 요청에서 해석한
// 사용자를 그대로 재사용하고, 그러면 권한이 없는 토큰이 200 을 받는다
// (제품 결함이 아니라 테스트 하네스의 성질이다).
$this->app['auth']->forgetGuards();
$this->getJson(
$this->url('/custom-assets', $type, $identifier),
$this->headers($this->outsiderToken)
)->assertStatus(403);
$this->app['auth']->forgetGuards();
$this->deleteJson(
$this->url('/custom-assets?path='.urlencode($path), $type, $identifier),
[],
$this->headers($this->managerToken)
)->assertStatus(200);
$this->assertFileDoesNotExist($dir.DIRECTORY_SEPARATOR.$path);
} finally {
@unlink($dir.DIRECTORY_SEPARATOR.$path);
@rmdir($dir);
}
}
public function test_알_수_없는_확장_타입은_라우트가_받지_않는다(): void
{
// 정규식이 세 값으로 제한하므로 404 다 — 통과시켜 빈 목록으로 응답하면 오타가
// "그 확장에는 파일이 없다" 로 보인다.
$this->getJson(
'/api/admin/extensions/theme/'.self::TEMPLATE.'/custom-assets',
$this->headers($this->managerToken)
)->assertStatus(404);
}
public function test_저장이_확장_캐시_버전을_올린다(): void
{
// 올리지 않으면 편집한 파일이 게시본에 반영되지 않아, 운영자에게는
// "고쳤는데 화면이 그대로" 로만 나타난다.
$before = ExtensionStaticCacheService::getExtensionCacheVersion();
// 버전은 초 단위 timestamp 라 같은 초에 저장하면 값이 같을 수 있다 — 값 비교가
// 아니라 "덮어써졌는지" 를 보기 위해 과거 값을 심어 둔다.
app(CacheInterface::class)->put('ext.cache_version', 1);
$this->putJson(
$this->url('/custom-assets/content'),
['path' => '__t123-bump.css', 'content' => 'body{}'],
$this->headers($this->managerToken)
)->assertStatus(200);
$after = (int) app(CacheInterface::class)->get('ext.cache_version');
$this->assertGreaterThan(1, $after);
$this->assertGreaterThan(0, $before);
}
}
@@ -0,0 +1,318 @@
<?php
namespace Tests\Feature\Extension;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 구동 에셋의 외부 CDN 의존 전수 차단
*
* 브라우저가 화면을 그리기 위해 제3자 origin 에 도달해야 하면, 그 도달 실패는 예외도
* 로그도 남기지 않고 **화면 기능만 조용히 사라진다.** 폐쇄망·방화벽·광고차단기에서
* 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
*
* 판정 모집단을 한 확장 안에 두지 않고 **코어까지 넓힌다** — 판정기를 한 확장에 두면
* 그 확장 밖의 동형 결함이 red 가 되지 않는다(`GenericCatchStatusCodeContractTest` 선례).
*
* 예외는 두 가지뿐이다:
* 1. 확장 manifest 가 `trusted_script_hosts` + `trusted_script_hosts_reason` 을 **함께**
* 선언한 호스트 (자체 호스팅이 불가능한 서비스 SDK 등)
* 2. 이 테스트가 사유와 함께 명시한 코어 예외 (운영자가 켜야만 출력되는 외부 분석 등)
*
* DB 를 쓰지 않는다 — 저장소 소스 스캔이다.
*/
class BundledExtensionNoExternalAssetTest extends TestCase
{
/**
* 자산으로 간주하는 확장자
*
* API 엔드포인트·문서 링크를 결함으로 오인하지 않도록 **브라우저가 실행/적용하는
* 파일**만 본다.
*/
private const ASSET_EXTENSIONS = 'js|mjs|css|woff2?|ttf|otf|eot';
/**
* 코어의 명시 예외 (사유 필수)
*
* @var array<string, string>
*/
private const CORE_EXEMPTIONS = [
// 운영자가 seo.google_analytics_id 를 설정했을 때만 출력되는 외부 분석 서비스다.
// 자체 제공 대상이 아니며 gdpr 차단 카탈로그가 다루는 영역이다.
'googletagmanager.com' => '운영자 설정 시에만 출력되는 외부 분석 서비스',
'google-analytics.com' => '운영자 설정 시에만 출력되는 외부 분석 서비스',
];
/**
* 스캔 대상 파일 목록을 만듭니다.
*
* @return array<int, string> 절대 경로 목록
*/
private function runtimeSources(): array
{
$patterns = [
// 번들 확장 런타임 소스
'modules/_bundled/*/resources/**/*.{ts,tsx,json}',
'modules/_bundled/*/src/**/*.{ts,tsx}',
'plugins/_bundled/*/resources/**/*.{ts,tsx,json}',
'plugins/_bundled/*/src/**/*.{ts,tsx}',
'templates/_bundled/*/src/**/*.{ts,tsx}',
'templates/_bundled/*/layouts/**/*.json',
'templates/_bundled/*/template.json',
'templates/_bundled/*/seo-config.json',
// 코어 런타임 소스
'resources/js/core/**/*.{ts,tsx}',
'resources/layouts/**/*.json',
'resources/views/*.blade.php',
];
$files = [];
foreach ($patterns as $pattern) {
foreach (glob(base_path($pattern), GLOB_BRACE) as $path) {
$files[] = $path;
}
// glob 은 `**` 를 재귀로 해석하지 않으므로 디렉토리 재귀를 따로 돈다
if (str_contains($pattern, '**')) {
$files = array_merge($files, $this->globRecursive($pattern));
}
}
// 설치 마법사 (SPA 부팅 전 화면)
$files[] = base_path('public/install/index.php');
// 테스트 파일은 제외한다 — 차단 대상 URL 을 픽스처로 담는 것이 그 테스트의 일이다
$files = array_filter($files, function (string $path): bool {
$normalized = str_replace('\\', '/', $path);
return is_file($path)
&& ! str_contains($normalized, '/__tests__/')
&& ! str_contains($normalized, '/tests/');
});
return array_values(array_unique($files));
}
/**
* `**` 를 포함한 패턴을 재귀적으로 해석합니다.
*
* @param string $pattern 상대 패턴
* @return array<int, string> 절대 경로 목록
*/
private function globRecursive(string $pattern): array
{
[$prefix, $suffix] = explode('**', $pattern, 2);
$suffix = ltrim($suffix, '/');
$results = [];
foreach (glob(base_path($prefix), GLOB_ONLYDIR | GLOB_BRACE) as $root) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
if (fnmatch($suffix, $file->getBasename(), FNM_CASEFOLD)
|| fnmatch(basename($suffix), $file->getBasename(), FNM_CASEFOLD)) {
$results[] = $file->getPathname();
}
}
}
return $results;
}
/**
* 파일이 속한 확장의 manifest 에서 사유가 선언된 신뢰 호스트를 읽습니다.
*
* @param string $path 파일 절대 경로
* @return array<int, string> 사유가 함께 선언된 호스트 목록
*/
private function declaredHostsWithReason(string $path): array
{
$relative = str_replace('\\', '/', substr($path, strlen(base_path()) + 1));
if (! preg_match('#^(modules|plugins|templates)/_bundled/([^/]+)/#', $relative, $m)) {
return [];
}
$manifestName = match ($m[1]) {
'modules' => 'module.json',
'plugins' => 'plugin.json',
default => 'template.json',
};
$manifestPath = base_path("{$m[1]}/_bundled/{$m[2]}/{$manifestName}");
if (! is_file($manifestPath)) {
return [];
}
$manifest = json_decode((string) file_get_contents($manifestPath), true) ?: [];
$hosts = $manifest['trusted_script_hosts'] ?? [];
$reasons = $manifest['trusted_script_hosts_reason'] ?? [];
if (! is_array($hosts) || ! is_array($reasons)) {
return [];
}
// 사유 없이 선언된 호스트는 예외로 인정하지 않는다 — 왜 외부로 나가는지가
// 코드에 남아야 "자체 제공이 원칙, 예외는 근거와 함께" 가 강제된다.
return array_values(array_filter(
$hosts,
fn ($host) => is_string($host) && ! empty($reasons[$host])
));
}
/**
* @scenario asset_class=vendored, outcome=loaded
* @scenario asset_class=translation, outcome=loaded
* @scenario asset_class=service_sdk, outcome=loaded
*
* @effects no_third_party_request_on_page_load, runtime_asset_served_same_origin
*/
#[Test]
public function 런타임_소스에_외부_자산_ur_l_이_없다(): void
{
// 구분자로 `~` 를 쓴다 — 패턴 안에 URL 프래그먼트(`#`)가 들어가 `#` 구분자와 충돌한다
// 확장자 뒤에 경계를 둔다 — 새 탭으로 여는 외부 페이지(`.jsp`)가 `.js` 로 오탐되면
// 자산이 아닌 링크를 결함으로 세게 된다 (KG이니시스 영수증 링크가 그 예다).
$pattern = '~(?:https?:)?//([A-Za-z0-9._-]+)/[^"\'\s)]*\.(?:'.self::ASSET_EXTENSIONS.')(?![A-Za-z0-9])(?:[?\#][^"\'\s)]*)?~i';
$violations = [];
foreach ($this->runtimeSources() as $path) {
$contents = (string) file_get_contents($path);
if (! preg_match_all($pattern, $contents, $matches, PREG_SET_ORDER)) {
continue;
}
$allowedHosts = array_merge(
$this->declaredHostsWithReason($path),
array_keys(self::CORE_EXEMPTIONS)
);
foreach ($matches as $match) {
[$url, $host] = $match;
if (in_array($host, $allowedHosts, true)) {
continue;
}
// 주석에 남은 설명 문구는 대상이 아니다 — 실제 로드 경로만 본다
if ($this->isInsideComment($contents, $url)) {
continue;
}
$violations[] = str_replace('\\', '/', substr($path, strlen(base_path()) + 1)).' → '.$url;
}
}
$this->assertSame(
[],
array_values(array_unique($violations)),
"구동 자산을 외부 origin 에서 불러오는 지점이 남아 있습니다.\n"
."자체 제공으로 바꾸거나, 자체 제공이 불가능하면 manifest 에\n"
."`trusted_script_hosts` + `trusted_script_hosts_reason` 을 함께 선언하세요.\n"
.implode("\n", array_unique($violations))
);
}
/**
* URL 이 주석 안에 있는지 판정합니다.
*
* @param string $contents 파일 내용
* @param string $url 검출된 URL
* @return bool 주석 안이면 true
*/
private function isInsideComment(string $contents, string $url): bool
{
foreach (explode("\n", $contents) as $line) {
if (! str_contains($line, $url)) {
continue;
}
$trimmed = ltrim($line);
if (str_starts_with($trimmed, '//')
|| str_starts_with($trimmed, '*')
|| str_starts_with($trimmed, '/*')
|| str_starts_with($trimmed, '#')) {
continue;
}
return false;
}
return true;
}
#[Test]
public function 신뢰_호스트를_선언한_확장은_사유도_함께_선언한다(): void
{
$missing = [];
foreach (['modules' => 'module.json', 'plugins' => 'plugin.json', 'templates' => 'template.json'] as $type => $name) {
foreach (glob(base_path("{$type}/_bundled/*/{$name}")) as $manifestPath) {
$manifest = json_decode((string) file_get_contents($manifestPath), true) ?: [];
$hosts = $manifest['trusted_script_hosts'] ?? [];
if (! is_array($hosts) || $hosts === []) {
continue;
}
$reasons = $manifest['trusted_script_hosts_reason'] ?? [];
foreach ($hosts as $host) {
if (empty($reasons[$host])) {
$missing[] = str_replace('\\', '/', substr($manifestPath, strlen(base_path()) + 1)).' → '.$host;
}
}
}
}
$this->assertSame(
[],
$missing,
"외부 호스트를 신뢰 출처로 선언했으면 그 사유도 manifest 에 남겨야 합니다.\n"
.'`trusted_script_hosts_reason` 에 호스트별 사유를 적으세요.'."\n"
.implode("\n", $missing)
);
}
/**
* @effects template_external_asset_field_resolves_to_asset_url
*/
#[Test]
public function 템플릿_externals_는_자체_제공_경로를_쓴다(): void
{
$violations = [];
foreach (glob(base_path('templates/_bundled/*/template.json')) as $manifestPath) {
$manifest = json_decode((string) file_get_contents($manifestPath), true) ?: [];
foreach ($manifest['externals'] ?? [] as $external) {
if (! is_array($external) || ! isset($external['url'])) {
continue;
}
if (preg_match('#^(https?:)?//#i', (string) $external['url']) === 1) {
$violations[] = str_replace('\\', '/', substr($manifestPath, strlen(base_path()) + 1))
.' → '.$external['id'].' ('.$external['url'].')';
}
}
}
$this->assertSame(
[],
$violations,
"템플릿 externals 는 `asset`(자체 제공 경로) 으로 선언해야 합니다.\n"
.implode("\n", $violations)
);
}
}
@@ -0,0 +1,215 @@
<?php
namespace Tests\Feature\Extension;
use App\Extension\Helpers\ExtensionPendingHelper;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
use Tests\TestCase;
/**
* 사용자 추가 에셋(`custom/`)의 확장 교체 생존 테스트
*
* `custom/` 은 운영자 소유 자리다. 확장 업데이트가 그것을 덮어쓰면 업데이트할 때마다
* 운영자가 넣은 파일이 사라지고, 그 사실이 어디에도 남지 않는다 — 파일이 조용히
* 없어질 뿐이다. 포럼 질문("custom.css 를 어디에 두나요")의 커뮤니티 답변대로
* `src/styles/` 에 넣으면 정확히 그 일이 벌어진다.
*
* 교체 경로가 **둘**이라는 점이 핵심이다:
* 1. 디렉토리 rename (정상 경로)
* 2. 제자리 파일 동기화 (Windows 에서 하위 트리에 열린 핸들이 있어 rename 이 막힐 때)
*
* 한쪽만 보존하면 잠금 상황에서만 조용히 사라진다 — 재현이 어려운 결함이 된다.
*
* DB 를 쓰지 않는다 — 파일 교체 동작만 검증한다.
*/
class CustomAssetPreservationTest extends TestCase
{
/** 테스트 작업 디렉토리 */
private string $workspace;
/** 기존 활성 디렉토리 */
private string $activePath;
/** 새 배포본 소스 */
private string $sourcePath;
protected function setUp(): void
{
parent::setUp();
$this->workspace = storage_path('framework/testing/custom-preserve-'.uniqid());
$this->activePath = $this->workspace.'/plugins/g7test-preserve';
$this->sourcePath = $this->workspace.'/plugins/_bundled/g7test-preserve';
File::ensureDirectoryExists($this->activePath.'/custom');
File::ensureDirectoryExists($this->activePath.'/dist/js');
File::ensureDirectoryExists($this->sourcePath.'/dist/js');
// 운영자가 넣은 파일
File::put($this->activePath.'/custom/custom.css', 'body{--operator:1}');
File::put($this->activePath.'/custom/assets.json', '{"assets":[]}');
// 구버전 산출물 (교체 시 정리되어야 한다)
File::put($this->activePath.'/dist/js/old-hash.js', '// old');
// 새 배포본
File::put($this->sourcePath.'/dist/js/new-hash.js', '// new');
File::put($this->sourcePath.'/plugin.json', '{"identifier":"g7test-preserve"}');
}
protected function tearDown(): void
{
File::deleteDirectory($this->workspace);
parent::tearDown();
}
/**
* 보존 결과를 단언합니다.
*
* @return void
*/
private function assertPreserved(): void
{
$this->assertFileExists(
$this->activePath.'/custom/custom.css',
'운영자가 넣은 파일이 확장 교체에 사라졌습니다.'
);
$this->assertSame(
'body{--operator:1}',
File::get($this->activePath.'/custom/custom.css'),
'운영자 파일의 내용이 새 배포본으로 덮어써졌습니다.'
);
$this->assertFileExists($this->activePath.'/custom/assets.json');
}
/**
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_dir_survives_extension_update
*/
#[Test]
public function rename_경로에서_운영자_파일이_보존된다(): void
{
ExtensionPendingHelper::copyToActive($this->sourcePath, $this->activePath);
$this->assertPreserved();
$this->assertFileExists($this->activePath.'/dist/js/new-hash.js');
}
#[Test]
public function rename_경로에서도_구버전_산출물은_정리된다(): void
{
// 보존 계층이 stale 산출물 청소를 무력화하면 배포본이 계속 부푼다
ExtensionPendingHelper::copyToActive($this->sourcePath, $this->activePath);
$this->assertFileDoesNotExist($this->activePath.'/dist/js/old-hash.js');
}
/**
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_dir_survives_inplace_sync_fallback
*/
#[Test]
public function 제자리_동기화_폴백_경로에서도_운영자_파일이_보존된다(): void
{
// rename 이 막히는 상황(Windows 잠금)의 폴백 경로를 직접 태운다
$sync = new ReflectionMethod(ExtensionPendingHelper::class, 'syncDirectoryContents');
$sync->setAccessible(true);
// 스테이징에는 새 배포본 + 운영자 파일이 함께 있어야 한다 (copyToActive 가 그렇게 만든다)
$staging = $this->workspace.'/staging';
File::copyDirectory($this->sourcePath, $staging);
File::copyDirectory($this->activePath.'/custom', $staging.'/custom');
$sync->invoke(null, $staging, $this->activePath, null);
$this->assertPreserved();
$this->assertFileExists($this->activePath.'/dist/js/new-hash.js');
$this->assertFileDoesNotExist($this->activePath.'/dist/js/old-hash.js');
}
#[Test]
public function 스테이징에_운영자_디렉토리가_없어도_정리_대상이_되지_않는다(): void
{
// 소스에 없는 항목을 지우는 정리 로직이 custom/ 을 지우면 안 된다
$stale = new ReflectionMethod(ExtensionPendingHelper::class, 'removeStaleEntries');
$stale->setAccessible(true);
$failures = [];
$stale->invokeArgs(null, [$this->sourcePath, $this->activePath, &$failures, true]);
$this->assertPreserved();
}
#[Test]
public function 새_배포본이_같은_이름의_디렉토리를_담아도_운영자_파일이_우선한다(): void
{
File::ensureDirectoryExists($this->sourcePath.'/custom');
File::put($this->sourcePath.'/custom/custom.css', 'body{--from-vendor:1}');
ExtensionPendingHelper::copyToActive($this->sourcePath, $this->activePath);
$this->assertPreserved();
}
#[Test]
public function 대상_디렉토리가_없으면_그냥_복사한다(): void
{
$fresh = $this->workspace.'/plugins/g7test-fresh';
ExtensionPendingHelper::copyToActive($this->sourcePath, $fresh);
$this->assertFileExists($fresh.'/dist/js/new-hash.js');
$this->assertDirectoryDoesNotExist($fresh.'/custom');
}
/**
* 삭제는 보존이 아니라 보관이다 — 그리고 보관 사실은 호출자에게 도달해야 한다.
*
* 사본 경로가 로그에만 남으면 운영자는 로그를 뒤지지 않는 한 사본의 존재를 모른다.
* 그러면 "확장을 지웠더니 내 CSS 가 사라졌다" 와 구분되지 않는다.
*
* @effects custom_dir_archived_on_uninstall
*/
#[Test]
public function 삭제는_운영자_파일을_보관하고_그_경로를_반환한다(): void
{
$archived = ExtensionPendingHelper::deleteExtensionDirectory(
$this->workspace.'/plugins',
'g7test-preserve'
);
// 확장은 실제로 지워진다 — 보존이 아니라 보관이다.
$this->assertDirectoryDoesNotExist($this->activePath);
// 보관 경로가 반환값으로 올라온다.
$this->assertCount(1, $archived, '보관된 디렉토리 1건이 반환되어야 한다');
$this->assertSame('custom', $archived[0]['directory']);
// 반환된 경로에 운영자 파일이 실제로 있다 — 경로만 돌려주고 사본이 없으면 무의미하다.
$this->assertFileExists($archived[0]['archive'].'/custom.css');
$this->assertSame('body{--operator:1}', File::get($archived[0]['archive'].'/custom.css'));
File::deleteDirectory(dirname($archived[0]['archive']));
}
/**
* 보관할 것이 없으면 빈 배열이다 — 호출부가 "사본이 있다" 고 오인해선 안 된다.
*/
#[Test]
public function 운영자_파일이_없으면_보관_결과가_비어_있다(): void
{
File::deleteDirectory($this->activePath.'/custom');
$archived = ExtensionPendingHelper::deleteExtensionDirectory(
$this->workspace.'/plugins',
'g7test-preserve'
);
$this->assertSame([], $archived);
}
}
@@ -0,0 +1,159 @@
<?php
namespace Tests\Feature\Extension;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* 회귀: 실패한 설치가 활성 디렉토리를 고아로 남기지 않는다
*
* 설치 흐름은 원본(`_pending`/`_bundled`)을 활성 디렉토리로 **먼저 복사한 뒤** 확장을
* 로드해 코어 버전·의존성·언어 경로를 검증한다. 검증에 로드된 인스턴스가 필요해 순서를
* 뒤집을 수 없는데, 검증이 실패하면 방금 만든 디렉토리가 그대로 남았다.
*
* 남은 디렉토리는 DB 행이 없어 `plugin:list` 에도 뜨지 않고 번들 병합에도 참여하지 않는다.
* 오류도 경고도 없이 디스크만 점유하는 고아이고, 다음 설치 시도가 그것을 "이미 있는
* 설치본" 으로 보고 원본 복사를 건너뛸 수 있다.
*/
class FailedInstallRollsBackActiveDirectoryTest extends TestCase
{
private string $sandbox;
protected function setUp(): void
{
parent::setUp();
$this->sandbox = storage_path('framework/testing/install-rollback-'.uniqid());
File::ensureDirectoryExists($this->sandbox);
}
protected function tearDown(): void
{
File::deleteDirectory($this->sandbox);
parent::tearDown();
}
/**
* 이번 설치가 만든 디렉토리는 제거한다.
*/
public function test_directory_created_by_this_install_is_removed(): void
{
$active = $this->sandbox.DIRECTORY_SEPARATOR.'vendor-sample';
File::ensureDirectoryExists($active);
File::put($active.DIRECTORY_SEPARATOR.'plugin.json', '{}');
$removed = ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$active,
existedBefore: false,
identifier: 'vendor-sample',
type: 'plugin',
);
$this->assertTrue($removed);
$this->assertDirectoryDoesNotExist($active);
}
/**
* 이미 있던 설치본은 건드리지 않는다.
*
* `--force` 재설치가 검증에서 실패한 경우다. 그 디렉토리는 운영자의 기존 설치본이므로
* 여기서 지우면 실패한 재설치가 멀쩡히 돌던 확장을 없애버린다.
*/
public function test_preexisting_directory_is_left_untouched(): void
{
$active = $this->sandbox.DIRECTORY_SEPARATOR.'vendor-existing';
File::ensureDirectoryExists($active);
File::put($active.DIRECTORY_SEPARATOR.'plugin.json', '{"keep":true}');
$removed = ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$active,
existedBefore: true,
identifier: 'vendor-existing',
type: 'plugin',
);
$this->assertFalse($removed);
$this->assertDirectoryExists($active);
$this->assertStringContainsString('keep', File::get($active.DIRECTORY_SEPARATOR.'plugin.json'));
}
/**
* 디렉토리가 아예 없으면 조용히 넘어간다 (복사 이전에 실패한 경우).
*/
public function test_absent_directory_is_a_no_op(): void
{
$this->assertFalse(ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$this->sandbox.DIRECTORY_SEPARATOR.'never-created',
existedBefore: false,
identifier: 'never-created',
type: 'module',
));
}
/**
* 세 관리자의 설치 경로가 모두 이 되돌리기를 경유한다.
*
* 한 곳만 배선하면 나머지 두 유형에서 같은 고아가 계속 생기고, 그 사실은 오류로
* 드러나지 않는다. 배선이 나중에 조용히 빠지는 것도 여기서 막는다.
*
* @return void
*/
public function test_all_three_managers_wire_the_rollback(): void
{
$targets = [
[app_path('Extension/PluginManager.php'), 'installPlugin'],
[app_path('Extension/ModuleManager.php'), 'installModule'],
[app_path('Extension/TemplateManager.php'), 'installTemplate'],
];
foreach ($targets as [$path, $method]) {
$source = File::get($path);
$body = $this->methodBody($source, $method);
$this->assertNotSame('', $body, "{$method} 본문을 찾지 못했습니다.");
$this->assertStringContainsString(
'ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall',
$body,
"{$method} 이 실패 시 활성 디렉토리를 되돌리지 않습니다."
);
}
}
/**
* 소스에서 메서드 본문을 잘라냅니다 (중괄호 균형).
*
* @param string $source PHP 소스 전문
* @param string $method 메서드명
* @return string 본문 (못 찾으면 빈 문자열)
*/
private function methodBody(string $source, string $method): string
{
$start = strpos($source, 'public function '.$method.'(');
if ($start === false) {
return '';
}
$depth = 0;
$began = false;
$length = strlen($source);
for ($i = $start; $i < $length; $i++) {
if ($source[$i] === '{') {
$depth++;
$began = true;
} elseif ($source[$i] === '}') {
$depth--;
if ($began && $depth === 0) {
return substr($source, $start, $i - $start + 1);
}
}
}
return '';
}
}
@@ -0,0 +1,438 @@
<?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
{
$workspace = storage_path('framework/testing/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;
/**
* 정리를 실행합니다.
*
* @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;
}
}
@@ -433,4 +433,116 @@ class ModuleAssetServingTest extends TestCase
// Assert: 200 서빙이 아니어야 한다 (검증 실패 → 리다이렉트)
$this->assertNotSame(200, $browser->getStatusCode());
}
/**
* 운영자가 넣은 `custom/` 자산도 서빙된다.
*
* 모듈 자산 경로는 확장 루트 기준이라 `custom/` 이 별도 분기 없이 해석된다
* (템플릿만 `dist/` 하드코딩이라 분기가 필요했다). 그 사실을 실제 HTTP 로 고정한다 —
* 서비스 반환값만 보면 라우트·FormRequest 게이트를 통과하는지가 남는다.
*
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_loaded_after_extension_bundles
*/
public function test_serves_operator_custom_css_from_active_module(): void
{
// Arrange
mkdir($this->testModulePath.'/custom', 0755, true);
file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }');
// Act
$response = $this->get('/api/modules/assets/test-module/custom/custom.css');
// Assert
$response->assertStatus(200);
$this->assertTrue(
str_starts_with($response->headers->get('Content-Type'), 'text/css'),
'Content-Type should start with text/css'
);
// BinaryFileResponse 는 스트리밍이라 본문이 버퍼에 없다 — 어떤 파일을 서빙했는지로
// 확인한다. dist/ 의 동명 파일이 아니라 custom/ 의 파일이어야 한다.
$served = $response->baseResponse->getFile()->getRealPath();
$this->assertSame(
realpath($this->testModulePath.'/custom/custom.css'),
$served,
'custom/ 의 파일이 서빙되어야 한다'
);
}
/**
* 비활성 모듈의 `custom/` 은 서빙되지 않는다.
*
* 비활성 확장의 자산이 계속 응답하면 "껐는데 스타일이 남아 있다" 가 된다.
*
* @scenario custom_source=convention_scan
*
* @effects inactive_extension_custom_not_served
*/
public function test_returns_404_for_custom_asset_of_inactive_module(): void
{
// Arrange
mkdir($this->testModulePath.'/custom', 0755, true);
file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }');
$this->activeModule->update(['status' => ExtensionStatus::Inactive->value]);
// Act
$response = $this->get('/api/modules/assets/test-module/custom/custom.css');
// Assert
$response->assertStatus(404);
}
/**
* `custom/` 도 확장자 화이트리스트를 그대로 받는다 — 신규 확장자를 열지 않는다.
*
* @scenario custom_source=convention_scan, custom_asset=static_file
*
* @effects custom_disallowed_extension_blocked
*/
public function test_blocks_disallowed_file_type_under_custom(): void
{
// Arrange
mkdir($this->testModulePath.'/custom', 0755, true);
file_put_contents($this->testModulePath.'/custom/notes.txt', 'not an asset');
// Act
$response = $this->getJson('/api/modules/assets/test-module/custom/notes.txt');
// Assert: FormRequest 의 확장자 게이트가 거부한다 (422)
$response->assertStatus(422);
$response->assertJsonValidationErrors('path');
$this->assertStringNotContainsString('not an asset', $response->getContent());
}
/**
* `custom/` 자산도 ETag 로 304 를 돌려준다 — 운영자 파일이라고 캐시 계약이 다르지 않다.
*
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_url_busts_on_file_change
*/
public function test_returns_304_for_custom_asset_when_etag_matches(): void
{
// Arrange
mkdir($this->testModulePath.'/custom', 0755, true);
file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }');
$first = $this->get('/api/modules/assets/test-module/custom/custom.css');
$first->assertStatus(200);
$etag = $first->headers->get('ETag');
$this->assertNotNull($etag, 'custom 자산에도 ETag 가 있어야 한다');
// Act
$second = $this->get(
'/api/modules/assets/test-module/custom/custom.css',
['If-None-Match' => $etag]
);
// Assert
$second->assertStatus(304);
}
}
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Permission;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Services\CoreUpdateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 커스텀 자산 관리 권한(D34)의 정의·전파 테스트
*
* 신규 권한은 별도 업그레이드 스텝 없이 코어 업데이트의 표준 동기화
* (`CoreUpdateService::syncCoreRolesAndPermissions`)로 기설치 사이트에 도달한다.
* 그 전제가 사실인지를 여기서 잠근다 — 사실이 아니면 신규 권한이 정의만 되고
* 아무에게도 부여되지 않아, 화면은 정상인데 **모두 403** 이 된다.
*/
class CustomAssetPermissionSyncTest extends TestCase
{
use RefreshDatabase;
private const IDENTIFIER = 'core.extensions.custom_assets.manage';
#[Test]
public function 권한이_확장_공통_그룹에_정의되어_있다(): void
{
// 확장 타입을 가리지 않는 단일 권한이므로 특정 타입 그룹(core.templates 등)에
// 두지 않는다. 한 타입 그룹에 있으면 나머지 두 타입에도 적용된다는 사실이
// 화면 어디에도 드러나지 않는다.
$categories = config('core.permissions.categories', []);
$group = null;
foreach ($categories as $category) {
if (($category['identifier'] ?? null) === 'core.extensions') {
$group = $category;
break;
}
}
$this->assertNotNull($group, 'core.extensions 권한 그룹이 없습니다.');
$this->assertContains(self::IDENTIFIER, array_column($group['permissions'] ?? [], 'identifier'));
}
#[Test]
public function 타입별_권한으로_쪼개지_않는다(): void
{
// 쪼개면 운영자가 셋을 다 부여해야 하고, "모듈 CSS 는 되는데 템플릿 CSS 는 안 되는"
// 상태가 실질적 의미 없이 생긴다.
$all = [];
foreach (config('core.permissions.categories', []) as $category) {
foreach ($category['permissions'] ?? [] as $permission) {
$all[] = $permission['identifier'];
}
}
foreach (['core.templates', 'core.modules', 'core.plugins'] as $prefix) {
$this->assertNotContains($prefix.'.custom_assets.manage', $all);
}
}
#[Test]
public function 레이아웃_편집_권한과_별개의_식별자다(): void
{
// 같은 권한을 재사용하면 "레이아웃을 고칠 수 있다" 가 곧 "사이트 전역 스크립트를
// 올릴 수 있다" 가 된다. 그 확대는 오류 없이 일어난다.
$this->assertNotSame('core.templates.layouts.edit', self::IDENTIFIER);
}
#[Test]
public function 표준_동기화가_권한을_만들고_관리자_역할에_부여한다(): void
{
// @scenario manage_actor=with_permission, manage_action=list
// @effects custom_asset_permission_reaches_existing_sites_via_core_sync
app(CoreUpdateService::class)->syncCoreRolesAndPermissions();
$permission = Permission::where('identifier', self::IDENTIFIER)->first();
$this->assertNotNull($permission, '표준 동기화가 신규 권한을 만들지 않았습니다.');
$this->assertSame(ExtensionOwnerType::Core, $permission->extension_type);
$admin = Role::where('identifier', 'admin')->first();
$this->assertNotNull($admin);
$this->assertTrue(
$admin->permissions()->where('identifier', self::IDENTIFIER)->exists(),
'관리자 역할(all_leaf)에 신규 권한이 부여되지 않았습니다.'
);
}
}
@@ -2,8 +2,12 @@
namespace Tests\Feature\Services;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Models\Module;
use App\Models\Plugin;
use App\Models\Template;
use App\Services\ExtensionBundleService;
use App\Services\ExtensionStaticCacheService;
@@ -71,11 +75,73 @@ class ExtensionStaticCacheServiceTest extends TestCase
File::deleteDirectory(public_path('build/ext'));
}
/**
* 게시 실패 시 어느 분기에서 멈췄는지 보여 줍니다.
*
* `publishCurrent()` 는 여러 사유로 **조용히 false** 를 돌려준다(kill-switch·락 미획득·
* 게시 중 예외). 단언 메시지가 "false is true" 뿐이면 그중 무엇인지 알 수 없어, 간헐적
* 실패를 만났을 때 재현부터 다시 해야 한다.
*
* @param ExtensionStaticCacheService $svc 대상 서비스
* @return string 진단 문자열
*/
private function publishDiagnostics(ExtensionStaticCacheService $svc): string
{
$v = ExtensionStaticCacheService::getExtensionCacheVersion();
$lock = Cache::lock('ext-static.publish.'.$v, 1);
$free = $lock->get();
if ($free) {
$lock->release();
}
$store = config('cache.default');
return sprintf(
'enabled=%s version=%d expected=%d lockFree=%s store=%s published=%s baseDir=%s',
var_export($svc->isEnabled(), true), $v, self::VERSION, var_export($free, true),
$store, var_export($svc->isPublished($v), true), $svc->baseDir()
);
}
private function service(): ExtensionStaticCacheService
{
return app(ExtensionStaticCacheService::class);
}
/**
* 활성 모듈·플러그인 행을 만듭니다.
*
* 레포지토리를 모킹하지 않는다 — 게시에는 번들 서비스 등 같은 레포지토리를 쓰는 다른
* 소비자가 있어, 인터페이스를 통째로 대체하면 그쪽이 끊겨 게시 자체가 실패한다.
* `RefreshDatabase` 라 행은 테스트마다 비어 있으므로 실제 설치본에 좌우되지도 않는다.
*
* @param array<int, string> $modules 활성 모듈 식별자
* @param array<int, string> $plugins 활성 플러그인 식별자
*/
private function createActiveExtensions(array $modules, array $plugins): void
{
foreach ($modules as $identifier) {
Module::create([
'identifier' => $identifier,
'vendor' => explode('-', $identifier)[0],
'name' => ['ko' => '테스트 모듈', 'en' => 'Test Module'],
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트', 'en' => 'Test'],
]);
}
foreach ($plugins as $identifier) {
Plugin::create([
'identifier' => $identifier,
'vendor' => explode('-', $identifier)[0],
'name' => ['ko' => '테스트 플러그인', 'en' => 'Test Plugin'],
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트', 'en' => 'Test'],
]);
}
}
private function createActiveTemplate(string $identifier = 'sirsoft-admin_basic', string $type = 'admin'): Template
{
return Template::create([
@@ -98,7 +164,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$this->assertTrue($service->isPublished(self::VERSION));
$this->assertFileExists($service->versionDir(self::VERSION).'/manifest.json');
$this->assertFileExists($service->versionDir(self::VERSION).'/.htaccess');
@@ -114,7 +180,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
$this->createActiveTemplate();
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$publishedPath = $service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic/lang/ko.json';
$this->assertFileExists($publishedPath);
@@ -140,7 +206,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
$this->createActiveTemplate();
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$publishedPath = $service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic/routes.json';
$this->assertFileExists($publishedPath);
@@ -155,6 +221,89 @@ class ExtensionStaticCacheServiceTest extends TestCase
$this->assertSame($apiResponse->json('data'), $published['data']);
}
/**
* 활성 모듈·플러그인의 운영자 소유 디렉토리(`custom/`)도 게시된다.
*
* 모듈·플러그인 빌드 산출물은 **병합 번들**로만 게시되는데 `custom/` 은 그 번들에
* 들어가지 않는다. 게시하지 않으면 확장 자산 중 이것만 요청마다 PHP 를 거치고,
* CSS 내부 상대 `url()` 이 해석되지 않는다 — 이 축이 빠지면 같은 기능이 확장 타입에
* 따라 다르게 동작하고 그 이유가 화면에 드러나지 않는다.
*
* @effects custom_asset_published_for_all_extension_types
*/
public function test_publishes_active_module_and_plugin_custom_assets(): void
{
$this->createActiveTemplate();
$module = 'g7test-publish_module';
$plugin = 'g7test-publish_plugin';
$fixtures = [
base_path("modules/{$module}/custom") => 'modules/'.$module,
base_path("plugins/{$plugin}/custom") => 'plugins/'.$plugin,
];
foreach (array_keys($fixtures) as $dir) {
File::ensureDirectoryExists($dir.'/fonts');
File::put($dir.'/custom.css', "body { background-image: url('./fonts/x.woff2'); }");
File::put($dir.'/fonts/x.woff2', 'font');
File::put($dir.'/notes.txt', 'not an asset');
}
// 활성 목록은 레포지토리가 판정한다 — 실제 설치본에 의존하면 이 저장소 상태에 따라
// 단언이 공허하게 통과한다.
$this->createActiveExtensions([$module], [$plugin]);
try {
$service = $this->service();
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$versionDir = $service->versionDir(self::VERSION);
foreach ($fixtures as $root) {
$this->assertFileExists($versionDir."/{$root}/assets/custom/custom.css");
$this->assertFileExists($versionDir."/{$root}/assets/custom/fonts/x.woff2");
// 허용 확장자 화이트리스트는 타입과 무관하게 같아야 한다
$this->assertFileDoesNotExist($versionDir."/{$root}/assets/custom/notes.txt");
}
} finally {
File::deleteDirectory(base_path("modules/{$module}"));
File::deleteDirectory(base_path("plugins/{$plugin}"));
}
}
/**
* 비활성 확장의 custom 은 게시하지 않는다.
*
* 자산 서빙이 활성 확장에만 응답하므로, 게시해 봐야 아무도 참조하지 않는 사본이
* 버전 디렉토리마다 쌓인다.
*
* @effects custom_asset_published_for_all_extension_types
*/
public function test_inactive_module_custom_is_absent_from_published_tree(): void
{
$this->createActiveTemplate();
$module = 'g7test-inactive_module';
$dir = base_path("modules/{$module}/custom");
File::ensureDirectoryExists($dir);
File::put($dir.'/custom.css', '/* operator */');
// 활성 행을 만들지 않는다 — 비활성(미설치) 상태 그대로.
try {
$service = $this->service();
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$this->assertFileDoesNotExist(
$service->versionDir(self::VERSION)."/modules/{$module}/assets/custom/custom.css"
);
} finally {
File::deleteDirectory(base_path("modules/{$module}"));
}
}
/**
* components.json 사본과 dist 에셋이 게시되고 `*.map` 은 제외된다.
*
@@ -165,7 +314,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
$this->createActiveTemplate();
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$templateDir = $service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic';
@@ -201,7 +350,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
]);
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$this->assertDirectoryExists($service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic');
$this->assertDirectoryDoesNotExist($service->versionDir(self::VERSION).'/templates/sirsoft-basic');
@@ -228,7 +377,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
]);
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$templatesDir = $service->versionDir(self::VERSION).'/templates';
$this->assertDirectoryExists($templatesDir.'/sirsoft-admin_basic');
@@ -246,13 +395,13 @@ class ExtensionStaticCacheServiceTest extends TestCase
public function test_publish_is_idempotent_and_force_republishes(): void
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
// 게시 후 심은 마커가 skip 재호출에서 살아남으면 재게시가 없었다는 증거
$marker = $service->versionDir(self::VERSION).'/idempotency-marker';
File::put($marker, 'x');
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$this->assertFileExists($marker);
// force 재게시는 디렉토리를 새로 만든다 → 마커 소실
@@ -281,6 +430,8 @@ class ExtensionStaticCacheServiceTest extends TestCase
$service = new ExtensionStaticCacheService(
$mock,
app(TemplateRepositoryInterface::class),
app(ModuleRepositoryInterface::class),
app(PluginRepositoryInterface::class),
app(ExtensionBundleService::class),
app(LanguagePackService::class),
);
@@ -303,7 +454,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
public function test_cleanup_keeps_current_and_previous_only(): void
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
// 과거 버전 3개 + 고아 tmp 시뮬레이션
foreach ([100, 200, 300] as $old) {
@@ -445,7 +596,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$htaccess = File::get($service->versionDir(self::VERSION).'/.htaccess');
@@ -0,0 +1,359 @@
<?php
namespace Tests\Feature\View;
use App\Contracts\Extension\CacheInterface;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Http\View\Composers\TemplateComposer;
use App\Support\CustomAssets;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
use Tests\TestCase;
/**
* 사용자 추가 에셋의 프론트 주입 테스트
*
* 서버가 해석한 목록이 프론트까지 도달하지 않으면 `custom/` 에 파일을 넣어도 아무 일도
* 일어나지 않는다 — 오류도 로그도 없이 "안 먹는다" 로만 나타난다. 배선의 존재와
* **순서**를 함께 본다: 확장 간 순서는 모듈 → 플러그인 → 템플릿이어야 한다.
*/
class CustomAssetInjectionTest extends TestCase
{
/** 테스트용 가짜 플러그인 식별자 */
private const FAKE_PLUGIN = 'g7test-injection';
protected function setUp(): void
{
parent::setUp();
CustomAssets::flushCache();
File::ensureDirectoryExists(base_path('plugins/'.self::FAKE_PLUGIN.'/custom'));
File::put(base_path('plugins/'.self::FAKE_PLUGIN.'/custom/custom.css'), '/* operator */');
}
protected function tearDown(): void
{
File::deleteDirectory(base_path('plugins/'.self::FAKE_PLUGIN));
CustomAssets::flushCache();
parent::tearDown();
}
/**
* 탈출구(D33) — `?custom=off` 요청은 목록을 비운다.
*
* 운영자가 넣은 CSS 한 줄이 관리자 화면을 조작 불능으로 만들면, 그것을 고칠 화면에도
* 그 CSS 가 실려 있어 스스로 갇힌다. 서버가 목록을 비우면 자산이 페이지에 **도달하지
* 않으므로**, 이미 깨진 화면에서 자바스크립트가 돌기를 기대할 필요가 없다.
*
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_assets_disabled_by_request_parameter
*/
#[Test]
public function custom_off_요청은_수집_결과를_비운다(): void
{
$this->mock(PluginManager::class, function ($mock) {
$mock->shouldReceive('getActivePlugins')->andReturn([self::FAKE_PLUGIN => []]);
$mock->shouldReceive('loadPlugins')->andReturnNull();
});
CustomAssets::flushCache();
$composer = app(TemplateComposer::class);
$collect = new ReflectionMethod(TemplateComposer::class, 'collectCustomAssets');
// 평소에는 실린다 (대조군이 없으면 빈 결과가 "원래 없었다" 와 구분되지 않는다)
$this->assertNotEmpty($collect->invoke($composer, null));
// `?custom=off` 이면 비운다
$this->app['request'] = Request::create('/admin/layout-editor/x', 'GET', ['custom' => 'off']);
CustomAssets::flushCache();
$this->assertSame([], $collect->invoke($composer, null));
}
/**
* 화면이 "지금 꺼져 있다" 를 알 수 있어야 한다.
*
* SPA 부팅이 주소를 다시 쓰면서 `?custom=off` 를 지우므로, 화면이 URL 로 판정하면
* 자산은 꺼졌는데 버튼은 "켜져 있음" 으로 표시되고 되돌릴 방법이 사라진다.
* 서버가 자기가 한 일을 그대로 심어야 한다.
*
* @effects custom_assets_disabled_by_request_parameter
*/
#[Test]
public function 서버가_custom_off_상태를_화면에_알린다(): void
{
$composer = app(TemplateComposer::class);
$judge = new ReflectionMethod(TemplateComposer::class, 'customAssetsDisabledByRequest');
$this->app['request'] = Request::create('/admin', 'GET');
$this->assertFalse($judge->invoke($composer));
$this->app['request'] = Request::create('/admin', 'GET', ['custom' => 'off']);
$this->assertTrue($judge->invoke($composer));
}
/**
* 판정이 변경 감지보다 **앞**에 있어야 한다.
*
* 뒤에 두면 빈 목록이 "전부 사라짐" 으로 읽혀 캐시 버전이 오르고, 다음 정상 요청이
* 다시 "전부 생김" 으로 읽어 또 오른다 — 요청마다 버전이 오르내리며 재게시가 끝없이
* 돈다.
*
* @effects custom_assets_disabled_by_request_parameter
*/
#[Test]
public function custom_off_요청은_캐시_버전을_건드리지_않는다(): void
{
$this->mock(PluginManager::class, function ($mock) {
$mock->shouldReceive('getActivePlugins')->andReturn([self::FAKE_PLUGIN => []]);
$mock->shouldReceive('loadPlugins')->andReturnNull();
});
$cache = app(CacheInterface::class);
$cache->put('ext.cache_version', 1);
$cache->forget(CustomAssets::SIGNATURE_CACHE_KEY);
$this->app['request'] = Request::create('/admin', 'GET', ['custom' => 'off']);
CustomAssets::flushCache();
$composer = app(TemplateComposer::class);
$collect = new ReflectionMethod(TemplateComposer::class, 'collectCustomAssets');
$collect->invoke($composer, null);
$collect->invoke($composer, null);
$this->assertSame(1, (int) $cache->get('ext.cache_version'));
$this->assertNull($cache->get(CustomAssets::SIGNATURE_CACHE_KEY));
}
/**
* @scenario custom_source=assets_json, custom_asset=css
*
* @effects custom_asset_loaded_after_extension_bundles
*/
#[Test]
public function 필터_훅으로_다른_출처의_항목을_더할_수_있다(): void
{
// 7.1.0 템플릿 환경설정이 화면에서 입력한 CSS 를 실어 보낼 통로다.
// 이 훅이 없으면 그때 코어 해석기를 다시 열어야 하고, 그 순간 "운영자 CSS 가
// 어디서 오는가" 의 SSoT 가 둘로 갈린다.
HookManager::addFilter(
'core.assets.custom_assets',
function (array $assets, string $type, string $identifier): array {
$assets[] = [
'id' => 'custom:settings:'.$identifier,
'type' => 'style',
'url' => '/api/'.$type.'/settings-css',
'version' => 42,
'source' => 'settings',
];
return $assets;
},
10,
3
);
CustomAssets::flushCache();
$assets = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN);
$sources = array_column($assets, 'source');
$this->assertContains('file', $sources, '파일 출처 항목이 사라졌습니다.');
$this->assertContains('settings', $sources, '필터 훅으로 더한 항목이 반영되지 않았습니다.');
// 파일 출처가 먼저 온다 — 화면에서 방금 입력한 것이 파일보다 뒤여야
// "고쳤는데 안 먹는다" 가 생기지 않는다
$this->assertSame('file', $sources[0]);
$this->assertSame('settings', $sources[count($sources) - 1]);
}
/**
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_loaded_after_extension_bundles
*/
#[Test]
public function 서술자는_프론트가_읽는_형태를_그대로_갖춘다(): void
{
// 프론트 로더(parseCustomAssetsFromConfig)는 id·type·url 셋으로 항목을 판정한다.
// 하나라도 빠지면 그 항목은 조용히 버려진다.
$asset = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN)[0];
$this->assertIsString($asset['id']);
$this->assertContains($asset['type'], ['style', 'script']);
$this->assertIsString($asset['url']);
$this->assertNotSame('', $asset['url']);
}
/**
* 확장 간 순서: 모듈 → 플러그인 → 템플릿.
*
* CSS 는 나중에 온 규칙이 이긴다. 템플릿이 화면 외관의 최종 책임을 지므로 템플릿
* 운영자의 재정의가 가장 뒤에 와야 한다. 순서가 뒤집히면 오류 없이 "재정의가
* 안 먹는다" 로만 나타난다.
*
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_loaded_after_extension_bundles
*/
#[Test]
public function 확장_간_순서는_모듈_플러그인_템플릿이다(): void
{
// 활성 목록을 고정한다 — 테스트 DB 에 무엇이 설치되어 있는지에 순서 검증이
// 좌우되면, 설치본이 비었을 때 수집 결과가 비어 단언이 공허하게 통과한다.
$module = 'g7test-order-module';
$plugin = 'g7test-order-plugin';
$template = 'g7test-order-template';
$this->mock(ModuleManager::class, function ($mock) use ($module) {
$mock->shouldReceive('getActiveModules')->andReturn([$module => []]);
$mock->shouldReceive('loadModules')->andReturnNull();
});
$this->mock(PluginManager::class, function ($mock) use ($plugin) {
$mock->shouldReceive('getActivePlugins')->andReturn([$plugin => []]);
$mock->shouldReceive('loadPlugins')->andReturnNull();
});
$created = [
'modules' => base_path('modules/'.$module.'/custom'),
'plugins' => base_path('plugins/'.$plugin.'/custom'),
'templates' => base_path('templates/'.$template.'/custom'),
];
try {
foreach ($created as $type => $dir) {
File::ensureDirectoryExists($dir);
File::put($dir.'/zz-order-probe.css', '/* '.$type.' */');
}
CustomAssets::flushCache();
$composer = app(TemplateComposer::class);
$collect = new ReflectionMethod(TemplateComposer::class, 'collectCustomAssets');
$assets = $collect->invoke($composer, $template);
// 이 테스트가 놓은 프로브만 골라 순서를 본다 — 다른 운영자 파일이 있어도 무관.
$observed = [];
foreach ($assets as $asset) {
if (! str_contains($asset['id'], 'zz-order-probe.css')) {
continue;
}
// id 형식: custom:{type}:{identifier}:{file}
$observed[] = explode(':', $asset['id'])[1];
}
$this->assertSame(
['modules', 'plugins', 'templates'],
$observed,
'확장 간 순서가 모듈 → 플러그인 → 템플릿이 아닙니다. '
.'CSS 는 나중에 온 규칙이 이기므로 템플릿 재정의가 가장 뒤여야 합니다.'
);
} finally {
foreach ($created as $dir) {
File::deleteDirectory($dir);
}
CustomAssets::flushCache();
}
}
/**
* 관리자 렌더와 사용자 렌더가 **하나의 서명 키를 서로 덮어쓰지 않는다.**
*
* 서명은 수집된 서술자로 만드는데, 그 수집은 모듈·플러그인(양쪽 동일)에 더해
* **그 렌더의 템플릿 하나**만 싣는다(`resolveCustomAssets`). 관리자 렌더는 admin
* 템플릿, 사용자 렌더는 user 템플릿이라 두 서명은 정상적으로 다르다. 그런데 저장
* 키가 하나뿐이면 두 렌더가 번갈아 덮어쓰며 매번 "파일이 바뀌었다" 로 읽힌다.
*
* 그 오독의 대가는 `incrementExtensionCacheVersion()` 이다 — 확장 캐시 버전이 오르고
* 정적 재게시가 예약된다. 운영자가 아무것도 건드리지 않았는데 관리자·사용자 페이지를
* 오갈 때마다 모든 자산 URL 이 바뀌어 브라우저 캐시가 상시 무효가 되고 전체 재게시가
* 반복된다. 예외도 화면 이상도 없어 로그 외에는 드러나지 않는다.
*
* `?custom=off` 진동은 `customAssetsDisabledByRequest` 가 이미 막고 있다 — 같은
* 진동의 다른 원인인 admin/user 템플릿 비대칭만 열려 있었다.
*
* @scenario custom_source=convention_scan, custom_ext_type=template
*
* @effects custom_asset_signature_is_scoped_per_render
*/
#[Test]
public function 관리자_사용자_렌더가_서명_키를_서로_덮어쓰지_않는다(): void
{
$adminTemplate = 'g7test-admin_tpl';
$userTemplate = 'g7test-user_tpl';
foreach ([$adminTemplate, $userTemplate] as $identifier) {
File::ensureDirectoryExists(base_path('templates/'.$identifier.'/custom'));
File::put(base_path('templates/'.$identifier.'/custom/custom.css'), '/* '.$identifier.' */');
}
try {
$composer = app(TemplateComposer::class);
$resolve = new ReflectionMethod(TemplateComposer::class, 'resolveCustomAssets');
$sync = new ReflectionMethod(TemplateComposer::class, 'syncCustomAssetCacheVersion');
$collect = new ReflectionMethod(TemplateComposer::class, 'collectCustomAssets');
$cache = app(CacheInterface::class);
CustomAssets::flushCache();
$adminAssets = $resolve->invoke($composer, $adminTemplate);
$userAssets = $resolve->invoke($composer, $userTemplate);
// 두 렌더의 수집 결과가 실제로 다르다 — 같으면 이 테스트는 공허하다
$this->assertNotSame($adminAssets, $userAssets);
$cache->forget(CustomAssets::SIGNATURE_CACHE_KEY);
// 실제 진입점으로 두 렌더를 번갈아 태운다 (스코프 전달 누락도 함께 잡는다)
foreach ([$adminTemplate, $userTemplate, $adminTemplate, $userTemplate] as $identifier) {
CustomAssets::flushCache();
$collect->invoke($composer, $identifier);
}
// 두 렌더의 서명이 **각자** 기억된다 — 하나의 자리를 서로 덮어쓰지 않는다
$stored = $cache->get(CustomAssets::SIGNATURE_CACHE_KEY);
$this->assertIsArray($stored, '서명이 스코프별로 기억되지 않는다 (단일 값으로 덮어쓰기)');
$this->assertArrayHasKey($adminTemplate, $stored);
$this->assertArrayHasKey($userTemplate, $stored);
$this->assertNotSame($stored[$adminTemplate], $stored[$userTemplate]);
// 파일은 그대로다 — 렌더를 오간다고 버전이 올라서는 안 된다
$this->assertFalse(
$sync->invoke($composer, $adminAssets, $adminTemplate),
'관리자 렌더가 사용자 렌더의 서명을 덮어써 파일 변경으로 오독했다'
);
$this->assertFalse(
$sync->invoke($composer, $userAssets, $userTemplate),
'사용자 렌더가 관리자 렌더의 서명을 덮어써 파일 변경으로 오독했다'
);
// 대조군 — 실제로 파일이 바뀌면 그 스코프에서는 감지된다 (가드가 사문이 아니다)
File::put(base_path('templates/'.$userTemplate.'/custom/custom.css'), '/* changed */');
touch(base_path('templates/'.$userTemplate.'/custom/custom.css'), time() + 10);
CustomAssets::flushCache();
$this->assertTrue(
$sync->invoke($composer, $resolve->invoke($composer, $userTemplate), $userTemplate),
'파일을 고쳤는데 변경 감지가 발화하지 않았다'
);
} finally {
foreach ([$adminTemplate, $userTemplate] as $identifier) {
File::deleteDirectory(base_path('templates/'.$identifier));
}
CustomAssets::flushCache();
}
}
}
@@ -5,6 +5,7 @@ namespace Tests\Feature\View;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Models\Template;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use App\Support\TemplateExternals;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
@@ -35,6 +36,11 @@ class TemplateExternalsRenderingTest extends TestCase
parent::tearDown();
}
/**
* @scenario asset_class=vendored, outcome=loaded
*
* @effects template_external_asset_field_resolves_to_asset_url
*/
public function test_blade_partials_render_all_external_types_attributes_and_positions(): void
{
$externals = TemplateExternals::normalize($this->externalsFixture());
@@ -77,6 +83,9 @@ class TemplateExternalsRenderingTest extends TestCase
$this->assertStringContainsString('src="https://cdn.example.com/body-end.js"', $bodyEnd);
}
/**
* @effects runtime_asset_served_same_origin, no_third_party_request_on_page_load
*/
public function test_admin_response_renders_sirsoft_admin_basic_externals_before_template_css(): void
{
$templateService = app(TemplateService::class);
@@ -89,13 +98,31 @@ class TemplateExternalsRenderingTest extends TestCase
$html = $response->getContent();
$this->assertStringContainsString('href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"', $html);
$this->assertStringContainsString('href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"', $html);
$this->assertStringContainsString('href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"', $html);
$this->assertBefore($html, 'href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"', '/api/templates/assets/sirsoft-admin_basic/css/components.css?v=');
$this->assertBefore($html, 'href="https://cdn.jsdelivr.net"', 'href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"');
// 자체 제공으로 전환됐다 (공개 #123) — 종전에는 이 세 자산이 각각 다른 외부 CDN 에서 왔다.
$fontAwesome = 'vendor/font-awesome/6.4.0/css/all.inlined.css';
$pretendard = 'vendor/pretendard/1.3.9/pretendard-variable.css';
$flagIcons = 'vendor/flag-icons/7.2.3/css/flag-icons.min.css';
foreach ([$fontAwesome, $pretendard, $flagIcons] as $asset) {
$this->assertStringContainsString('/api/templates/assets/sirsoft-admin_basic/'.$asset, $html);
}
// externals 는 템플릿 자체 CSS 보다 먼저 온다 (종전 순서 계약 유지)
$this->assertBefore(
$html,
'/api/templates/assets/sirsoft-admin_basic/'.$fontAwesome,
'/api/templates/assets/sirsoft-admin_basic/css/components.css?v='
);
// 자체 제공 항목에는 외부 전용 힌트가 붙지 않는다 (자기 origin 에 preconnect 는 무의미)
$this->assertStringNotContainsString('rel="preconnect"', $html);
$this->assertStringNotContainsString('cdnjs.cloudflare.com', $html);
$this->assertStringNotContainsString('cdn.jsdelivr.net', $html);
}
/**
* @effects runtime_asset_served_same_origin
*/
public function test_user_response_renders_template_externals_with_same_manifest_syntax(): void
{
$this->createFixtureTemplate('test-userexternals', 'user', [
@@ -129,6 +156,72 @@ class TemplateExternalsRenderingTest extends TestCase
$this->assertBefore($html, 'href="https://cdn.example.com"', 'href="https://cdn.example.com/user.css"');
}
/**
* `asset` 필드가 만드는 URL 은 자산 URL 모드 × 정적 게시 4조합 전부에서 same-origin 이다.
*
* 이 축이 이 작업의 잔여 리스크다 — 확장자를 정적 location 이 가로채는 서버에서는
* `extensionless` 모드가 쓰이고, 그 조합에서 URL 형태가 달라진다. 어느 조합에서든
* 외부 origin 이 새어 나가면 자체 제공 전환이 그 조합에서만 무효가 되는데,
* 화면에는 "아이콘이 안 보인다" 로만 나타난다.
*
* 한계: 정적 게시 게이트(`AssetUrl::staticExtBase`)는 `production` 환경도 함께 요구하므로
* 테스트 환경에서 `static=on` 다리는 실제 게시 경로(`/build/ext/{v}/…`)를 타지 않는다.
* 여기서 고정하는 것은 **설정 축에서 URL 이 외부로 새지 않는다** 는 것까지이며,
* 게시 경로 자체의 실제 형태는 브라우저 점검(T10-f/T10-g)이 맡는다.
*
* @scenario asset_class=vendored, outcome=loaded
*
* @effects runtime_asset_served_same_origin
*/
public function test_asset_field_stays_same_origin_across_url_mode_and_static_cache(): void
{
$combinations = [
['mode' => 'extension', 'static' => false],
['mode' => 'extension', 'static' => true],
['mode' => 'extensionless', 'static' => false],
['mode' => 'extensionless', 'static' => true],
];
try {
foreach ($combinations as $combination) {
AssetUrl::forceMode($combination['mode']);
config(['core.static_cache.enabled' => $combination['static']]);
AssetUrl::resetStaticExtBaseMemo();
$normalized = TemplateExternals::normalize([
[
'id' => 'style-vendored',
'type' => 'style',
'asset' => 'vendor/font-awesome/6.4.0/css/all.inlined.css',
],
], 'test-userexternals', 7);
$label = "mode={$combination['mode']} static=".($combination['static'] ? 'on' : 'off');
$this->assertCount(1, $normalized, "항목이 사라졌습니다 ({$label})");
$url = $normalized[0]['url'];
$this->assertDoesNotMatchRegularExpression(
'#^(https?:)?//#',
$url,
"외부 origin 으로 새어 나갔습니다 ({$label}): {$url}"
);
$this->assertStringStartsWith('/', $url, "same-origin 경로가 아닙니다 ({$label}): {$url}");
$this->assertStringContainsString('all.inlined.css', rawurldecode($url), $label);
// 외부 전용 키는 same-origin 항목에서 붙지 않는다 — preconnect 는 외부 origin
// 을 미리 여는 장치라 자기 자신에게는 의미가 없다.
$this->assertArrayNotHasKey('preconnect', $normalized[0], $label);
$this->assertArrayNotHasKey('crossorigin', $normalized[0], $label);
}
} finally {
AssetUrl::forceMode(null);
config(['core.static_cache.enabled' => true]);
AssetUrl::resetStaticExtBaseMemo();
}
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -0,0 +1,76 @@
/**
* 사용자 추가 에셋(`custom/`) E2E 상호 배제 잠금
*
* `custom-assets.spec.ts` 와 `custom-asset-management.spec.ts` 는 **실서버의 같은
* `custom/` 디렉토리**를 만진다. 파일명을 갈라 놓아도 충돌은 사라지지 않는다 — 어느
* 디렉토리든 쓰기가 일어나면 서버가 파일 서명 변화를 감지해 확장 캐시 버전을 올리고,
* 그 버전이 자산 URL(`?v` / `/build/ext/{v}/`)과 정적 게시본을 통째로 회전시키기
* 때문이다. 즉 두 spec 은 **전역 상태 하나를 공유**한다.
*
* Playwright 의 `mode: 'serial'` 은 파일 **안**의 순서만 정한다. 파일끼리는
* `fullyParallel: true` 로 동시에 돌므로, 한쪽의 쓰기가 다른 쪽의 측정 창에 끼어들어
* 제품이 정상인데도 단언이 무작위로 깨진다(각각 단독 실행하면 둘 다 통과한다).
*
* 스위트 전체 설정(`workers` · `fullyParallel`)은 건드리지 않는다 — 다른 spec 의
* 실행 특성까지 바꾸게 되기 때문이다. 이 잠금을 쓰는 두 파일만 서로를 기다린다.
*
* 디렉토리 생성(`mkdir`)은 원자적이라 별도 동기화 없이 상호 배제가 성립한다.
*/
import { mkdirSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
/** 잠금 디렉토리 — 워커 프로세스가 갈라져도 파일시스템은 공유된다 */
const LOCK_DIR = join(tmpdir(), 'g7-custom-asset-e2e.lock');
/** 이 시간이 지난 잠금은 죽은 워커가 남긴 것으로 보고 걷어낸다 */
const STALE_MS = 5 * 60 * 1000;
/** 획득 대기 상한 — 넘으면 조용히 계속하지 않고 실패시킨다 */
const ACQUIRE_TIMEOUT_MS = 4 * 60 * 1000;
/**
* 잠금을 획득할 때까지 기다립니다.
*
* @return Promise<void>
*/
export async function acquireCustomAssetLock(): Promise<void> {
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
for (;;) {
try {
mkdirSync(LOCK_DIR);
return;
} catch {
try {
if (Date.now() - statSync(LOCK_DIR).mtimeMs > STALE_MS) {
rmSync(LOCK_DIR, { recursive: true, force: true });
continue;
}
} catch {
// 그 사이 다른 워커가 풀었다 — 다음 시도에서 바로 잡는다
}
if (Date.now() > deadline) {
throw new Error(
`custom 자산 E2E 잠금을 ${ACQUIRE_TIMEOUT_MS}ms 안에 얻지 못했습니다. ` +
`이전 실행이 남긴 잠금이면 ${LOCK_DIR} 을 지우십시오.`
);
}
await new Promise(resolve => setTimeout(resolve, 250));
}
}
}
/**
* 잠금을 해제합니다. 이미 없어도 오류로 보지 않습니다.
*
* @return void
*/
export function releaseCustomAssetLock(): void {
rmSync(LOCK_DIR, { recursive: true, force: true });
}
@@ -0,0 +1,251 @@
/**
* E2E: 사용자 추가 에셋 화면 관리 + 탈출구 (공개 이슈 #123, D32~D34)
*
* 운영자가 자기 CSS·JS·폰트·이미지를 화면에서 넣고 고치는 경로를 실브라우저로 잰다.
* 잠그는 것은 셋이다:
*
* ① **권한 경계** — 레이아웃 편집 권한만으로는 관리 API 를 통과하지 못한다. 여기서 올린
* 스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로, 레이아웃을
* 고칠 수 있다는 것이 곧 그 권한이 될 수 없다. 이 결함은 오류를 남기지 않는다 —
* 약한 경로가 정상 200 을 내보내는 것이 유일한 증상이다.
*
* ② **왕복** — 저장한 파일이 목록·본문·삭제까지 그대로 왕복한다.
*
* ③ **탈출구** — `?custom=off` 로 다시 열면 서버가 목록을 비운다. 운영자가 넣은 CSS 한 줄이
* 화면을 조작 불능으로 만들면 그것을 고칠 편집기에도 같은 CSS 가 실려 스스로 갇히는데,
* 서버가 목록을 비우면 자산이 페이지에 **도달하지 않으므로** 이미 깨진 화면에서
* 자바스크립트가 돌기를 기대하지 않아도 된다.
*
* 시나리오 축·효과는 tests/scenarios/custom-asset-management.yaml 참조.
*/
import { test as base, expect } from '@playwright/test';
import { authenticatePage, issueScopedToken, issueToken } from '../fixtures/auth';
import { acquireCustomAssetLock, releaseCustomAssetLock } from '../fixtures/custom-asset-lock';
/** 관리 대상 템플릿 — 관리자 화면의 활성 템플릿. */
const TEMPLATE = 'sirsoft-admin_basic';
/** 테스트가 만드는 파일 (접두어로 운영자 파일과 구분해 정리한다). */
const FIXTURE_PATH = '__e2e123-manage.css';
/** 이 테스트만 쓰는 색 — 우연히 같아져 거짓 통과할 수 없다. */
const FIXTURE_COLOR = 'rgb(55, 55, 55)';
type Fixtures = {
managerToken: string;
layoutOnlyToken: string;
};
const test = base.extend<Fixtures>({
managerToken: async ({}, use) => {
await use(issueToken('core.extensions.custom_assets.manage', 'core.templates.layouts.edit'));
},
layoutOnlyToken: async ({}, use) => {
// 대조군 — 편집 권한은 있으나 자산 관리 권한이 없는 계정.
//
// `issueScopedToken` 이어야 한다. `issueToken` 은 admin 역할을 함께 붙이는데,
// 그 역할은 코어 동기화가 **모든 리프 권한**을 부여하므로(all_leaf) 대조군이
// 대조군이 아니게 된다 — 그러면 게이트가 없어도 이 테스트가 통과한다.
await use(issueScopedToken('core.templates.layouts.edit'));
},
});
/**
* 관리 API base URL — 세 확장 타입이 한 엔드포인트를 공유한다.
*
* @param identifier 확장 식별자
* @param type 확장 타입
*/
const base_ = (identifier: string, type: 'template' | 'module' | 'plugin' = 'template'): string =>
`/api/admin/extensions/${type}/${encodeURIComponent(identifier)}/custom-assets`;
test.describe('사용자 추가 에셋 화면 관리 @custom-assets', () => {
// 케이스들이 실서버의 같은 `custom/` 디렉토리에 같은 파일명을 쓰고 `afterEach` 가 그것을
// 지운다. 병렬로 돌리면 한 워커의 정리가 다른 워커가 방금 저장한 파일을 지워, 제품이
// 정상인데도 목록 단언이 무작위로 깨진다. 형제 spec(`custom-assets.spec.ts`)이 같은
// 이유로 이미 직렬이다.
test.describe.configure({ mode: 'serial' });
// 파일 안의 순서만으로는 부족하다 — `custom-assets.spec.ts` 가 같은 서버의 같은
// `custom/` 디렉토리를 만지고, 여기서의 쓰기가 확장 캐시 버전을 올려 그쪽의 측정 창을
// 흔든다. 파일끼리는 병렬이므로 둘을 서로 배제한다.
test.beforeAll(async () => {
// 훅 기본 상한(30초)은 상대 spec 이 잠금을 쥐고 있는 시간보다 짧다 — 올리지 않으면
// 배제가 성립한 바로 그 순간에 훅이 시간 초과로 죽는다.
test.setTimeout(5 * 60 * 1000);
await acquireCustomAssetLock();
});
test.afterAll(() => {
releaseCustomAssetLock();
});
test.afterEach(async ({ request, managerToken }) => {
await request.delete(`${base_(TEMPLATE)}?path=${encodeURIComponent(FIXTURE_PATH)}`, {
headers: { Authorization: `Bearer ${managerToken}`, Accept: 'application/json' },
});
});
// @scenario manage_actor=layout_edit_only, manage_action=save
// @effects custom_asset_manage_requires_dedicated_permission
test('레이아웃 편집 권한만으로는 저장할 수 없다', async ({ request, layoutOnlyToken }) => {
const response = await request.put(`${base_(TEMPLATE)}/content`, {
headers: { Authorization: `Bearer ${layoutOnlyToken}`, Accept: 'application/json' },
data: { path: FIXTURE_PATH, content: `body { color: ${FIXTURE_COLOR}; }` },
});
expect(response.status()).toBe(403);
});
// @scenario manage_actor=with_permission, manage_action=save
// @effects custom_asset_editor_save_invalidates_published_copy
// @scenario manage_actor=with_permission, manage_action=list
// @effects custom_asset_manage_requires_dedicated_permission
// @scenario manage_actor=with_permission, manage_action=read
// @effects custom_asset_manage_requires_dedicated_permission
test('저장한 파일이 목록·본문으로 왕복하고 화면에 적용된다', async ({
page,
request,
managerToken,
}) => {
const headers = { Authorization: `Bearer ${managerToken}`, Accept: 'application/json' };
const save = await request.put(`${base_(TEMPLATE)}/content`, {
headers,
data: { path: FIXTURE_PATH, content: `body { color: ${FIXTURE_COLOR} !important; }` },
});
expect(save.status()).toBe(200);
const list = await request.get(base_(TEMPLATE), { headers });
expect(list.status()).toBe(200);
const files = (await list.json()).data.files as Array<{ path: string; loaded: boolean }>;
const row = files.find((f) => f.path === FIXTURE_PATH);
expect(row, '저장한 파일이 목록에 없습니다').toBeTruthy();
expect(row?.loaded, '규약 스캔이 자동으로 싣지 않았습니다').toBe(true);
const show = await request.get(
`${base_(TEMPLATE)}/content?path=${encodeURIComponent(FIXTURE_PATH)}`,
{ headers },
);
expect(show.status()).toBe(200);
expect((await show.json()).data.content).toContain(FIXTURE_COLOR);
// 저장만으로는 증명이 아니다 — 실제 화면에 적용되는지를 최종 계산값으로 잰다.
await authenticatePage(page, managerToken);
await page.goto('/admin');
// `networkidle` 은 쓰지 않는다 — 관리자 SPA 는 유지 연결이 있어 idle 이 오지 않는다.
// custom CSS 는 JS 부팅 이후에 붙으므로 최종 적용값 자체를 조건으로 기다린다.
await expect
.poll(async () => page.evaluate(() => getComputedStyle(document.body).color), {
timeout: 20000,
})
.toBe(FIXTURE_COLOR);
});
// @scenario manage_actor=with_permission, manage_action=delete
// @effects custom_assets_disabled_by_request_parameter
test('?custom=off 로 열면 그 자산이 페이지에 도달하지 않는다', async ({
page,
request,
managerToken,
}) => {
const headers = { Authorization: `Bearer ${managerToken}`, Accept: 'application/json' };
await request.put(`${base_(TEMPLATE)}/content`, {
headers,
data: { path: FIXTURE_PATH, content: `body { color: ${FIXTURE_COLOR} !important; }` },
});
await authenticatePage(page, managerToken);
await page.goto('/admin?custom=off');
// 목록이 비었는지를 서버가 심은 설정에서 직접 확인한다 — DOM 부재만 보면
// "로더가 아직 안 돌았다" 와 구분되지 않는다.
await page.waitForFunction(() => !!(window as any).G7Config, undefined, { timeout: 20000 });
const declared = await page.evaluate(
() => ((window as any).G7Config?.customAssets ?? []) as unknown[],
);
expect(declared).toHaveLength(0);
const applied = await page.evaluate(() => getComputedStyle(document.body).color);
expect(applied).not.toBe(FIXTURE_COLOR);
// 대조군 — 파라미터 없이 열면 다시 적용된다 (위 단언이 우연이 아님을 잠근다)
await page.goto('/admin');
await expect
.poll(async () => page.evaluate(() => getComputedStyle(document.body).color), {
timeout: 20000,
})
.toBe(FIXTURE_COLOR);
});
// @scenario manage_actor=with_permission, manage_action=upload
// @effects custom_asset_manage_upload_extension_whitelist
test('허용되지 않는 확장자 업로드는 422 로 막힌다', async ({ request, managerToken }) => {
const response = await request.post(`${base_(TEMPLATE)}/upload`, {
headers: { Authorization: `Bearer ${managerToken}`, Accept: 'application/json' },
multipart: {
file: {
name: '__e2e123-shell.php',
mimeType: 'application/x-php',
buffer: Buffer.from('<?php echo 1;'),
},
},
});
expect(response.status()).toBe(422);
});
// @scenario manage_actor=unauthenticated, manage_action=list
// @effects custom_asset_manage_requires_dedicated_permission
test('비인증 요청은 목록을 받을 수 없다', async ({ request }) => {
const response = await request.get(base_(TEMPLATE), { headers: { Accept: 'application/json' } });
expect(response.status()).toBe(401);
});
// @scenario manage_actor=with_permission, manage_action=list
// @effects custom_asset_manage_all_extension_types_parity
test('모듈·플러그인도 같은 엔드포인트로 관리된다', async ({ request, managerToken }) => {
const headers = { Authorization: `Bearer ${managerToken}`, Accept: 'application/json' };
for (const [type, identifier] of [
['module', 'sirsoft-page'],
['plugin', 'sirsoft-gdpr'],
] as const) {
const path = `__e2e123-${type}.css`;
const save = await request.put(`${base_(identifier, type)}/content`, {
headers,
data: { path, content: 'body { line-height: 1.7; }' },
});
expect(save.status(), `${type} 저장이 실패했습니다`).toBe(200);
const list = await request.get(base_(identifier, type), { headers });
expect(list.status()).toBe(200);
const paths = ((await list.json()).data.files as Array<{ path: string }>).map((f) => f.path);
expect(paths).toContain(path);
const removed = await request.delete(
`${base_(identifier, type)}?path=${encodeURIComponent(path)}`,
{ headers },
);
expect(removed.status()).toBe(200);
}
});
// @scenario manage_actor=with_permission, manage_action=read
// @effects custom_asset_manage_all_extension_types_parity
test('알 수 없는 확장 타입은 라우트가 받지 않는다', async ({ request, managerToken }) => {
// 통과시켜 빈 목록으로 응답하면 오타가 "그 확장에는 파일이 없다" 로 보인다.
const response = await request.get(`/api/admin/extensions/theme/${TEMPLATE}/custom-assets`, {
headers: { Authorization: `Bearer ${managerToken}`, Accept: 'application/json' },
});
expect(response.status()).toBe(404);
});
});
@@ -0,0 +1,528 @@
/**
* E2E: 사용자 추가 에셋(`custom/`) 적용 순서 (공개 이슈 #123)
*
* 운영자가 덧붙인 CSS 는 확장 스타일보다 **뒤**에 붙어야 재정의가 성립한다. 앞에 붙으면
* "고쳤는데 안 먹는다" 가 되고, 그 원인은 화면 어디에도 드러나지 않는다.
*
* 이 스펙은 **실제 픽스처를 놓고 최종 적용값을 잰다.** 문서 순서만 보면 "뒤에 있다" 는
* 확인일 뿐 "이긴다" 의 증명이 아니다 — specificity·!important·미디어쿼리 중 하나만
* 어긋나도 순서가 맞는데 적용되지 않는 상태가 성립한다. `getComputedStyle` 이 유일한 증거다.
*
* 경쟁 상대가 **실재하는 속성**으로 잰다. 아무도 정의하지 않는 속성으로 재면 순서와
* 무관하게 통과하므로 "적용됐다" 의 증명일 뿐 "이겼다" 의 증명이 아니다. 관리자 번들은
* `body { color: … }` 를 정의하므로(`dist/css/components.css`) 그 속성을 다툰다.
*
* 픽스처는 **활성 확장 디렉토리**에 놓는다. 활성 확장 경로는 `.gitignore` 대상이라
* 배포본에 섞이지 않으며, `_bundled` 에 놓는 것은 정적 검사가 차단한다.
*
* 시나리오 축·효과는 tests/scenarios/custom-extension-assets.yaml 참조.
*/
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Page } from '@playwright/test';
import { test, expect } from '../fixtures/auth';
import { acquireCustomAssetLock, releaseCustomAssetLock } from '../fixtures/custom-asset-lock';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
/**
* 픽스처를 놓을 활성 확장 3종.
*
* 세 타입을 동시에 놓아야 **확장 간 순서**(모듈 → 플러그인 → 템플릿)를 다툴 수 있다.
* 하나만 놓으면 그 하나가 언제나 이기므로 순서가 검증되지 않는다.
*/
const FIXTURES = {
module: 'modules/sirsoft-page',
plugin: 'plugins/sirsoft-gdpr',
template: 'templates/sirsoft-admin_basic',
} as const;
/**
* 확장별·파일별 색.
*
* 템플릿·번들 어디에서도 쓰지 않는 값이라 우연히 같아져 거짓 통과할 수 없다.
* 마지막에 오는 `template20` 이 최종 승자여야 한다.
*/
const COLOR = {
module: 'rgb(11, 11, 11)',
plugin: 'rgb(22, 22, 22)',
template10: 'rgb(33, 33, 33)',
template20: 'rgb(44, 44, 44)',
} as const;
/** custom JS 가 실행 흔적을 남기는 전역 배열 이름. */
const TRACE = '__g7CustomOrder';
/** 선언 순서대로의 기대 실행 흔적. */
const EXPECTED_TRACE = ['module', 'plugin', 'template-10', 'template-20'];
/**
* 다툴 속성을 덮는 규칙.
*
* `!important` 를 쓰지 않는다 — 그것을 쓰면 순서와 무관하게 이기므로 이 스펙이
* 검증하려는 캐스케이드 순서를 우회해 버린다. 번들과 **같은 specificity**(`body`)로
* 두고 «나중에 온 것이 이긴다» 는 성질만으로 판정한다.
*
* @param color 적용할 색
* @return CSS 본문
*/
function css(color: string): string {
return `body { color: ${color}; }\n`;
}
/**
* 실행 흔적을 남기는 스크립트.
*
* 배열을 스스로 만들어 두므로 어느 것이 먼저 실행되든 흔적이 유실되지 않는다.
*
* @param mark 남길 표식
* @return JS 본문
*/
function js(mark: string): string {
return `(window.${TRACE} = window.${TRACE} || []).push(${JSON.stringify(mark)});\n`;
}
/**
* 픽스처 파일 절대 경로.
*
* @param key 확장 종류
* @param name 파일명
* @return 절대 경로
*/
function fixturePath(key: keyof typeof FIXTURES, name: string): string {
return resolve(REPO_ROOT, FIXTURES[key], 'custom', name);
}
/**
* 파일 하나를 놓습니다.
*
* @param key 확장 종류
* @param name 파일명
* @param contents 내용
* @return void
*/
function put(key: keyof typeof FIXTURES, name: string, contents: string): void {
const target = fixturePath(key, name);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents, 'utf8');
}
/**
* 파일 하나를 치웁니다.
*
* @param key 확장 종류
* @param name 파일명
* @return void
*/
function drop(key: keyof typeof FIXTURES, name: string): void {
const target = fixturePath(key, name);
if (existsSync(target)) {
rmSync(target, { force: true });
}
}
/**
* 세 확장에 픽스처를 모두 놓습니다.
*
* @return void
*/
function putAll(): void {
put('module', '10-e2e-order.css', css(COLOR.module));
put('module', '10-e2e-order.js', js('module'));
put('plugin', '10-e2e-order.css', css(COLOR.plugin));
put('plugin', '10-e2e-order.js', js('plugin'));
put('template', '10-e2e-order.css', css(COLOR.template10));
put('template', '20-e2e-order.css', css(COLOR.template20));
put('template', '10-e2e-order.js', js('template-10'));
put('template', '20-e2e-order.js', js('template-20'));
}
/**
* 픽스처 디렉토리를 통째로 치웁니다.
*
* 운영자 파일 자리이므로 스펙이 만든 것 외에는 원래 비어 있다. 남겨 두면 다음 실행이
* 지난 픽스처를 함께 재게 되어 순서 판정이 어긋난다.
*
* @return void
*/
function cleanAll(): void {
for (const relative of Object.values(FIXTURES)) {
const dir = resolve(REPO_ROOT, relative, 'custom');
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true });
}
}
}
/** 서버가 내려준 custom 자산 목록을 읽는다. */
async function declaredCustomAssets(page: Page) {
return page.evaluate(
() =>
((window as any).G7Config?.customAssets ?? []) as Array<{
id: string;
type: string;
url: string;
source?: string;
}>
);
}
/**
* 관리자 화면에 진입해 자산 로드가 끝나기를 기다립니다.
*
* custom 자산은 확장 번들 **뒤**에 붙으므로 첫 페인트 시점에는 아직 적용되지 않았다.
* 최종 적용값을 재려면 붙을 때까지 기다려야 한다.
*
* @param page 대상 페이지
* @return void
*/
async function enterAdmin(page: Page): Promise<void> {
await page.goto('/admin/login');
await page.waitForFunction(() => typeof (window as any).G7Config !== 'undefined', null, {
timeout: 30_000,
});
await page.waitForLoadState('networkidle');
}
/**
* `body` 의 최종 색이 기대값이 될 때까지 기다립니다.
*
* @param page 대상 페이지
* @param expected 기대 색
* @return void
*/
async function expectFinalColor(page: Page, expected: string): Promise<void> {
await page.waitForFunction(
(want) => getComputedStyle(document.body).color === want,
expected,
{ timeout: 30_000 }
);
}
test.describe('사용자 추가 에셋 — 적용 순서', () => {
// 이 스펙은 **공유 파일 시스템 상태**(활성 확장의 custom 픽스처)를 놓고 잰다.
// 병렬로 돌리면 한 테스트의 파일 수정과 다른 테스트의 단언이 겹치고, 워커별 정리가
// 아직 쓰는 중인 픽스처를 지운다 — 제품 결함이 아닌데 실패하는 flaky 가 된다.
test.describe.configure({ mode: 'serial' });
// 파일 안의 순서만으로는 부족하다 — `custom-asset-management.spec.ts` 가 같은 서버의
// 같은 `custom/` 디렉토리를 만지고, 그 쓰기가 확장 캐시 버전을 올려 자산 URL 을 통째로
// 회전시킨다. 파일끼리는 병렬이므로 그 회전이 이쪽 측정 창에 끼어든다.
test.beforeAll(async () => {
// 훅 기본 상한(30초)은 상대 spec 이 잠금을 쥐고 있는 시간보다 짧다 — 올리지 않으면
// 배제가 성립한 바로 그 순간에 훅이 시간 초과로 죽는다.
test.setTimeout(5 * 60 * 1000);
await acquireCustomAssetLock();
cleanAll();
putAll();
});
test.afterAll(() => {
cleanAll();
releaseCustomAssetLock();
});
// ── 배선 ────────────────────────────────────────────────────────────────
test('서버가 세 확장의 custom 자산을 선언 순서대로 내려준다', async ({ page }) => {
// @scenario custom_source=convention_scan, custom_asset=css
// @effects custom_asset_loaded_after_extension_bundles
await enterAdmin(page);
const shape = await page.evaluate(() => {
const config = (window as any).G7Config;
return {
hasKey: Object.prototype.hasOwnProperty.call(config, 'customAssets'),
isArray: Array.isArray(config.customAssets),
};
});
expect(shape.hasKey).toBe(true);
expect(shape.isArray).toBe(true);
const declared = await declaredCustomAssets(page);
const mine = declared.filter((asset) => asset.url.includes('e2e-order'));
// 픽스처를 8개 놓았으므로 목록이 비면 배선이 끊긴 것이다 —
// 빈 목록을 그냥 통과시키면 이 스펙 전체가 공허해진다.
expect(mine.length, '픽스처가 목록에 없습니다 — 수집/서빙 배선이 끊겼습니다.').toBe(8);
// 확장 간 순서: 모듈 → 플러그인 → 템플릿.
// 같은 확장 안에서는 CSS 가 JS 보다 앞이고, 파일명 오름차순이다.
const order = mine.map((asset) => {
const scope = asset.url.includes('/modules/')
? 'module'
: asset.url.includes('/plugins/')
? 'plugin'
: 'template';
return `${scope}:${asset.url.match(/(\d\d-e2e-order\.\w+)/)?.[1] ?? '?'}`;
});
expect(order).toEqual([
'module:10-e2e-order.css',
'module:10-e2e-order.js',
'plugin:10-e2e-order.css',
'plugin:10-e2e-order.js',
'template:10-e2e-order.css',
'template:20-e2e-order.css',
'template:10-e2e-order.js',
'template:20-e2e-order.js',
]);
});
test('사용자 추가 에셋은 same-origin 으로 서빙된다', async ({ page }) => {
// @scenario custom_source=convention_scan, custom_asset=static_file
// @effects runtime_asset_served_same_origin
await enterAdmin(page);
const declared = await declaredCustomAssets(page);
const origin = await page.evaluate(() => window.location.origin);
expect(declared.length).toBeGreaterThan(0);
for (const asset of declared.filter((item) => item.source !== 'url')) {
expect(
asset.url.startsWith('/') || asset.url.startsWith(origin),
`외부 origin 으로 나갔습니다: ${asset.url}`
).toBe(true);
}
});
// ── A. 캐스케이드 경쟁 ──────────────────────────────────────────────────
test('A1·A2·A3 — 경쟁이 성립한 속성에서 템플릿의 마지막 파일이 이긴다', async ({ page }) => {
// 관리자 번들이 `body { color }` 를 정의하므로 이 속성은 실제 경쟁 상태다.
// 최종 승자가 template20 이라는 것은 세 가지를 동시에 증명한다:
// A1 운영자 CSS 가 번들 CSS 를 이긴다 (번들 값이 아니다)
// A2 확장 간 순서가 모듈 → 플러그인 → 템플릿이다 (앞 둘의 값이 아니다)
// A3 한 확장 안에서 파일명 오름차순이다 (template10 의 값이 아니다)
//
// A1 과 A2·A3 는 **이기는 근거가 다르다**. A1 은 번들이 `@layer` 안에 있어
// 레이어 밖(custom)이 순서와 무관하게 이기는 것이고, A2·A3 는 custom 끼리
// 모두 레이어 밖·같은 specificity 라 **순수하게 소스 순서**로 갈리는 것이다.
// 아래에서 그 구도 자체를 단언한다 — 근거가 바뀌면 알아채야 한다.
// @scenario custom_source=convention_scan, custom_asset=css
// @effects custom_asset_cascade_order_module_plugin_template
await enterAdmin(page);
// 경쟁 구도를 먼저 실측한다. «최종값이 내 값이더라» 만으로는 왜 이겼는지 모르고,
// 이유가 달라지면 보장의 성질도 달라진다.
//
// 번들 규칙이 `@layer` 안 → 레이어 밖(custom)이 **순서와 무관하게** 이긴다
// 번들 규칙이 레이어 밖 → **소스 순서**가 승패를 정한다
//
// 둘 중 어느 쪽인지 단언해 두지 않으면, 나중에 번들이 레이어를 걷어냈을 때
// 보장의 근거가 바뀌었는데도 이 테스트는 계속 통과한다.
const shape = await page.evaluate(() => {
const found: Array<{ fromCustom: boolean; layer: string }> = [];
function walk(rules: CSSRuleList, href: string, layer: string): void {
for (const rule of Array.from(rules)) {
const anyRule = rule as any;
if (anyRule.selectorText === 'body' && anyRule.style?.color) {
found.push({ fromCustom: href.includes('e2e-order'), layer });
}
if (anyRule.cssRules) {
const next =
rule.constructor.name === 'CSSLayerBlockRule'
? `${layer}>${anyRule.name ?? '?'}`
: layer;
walk(anyRule.cssRules, href, next);
}
}
}
for (const sheet of Array.from(document.styleSheets)) {
try {
walk(sheet.cssRules, String(sheet.href ?? 'inline'), '(none)');
} catch {
continue; // 교차 origin 시트는 읽을 수 없다 — same-origin 만 본다
}
}
return {
bundle: found.filter((entry) => !entry.fromCustom).map((entry) => entry.layer),
custom: found.filter((entry) => entry.fromCustom).map((entry) => entry.layer),
};
});
expect(
shape.bundle.length,
'번들이 body{color} 를 정의하지 않습니다 — 경쟁 상대가 없어 이 단언이 공허해집니다.'
).toBeGreaterThan(0);
expect(
shape.custom.length,
'custom 규칙이 문서에 붙지 않았습니다 — 로드 배선이 끊겼습니다.'
).toBe(4);
// 현재 구도: 번들은 레이어 안, custom 은 레이어 밖.
expect(
shape.bundle.every((layer) => layer !== '(none)'),
'번들 규칙이 레이어 밖으로 나왔습니다 — 이제 순서가 승패를 정하므로 이 절의 근거가 바뀌었습니다.'
).toBe(true);
expect(
shape.custom.every((layer) => layer === '(none)'),
'custom 규칙이 레이어 안에 들어갔습니다 — 번들 레이어와의 우열이 선언 순서에 좌우됩니다.'
).toBe(true);
await expectFinalColor(page, COLOR.template20);
const applied = await page.evaluate(() => getComputedStyle(document.body).color);
expect(applied, '최종 승자가 템플릿의 마지막 파일이 아닙니다.').toBe(COLOR.template20);
expect(applied).not.toBe(COLOR.template10);
expect(applied).not.toBe(COLOR.plugin);
expect(applied).not.toBe(COLOR.module);
});
test('A4 — 템플릿 픽스처를 치우면 플러그인이 승자가 된다 (순서가 우연이 아님)', async ({ page }) => {
// A2 가 «템플릿이 마지막이라» 이긴 것인지, «템플릿만 적용된» 것인지 가른다.
// 뒤를 치웠을 때 그 앞이 올라와야 순서가 실재하는 것이다.
// @scenario custom_source=convention_scan, custom_asset=css
// @effects custom_asset_cascade_order_module_plugin_template
drop('template', '10-e2e-order.css');
drop('template', '20-e2e-order.css');
await enterAdmin(page);
await expectFinalColor(page, COLOR.plugin);
const applied = await page.evaluate(() => getComputedStyle(document.body).color);
expect(applied, '템플릿을 치웠는데 플러그인이 올라오지 않았습니다.').toBe(COLOR.plugin);
// 다음 테스트를 위해 되돌린다.
put('template', '10-e2e-order.css', css(COLOR.template10));
put('template', '20-e2e-order.css', css(COLOR.template20));
});
// ── B. JS 실행 순서 ────────────────────────────────────────────────────
test('B1·B2 — custom JS 는 선언 순서대로 실행된다 (CSS 와 섞여 있어도)', async ({ page }) => {
// 선언 목록에서 CSS 와 JS 가 번갈아 오지만, JS 끼리의 상대 순서는 유지되어야 한다.
// 순서가 뒤집히면 뒤 스크립트가 앞 스크립트의 정의에 기대는 구성이 조용히 깨진다.
// @scenario custom_source=convention_scan, custom_asset=js
// @effects custom_script_execution_order_matches_declaration
await enterAdmin(page);
await page.waitForFunction(
(args) => ((window as any)[args.trace] ?? []).length === args.count,
{ trace: TRACE, count: EXPECTED_TRACE.length },
{ timeout: 30_000 }
);
const trace = await page.evaluate((name) => (window as any)[name], TRACE);
expect(trace, 'custom JS 실행 순서가 선언 순서와 다릅니다.').toEqual(EXPECTED_TRACE);
});
test('B3 — 하나가 실패해도 나머지는 실행되고 안내가 뜬다', async ({ page }) => {
// 운영자가 넣은 파일 하나가 실패했다고 나머지까지 멈추면, 고친 것과 무관한
// 기능이 함께 죽는다. 실패는 격리하고 사실은 알린다.
// @scenario custom_source=convention_scan, custom_asset=js
// @effects custom_script_execution_order_matches_declaration
// 가운데 것 하나만 막는다 — 앞뒤가 모두 살아야 «격리» 가 증명된다.
// 맨 앞이나 맨 뒤를 막으면 "뒤가 안 돌았다"/"앞이 안 돌았다" 와 구분되지 않는다.
const blocked = '**/plugins/**e2e-order.js*';
await page.route(blocked, (route) => route.abort());
await enterAdmin(page);
// 막힌 플러그인만 빠지고 나머지는 순서 그대로 실행되어야 한다.
await page.waitForFunction(
(args) => ((window as any)[args.trace] ?? []).length === args.count,
{ trace: TRACE, count: 3 },
{ timeout: 30_000 }
);
const trace = await page.evaluate((name) => (window as any)[name], TRACE);
expect(trace, '한 파일의 실패가 나머지 실행을 막거나 순서를 흔들었습니다.').toEqual([
'module',
'template-10',
'template-20',
]);
// 실패를 조용히 삼키지 않는다 — 안내가 떠야 운영자가 사실을 안다.
await expect(page.locator('#g7-asset-failure-notice')).toBeVisible({ timeout: 20_000 });
await page.unroute(blocked);
});
// ── C. 캐시 서명 ───────────────────────────────────────────────────────
test('C1·C2 — 파일을 고치면 세 타입 모두 URL 이 바뀌고, 확장 번들과 같은 축을 쓴다', async ({ page }) => {
// 무효화 축은 세 타입이 같다. 템플릿·모듈·플러그인 `custom/` 이 모두 확장 자산과
// **같은 메커니즘**으로 정적 게시되므로 URL 도 같은 축(확장 캐시 버전)을 쓴다.
//
// 파일 서명(mtime)을 URL 에 실으면 안 된다 — 정적 경로는 언제나 현재 게시 버전이라
// 버전 일치 게이트에 걸려 정적 분기가 영영 선택되지 않는다. 대신 파일 변경을 감지해
// 캐시 버전을 올리므로, 고치면 URL 도 바뀐다.
//
// 종전 이 테스트는 "custom 서명과 번들 URL 은 언제나 독립" 을 단언했다. custom 을
// 정적 게시로 옮기면서 그 전제가 깨졌으므로 실제 계약으로 갈아 끼운다.
//
// @scenario custom_source=convention_scan, custom_asset=css
// @effects custom_asset_url_busts_on_file_change
// @scenario custom_source=convention_scan, custom_asset=static_file
// @effects custom_asset_published_with_extension_assets
await enterAdmin(page);
const pick = (list: Array<{ url: string }>, needle: string) =>
list.find((a) => a.url.includes(needle))?.url;
const before = await declaredCustomAssets(page);
const beforeTemplate = pick(before, '20-e2e-order.css');
const beforeModule = pick(before, '/modules/');
const beforePlugin = pick(before, '/plugins/');
expect(beforeTemplate, '템플릿 픽스처 URL 을 찾지 못했습니다.').toBeTruthy();
expect(beforeModule, '모듈 픽스처 URL 을 찾지 못했습니다.').toBeTruthy();
expect(beforePlugin, '플러그인 픽스처 URL 을 찾지 못했습니다.').toBeTruthy();
// 세 타입 모두 내용을 바꾼다 — 어느 타입이든 URL 은 반드시 달라져야 한다.
put('template', '20-e2e-order.css', css('rgb(55, 55, 55)'));
put('module', '10-e2e-order.css', css('rgb(66, 66, 66)'));
put('plugin', '10-e2e-order.css', css('rgb(77, 77, 77)'));
await enterAdmin(page);
const after = await declaredCustomAssets(page);
expect(
pick(after, '20-e2e-order.css'),
'템플릿 custom 을 고쳤는데 URL 이 그대로입니다 — 브라우저가 옛 CSS 를 계속 씁니다.'
).not.toBe(beforeTemplate);
expect(
pick(after, '/modules/'),
'모듈 custom 을 고쳤는데 URL 이 그대로입니다.'
).not.toBe(beforeModule);
expect(
pick(after, '/plugins/'),
'플러그인 custom 을 고쳤는데 URL 이 그대로입니다.'
).not.toBe(beforePlugin);
// 바뀐 내용이 실제로 적용되는지까지 본다 — URL 만 바뀌고 내용이 안 바뀌면 의미가 없다.
await expectFinalColor(page, 'rgb(55, 55, 55)');
// 다음 테스트를 위해 원래 값으로 되돌린다.
put('template', '20-e2e-order.css', css(COLOR.template20));
put('module', '10-e2e-order.css', css(COLOR.module));
put('plugin', '10-e2e-order.css', css(COLOR.plugin));
});
});
@@ -0,0 +1,231 @@
/**
* E2E: 구동 에셋의 자체 제공 (공개 이슈 #123)
*
* 배경: 브라우저가 화면을 그리기 위해 6개 제3자 CDN 에 도달해야 했다. 도달 실패는
* 예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다 — 폐쇄망·방화벽·광고차단기에서
* 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
*
* 이 스펙은 **네트워크 레벨에서 무조건 측정**한다. 소스에 URL 이 없는지는 정적 검사와
* PHPUnit 전수 스캔이 보지만, 실제 페이지가 어디로 나가는지는 브라우저만 안다 —
* 런타임에 주입되는 스크립트·CSS 는 소스 스캔이 놓칠 수 있다.
*
* 시나리오 축·효과는 tests/scenarios/self-hosted-runtime-assets.yaml 참조.
* 마커는 test 에만 둔다 — 파일 레벨에 몰아 적으면 그 test 를 지워도 효과가 green 으로 남는다.
*/
import { test, expect, authenticatePage } from '../fixtures/auth';
/** 자체 제공 대상이 아닌 외부 호스트 (서비스 SDK — manifest 에 사유와 함께 선언됨) */
const SERVICE_SDK_HOSTS = new Set(['t1.daumcdn.net']);
/**
* 페이지가 실제로 요청한 URL 을 수집합니다.
*
* @param page Playwright 페이지
* @returns 수집된 URL 배열 (수집은 호출 시점부터 시작)
*/
function collectRequests(page: any): string[] {
const urls: string[] = [];
page.on('request', (request: any) => {
const type = request.resourceType();
if (['script', 'stylesheet', 'font', 'image'].includes(type)) {
urls.push(request.url());
}
});
return urls;
}
/**
* same-origin 도 서비스 SDK 도 아닌 요청을 골라냅니다.
*
* @param urls 수집된 URL 목록
* @param origin 현재 사이트 origin
* @returns 위반 URL 목록
*/
function thirdPartyOffenders(urls: string[], origin: string): string[] {
return urls.filter((url) => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false; // data:/blob: 는 자체 제공의 반대편이 아니다
}
if (parsed.origin === origin) {
return false;
}
return !SERVICE_SDK_HOSTS.has(parsed.hostname.toLowerCase());
});
}
test.describe('구동 에셋 자체 제공', () => {
test('관리자 화면 로드에 제3자 origin 요청이 없다', async ({ page }) => {
// @scenario asset_class=vendored, outcome=loaded
// @effects no_third_party_request_on_page_load, runtime_asset_served_same_origin
const urls = collectRequests(page);
await page.goto('/admin/login');
await page.waitForFunction(() => typeof (window as any).G7Config !== 'undefined', null, {
timeout: 30_000,
});
await page.waitForLoadState('networkidle');
const origin = await page.evaluate(() => window.location.origin);
// 측정 대상이 실제로 존재하는지 확인 (공허한 통과 방지)
expect(urls.length).toBeGreaterThan(0);
expect(thirdPartyOffenders(urls, origin)).toEqual([]);
});
test('사용자 화면 로드에 제3자 origin 요청이 없다', async ({ page }) => {
// @scenario asset_class=vendored, outcome=loaded
// @effects no_third_party_request_on_page_load
const urls = collectRequests(page);
await page.goto('/');
await page.waitForLoadState('networkidle');
const origin = await page.evaluate(() => window.location.origin);
expect(urls.length).toBeGreaterThan(0);
expect(thirdPartyOffenders(urls, origin)).toEqual([]);
});
test('아이콘 폰트가 실제로 적용된다 (자체 제공본이 동작한다)', async ({ page }) => {
// 요청이 same-origin 이어도 그 파일이 깨졌으면 아이콘은 안 보인다.
// "외부로 안 나갔다" 와 "아이콘이 보인다" 는 다른 사실이므로 둘 다 측정한다.
// @scenario asset_class=vendored, outcome=loaded
// @effects runtime_asset_served_same_origin
await page.goto('/admin/login');
await page.waitForLoadState('networkidle');
const fontFamily = await page.evaluate(() => {
const icon = document.querySelector('[class*="fa-"]');
if (!icon) {
return null;
}
return window.getComputedStyle(icon, '::before').fontFamily;
});
// 아이콘이 없는 화면이면 이 단언은 건너뛴다 (측정 대상 부재를 통과로 위장하지 않는다)
if (fontFamily !== null) {
expect(fontFamily).toContain('Font Awesome');
}
});
test('동봉 자산이 same-origin 경로로 200 을 돌려준다', async ({ page }) => {
// @scenario asset_class=vendored, outcome=loaded
// @effects runtime_asset_served_same_origin, vendored_asset_declared_path_exists_on_disk
await page.goto('/admin/login');
const results = await page.evaluate(async () => {
// URL 을 문자열로 조립하지 않는다 — 확장자를 정적 location 이 가로채는 서버에서는
// 확장자 형태가 404 이고 `?file=` 형태만 200 이다. 어느 쪽이 맞는지는 서버 설정이
// 정하므로 앱 자신의 빌더(`G7Core.asset`)에게 물어야 한다. 하드코딩하면 이 테스트는
// `extension` 모드 서버에서만 통과하고 다른 서버에서는 제품이 멀쩡한데 실패한다.
const asset = (window as any).G7Core?.asset;
if (!asset) {
return null;
}
const urls: string[] = [
asset.template('sirsoft-admin_basic', 'vendor/font-awesome/6.4.0/css/all.inlined.css'),
asset.plugin('sirsoft-ckeditor5', 'dist/vendor/ckeditor5/43.3.1/ckeditor5.css'),
];
return Promise.all(
urls.map(async (path) => {
const response = await fetch(path, { method: 'GET' });
return { path, status: response.status, type: response.headers.get('content-type') };
})
);
});
expect(results, 'G7Core.asset 이 노출되지 않았습니다 — 자산 URL 배선이 끊겼습니다.').not.toBeNull();
for (const result of results ?? []) {
expect(result.status, `${result.path} 가 200 이 아닙니다`).toBe(200);
expect(result.type).toContain('css');
}
});
});
test.describe('서버가 심은 externals 의 로드 실패', () => {
// 아이콘 폰트 CSS 는 서버가 HTML 에 직접 심으므로 엔진 번들보다 먼저 평가된다.
// 실패해도 자바스크립트에는 아무 신호가 오지 않아, 안내 계층이 이 경로만 비어 있었다.
// 그 결과는 "아이콘이 통째로 사라진 화면" 이고, 아이콘만으로 조작하는 버튼이 있는
// 화면에서는 곧 조작 불능인데 배너도 로그도 남지 않았다.
test('아이콘 글꼴을 못 불러오면 안내가 뜨고, 복구되면 사라진다', async ({ page, editToken }) => {
// @scenario asset_class=vendored, outcome=failed
// @effects failed_asset_shows_retry_notice
test.setTimeout(120_000);
let blocked = true;
await page.route(/font-awesome/i, route => (blocked ? route.abort() : route.continue()));
// 아이콘이 실제로 조작 수단인 화면에서 본다 — 사용자 홈은 아이콘 노드가 없어
// "무너졌다" 를 관측할 대상 자체가 없다.
await authenticatePage(page, editToken);
await page.goto('/admin/dashboard');
const notice = page.locator('#g7-asset-failure-notice');
await expect(notice).toBeVisible({ timeout: 30_000 });
await expect(notice).toHaveAttribute('role', 'alert');
// 아이콘이 글리프 없이 무너졌는지 — 배너가 가리키는 실제 손상.
// 배너는 화면이 그려지기 전에 뜨므로 아이콘 노드가 생길 때까지 기다린 뒤 잰다.
// state: 'attached' 가 필수다 — 글리프가 사라진 아이콘은 0×0 이라 Playwright 의
// 기본 조건('visible')으로는 영원히 기다리게 된다. 재고 싶은 손상이 곧 대기 조건을
// 막는 형태라, 여기서 기본값을 쓰면 이 테스트는 원리상 통과할 수 없다.
await page.waitForSelector('.fa, [class^="fa-"], [class*=" fa-"]', {
state: 'attached',
timeout: 30_000,
});
const collapsedWidth = await page.evaluate(() => {
const icon = document.querySelector('.fa, [class^="fa-"], [class*=" fa-"]')!;
return Math.round(icon.getBoundingClientRect().width);
});
expect(collapsedWidth).toBe(0);
// 차단을 풀고 [다시 시도] → 배너가 사라지고 아이콘이 되살아난다
blocked = false;
await notice.locator('[data-action="retry"]').click();
await expect(notice).toHaveCount(0, { timeout: 30_000 });
await expect
.poll(
() =>
page.evaluate(() => {
const icon = document.querySelector('.fa, [class^="fa-"], [class*=" fa-"]');
return icon ? Math.round(icon.getBoundingClientRect().width) : 0;
}),
{ timeout: 20_000 },
)
.toBeGreaterThan(0);
});
test('정상 경로에서는 안내가 뜨지 않는다', async ({ page, editToken }) => {
// 실패 신호가 오탐하면 모든 화면에 배너가 상주해 안내가 잡음이 된다.
// @scenario asset_class=vendored, outcome=loaded
// @effects runtime_asset_served_same_origin
await authenticatePage(page, editToken);
await page.goto('/admin/dashboard');
await page.waitForTimeout(6000);
await expect(page.locator('#g7-asset-failure-notice')).toHaveCount(0);
});
});
@@ -193,13 +193,33 @@ test.describe('봇 렌더링 본문 노출', () => {
*/
test('상품 상세설명이 봇 화면에 출력된다', async ({ request }) => {
const listHtml = await fetchBotHtml(request, '/shop/products');
const match = bodyOf(listHtml).match(/href="(\/shop\/products\/[^"]+)"/);
const links = [
...new Set([...bodyOf(listHtml).matchAll(/href="(\/shop\/products\/[^"]+)"/g)].map(m => m[1])),
];
test.skip(match === null, '상품 목록에 상품이 없어 건너뜁니다');
test.skip(links.length === 0, '상품 목록에 상품이 없어 건너뜁니다');
const html = await fetchBotHtml(request, match![1]);
// 목록의 **첫** 상품을 집어 "설명이 있다" 고 가정하면 안 된다. 상세설명이 비어 있는
// 상품(등록 직후·테스트용 상품)이 맨 앞에 오면 빈 렌더가 **정답**인데도 실패한다 —
// 데이터 순서에 매달린 실패는 제품 결함처럼 보이면서 매번 다른 날에 재현된다.
// 설명이 실린 상품을 하나라도 찾을 때까지 훑고, 하나도 없으면 건너뛴다.
let rendered: string | null = null;
expect(bodyOf(html)).not.toContain(EMPTY_INJECTED_BODY);
for (const link of links.slice(0, 10)) {
const html = bodyOf(await fetchBotHtml(request, link));
if (!html.includes(EMPTY_INJECTED_BODY)) {
rendered = html;
break;
}
}
test.skip(
rendered === null,
'상세설명이 등록된 상품이 없어 건너뜁니다 (빈 설명은 빈 렌더가 정상)'
);
expect(rendered).not.toContain(EMPTY_INJECTED_BODY);
});
/**
@@ -0,0 +1,96 @@
<?php
namespace Tests\Unit\Resources;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\Resources\TemplateResource;
use App\Support\TemplateExternals;
use Tests\TestCase;
/**
* TemplateResource externals 정규화 테스트
*
* `asset`(템플릿이 자체 제공하는 `dist/` 이하 경로) 항목은 URL 을 만들 때
* 식별자와 캐시 버전을 요구한다. 리소스가 그 둘을 넘기지 않으면 항목이
* "식별자 미상" 으로 **조용히 버려져** 응답에서 사라진다 — 예외도 오류도
* 남지 않고 필드만 빈 배열이 된다.
*
* 뷰 컴포저(CollectsTemplateExternals)와 이 리소스는 같은 정규화기를 쓰는
* 두 소비자다. 한쪽만 인자를 갖추면 화면은 정상인데 API 만 비는 비대칭이
* 생기므로, 두 경로가 같은 결과를 내는지까지 잠근다.
*/
class TemplateResourceExternalsTest extends TestCase
{
/**
* `asset` externals 를 선언한 템플릿 manifest 조각을 만듭니다.
*
* @return array<string, mixed> 리소스 입력용 배열
*/
private function templatePayload(): array
{
return [
'id' => 1,
'identifier' => 'test-assetexternals',
'externals' => [
[
'id' => 'fontawesome',
'type' => 'style',
'asset' => 'vendor/font-awesome/6.4.0/css/all.inlined.css',
],
[
'id' => 'pretendard',
'type' => 'webfont',
'asset' => 'vendor/pretendard/1.3.9/pretendard-variable.css',
],
],
];
}
/**
* `asset` 항목이 응답에서 사라지지 않는지 확인합니다.
*/
public function test_asset_externals_survive_detail_serialization(): void
{
$externals = (new TemplateResource($this->templatePayload()))->toDetailArray()['externals'];
$this->assertCount(2, $externals, '`asset` externals 가 응답에서 버려졌다 (식별자 미전달)');
$this->assertSame(
['fontawesome', 'pretendard'],
array_column($externals, 'id')
);
}
/**
* 해석된 URL 이 same-origin 인지 확인합니다.
*/
public function test_asset_externals_resolve_to_same_origin_urls(): void
{
$externals = (new TemplateResource($this->templatePayload()))->toDetailArray()['externals'];
foreach ($externals as $item) {
$url = $item['url'] ?? $item['href'] ?? '';
$this->assertStringStartsWith('/', $url, '자체 제공 자산이 same-origin 경로로 해석되지 않았다');
$this->assertStringNotContainsString('//', ltrim($url, '/'), 'protocol-relative URL 로 해석되었다');
}
}
/**
* 리소스 경로와 뷰 컴포저 경로가 같은 결과를 내는지 확인합니다.
*
* 두 소비자가 어긋나면 화면은 정상인데 API 만 비는 비대칭이 생긴다.
*/
public function test_resource_matches_view_composer_normalization(): void
{
$payload = $this->templatePayload();
$fromResource = (new TemplateResource($payload))->toDetailArray()['externals'];
$fromComposerPath = TemplateExternals::normalize(
$payload['externals'],
$payload['identifier'],
ClearsTemplateCaches::getExtensionCacheVersion(),
);
$this->assertEquals($fromComposerPath, $fromResource);
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
namespace Tests\Unit\Support;
use App\Services\ExtensionStaticCacheService;
use App\Support\CustomAssets;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 사용자 추가 에셋(`custom/`) 해석기 테스트
*
* 운영자가 자기 CSS·JS 를 덧붙일 자리를 각 확장이 제공한다. 이 해석기는 여러 출처를
* 합쳐 **출처에 의존하지 않는 서술자** 목록을 만든다 — 소비자(뷰 컴포저·프론트 로더)는
* 파일에서 왔는지 선언에서 왔는지 보지 않는다.
*
* DB 를 쓰지 않는다 — 파일 시스템 해석만 검증한다.
*/
class CustomAssetsTest extends TestCase
{
/** 테스트용 가짜 플러그인 식별자 */
private const FAKE_PLUGIN = 'g7test-customassets';
/**
* 테스트용 확장 디렉토리 경로를 돌려줍니다.
*
* @return string 절대 경로
*/
private function pluginDir(): string
{
return base_path('plugins/'.self::FAKE_PLUGIN);
}
protected function setUp(): void
{
parent::setUp();
CustomAssets::flushCache();
File::ensureDirectoryExists($this->pluginDir().'/custom');
}
protected function tearDown(): void
{
File::deleteDirectory($this->pluginDir());
CustomAssets::flushCache();
parent::tearDown();
}
/**
* `custom/` 에 파일을 씁니다.
*
* @param string $relative 상대 경로
* @param string $contents 내용
* @return void
*/
private function writeCustom(string $relative, string $contents = '/* x */'): void
{
$path = $this->pluginDir().'/custom/'.$relative;
File::ensureDirectoryExists(dirname($path));
File::put($path, $contents);
CustomAssets::flushCache();
}
#[Test]
public function custom_디렉토리가_없으면_빈_목록이다(): void
{
File::deleteDirectory($this->pluginDir());
CustomAssets::flushCache();
$this->assertSame([], CustomAssets::forExtension('plugins', self::FAKE_PLUGIN));
}
/**
* @scenario custom_source=convention_scan, custom_asset=js
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_loaded_after_extension_bundles
*/
#[Test]
public function 규약_스캔은_파일명_오름차순으로_싣고_css_를_js_보다_먼저_둔다(): void
{
$this->writeCustom('20-second.css');
$this->writeCustom('10-first.css');
$this->writeCustom('script.js', '// x');
$assets = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN);
$this->assertCount(3, $assets);
$this->assertStringContainsString('10-first.css', $assets[0]['url']);
$this->assertStringContainsString('20-second.css', $assets[1]['url']);
$this->assertSame('script', $assets[2]['type']);
}
#[Test]
public function 서술자는_출처에_의존하지_않는_형태다(): void
{
$this->writeCustom('custom.css');
$asset = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN)[0];
$this->assertArrayHasKey('id', $asset);
$this->assertArrayHasKey('type', $asset);
$this->assertArrayHasKey('url', $asset);
$this->assertArrayHasKey('version', $asset);
$this->assertSame('file', $asset['source']);
$this->assertSame('style', $asset['type']);
}
/**
* URL 은 same-origin 이고, 서술자는 파일 서명을 들고 다닌다.
*
* URL 의 캐시 무효화 축은 **확장 캐시 버전**이다(세 타입 공통). 파일 서명(mtime)은 URL 에
* 실리지 않고 서술자 `version` 으로만 남아 **변경 감지 서명**의 재료가 된다 — 뷰 컴포저가
* 그 서명 변화를 보고 캐시 버전을 올리므로, 결과적으로 파일을 고치면 URL 도 바뀐다.
*
* 서명을 URL 에 실으면 정적 게시 경로가 영영 선택되지 않는다: 정적 경로는 언제나 현재
* 게시 버전이라 `AssetUrl` 의 버전 일치 게이트에 걸린다.
*
* @scenario custom_source=convention_scan, custom_asset=css
*
* @effects custom_asset_url_busts_on_file_change, runtime_asset_served_same_origin
*/
#[Test]
public function url_은_same_origin_이고_서술자가_파일_서명을_들고_다닌다(): void
{
$this->writeCustom('custom.css');
$first = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN)[0];
$this->assertStringStartsWith('/api/plugins/assets/', $first['url']);
$this->assertNotNull($first['version']);
// 파일을 고치면 서술자 서명이 달라진다 (감지 축)
touch($this->pluginDir().'/custom/custom.css', time() + 60);
CustomAssets::flushCache();
$second = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN)[0];
$this->assertNotSame($first['version'], $second['version']);
// URL 축은 확장 캐시 버전이다 — 버전이 오르면 URL 도 바뀐다
$this->assertStringContainsString(
'v='.ExtensionStaticCacheService::getExtensionCacheVersion(),
$second['url'],
'custom URL 이 확장 캐시 버전 축을 쓰지 않습니다 — 정적 게시 경로가 선택되지 않습니다.'
);
}
/**
* @scenario custom_source=assets_json, custom_asset=js
* @scenario custom_source=assets_json, custom_asset=css
*
* @effects custom_assets_json_declaration_overrides_convention_scan
*/
#[Test]
public function 선언_파일이_있으면_그것이_규약_스캔보다_우선한다(): void
{
$this->writeCustom('ignored.css');
$this->writeCustom('declared.css');
$this->writeCustom(CustomAssets::DECLARATION_FILE, json_encode([
'assets' => [
['type' => 'style', 'file' => 'declared.css'],
],
]));
$assets = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN);
$this->assertCount(1, $assets);
$this->assertStringContainsString('declared.css', $assets[0]['url']);
}
/**
* @scenario custom_source=assets_json_broken, custom_asset=css
* @scenario custom_source=assets_json_broken
*
* @effects custom_broken_declaration_yields_empty_list_without_scan_fallback
*/
#[Test]
public function 선언이_깨졌으면_규약_스캔으로_되돌아가지_않는다(): void
{
// 되돌아가면 운영자가 의도적으로 뺀 파일이 되살아난다
$this->writeCustom('should-not-load.css');
$this->writeCustom(CustomAssets::DECLARATION_FILE, '{ 깨진 JSON');
$this->assertSame([], CustomAssets::forExtension('plugins', self::FAKE_PLUGIN));
}
/**
* @scenario custom_source=assets_json, custom_asset=external_url
*
* @effects custom_external_url_allowed_only_from_declaration
*/
#[Test]
public function 외부_url_은_선언에서만_그리고_사유가_있을_때만_허용된다(): void
{
$this->writeCustom(CustomAssets::DECLARATION_FILE, json_encode([
'assets' => [
['type' => 'style', 'url' => 'https://fonts.example.com/a.css', 'reason' => '본문 웹폰트'],
['type' => 'style', 'url' => 'https://fonts.example.com/b.css'],
['type' => 'style', 'url' => 'http://insecure.example.com/c.css', 'reason' => '평문'],
],
]));
$assets = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN);
$this->assertCount(1, $assets);
$this->assertSame('https://fonts.example.com/a.css', $assets[0]['url']);
$this->assertSame('url', $assets[0]['source']);
}
/**
* @scenario custom_source=assets_json, custom_asset=static_file
*
* @effects custom_path_traversal_blocked, custom_disallowed_extension_blocked
*/
#[Test]
public function 경로_이탈과_미허용_확장자는_차단된다(): void
{
$this->writeCustom('safe.css');
$this->writeCustom(CustomAssets::DECLARATION_FILE, json_encode([
'assets' => [
['type' => 'style', 'file' => '../../../.env'],
['type' => 'style', 'file' => 'safe.css'],
['type' => 'script', 'file' => 'evil.php'],
['type' => 'style', 'file' => 'missing.css'],
],
]));
$assets = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN);
$this->assertCount(1, $assets);
$this->assertStringContainsString('safe.css', $assets[0]['url']);
}
#[Test]
public function 하위_디렉토리_파일은_규약_스캔이_싣지_않는다(): void
{
// 폰트·이미지는 CSS 가 상대 경로로 참조하는 대상이지 그 자체로 로드할 것이 아니다
$this->writeCustom('fonts/inner.css');
$this->assertSame([], CustomAssets::forExtension('plugins', self::FAKE_PLUGIN));
}
#[Test]
public function 확장_타입이_유효하지_않으면_빈_목록이다(): void
{
$this->assertSame([], CustomAssets::forExtension('unknown', self::FAKE_PLUGIN));
$this->assertNull(CustomAssets::directory('unknown', self::FAKE_PLUGIN));
}
#[Test]
public function 식별자에_경로_이탈이_섞이면_거부한다(): void
{
$this->assertNull(CustomAssets::directory('plugins', '../../etc'));
}
}
@@ -105,4 +105,158 @@ class TemplateExternalsTest extends TestCase
$this->assertSame('https://cdn.example.com', $hints[0]['url']);
$this->assertSame('dns-prefetch', $hints[1]['type']);
}
/**
* 자체 제공 자산(asset) 선언은 same-origin 자산 URL 로 해석된다.
*
* @scenario asset_class=vendored, outcome=loaded
*
* @effects template_external_asset_field_resolves_to_asset_url, runtime_asset_served_same_origin
*/
public function test_asset_field_resolves_to_same_origin_asset_url(): void
{
$normalized = TemplateExternals::normalize([
['id' => 'fontawesome', 'type' => 'style', 'asset' => 'vendor/font-awesome/6.4.0/css/all.inlined.css'],
], 'sirsoft-basic', 123);
$this->assertCount(1, $normalized);
$this->assertStringStartsWith('/api/templates/assets/sirsoft-basic', $normalized[0]['url']);
$this->assertStringNotContainsString('/dist/', $normalized[0]['url']);
$this->assertDoesNotMatchRegularExpression('#^(https?:)?//#', $normalized[0]['url']);
}
/**
* same-origin 항목에서는 외부 전용 키를 무시한다.
*
* 자기 서버 자산에 preconnect 를 걸거나 CORS 모드를 붙일 이유가 없다.
*
*
* @effects template_external_same_origin_ignores_preconnect_and_crossorigin
*/
public function test_same_origin_item_drops_external_only_keys(): void
{
$normalized = TemplateExternals::normalize([
[
'id' => 'pretendard',
'type' => 'webfont',
'asset' => 'vendor/pretendard/1.3.9/pretendard-variable.css',
'preconnect' => 'https://cdn.example.com',
'crossorigin' => 'anonymous',
],
], 'sirsoft-basic', 1);
$this->assertArrayNotHasKey('preconnect', $normalized[0]);
$this->assertArrayNotHasKey('crossorigin', $normalized[0]);
$this->assertSame([], TemplateExternals::resourceHints($normalized));
}
/**
* same-origin 절대 경로 url 도 그대로 통과시킨다.
*
*
* @effects template_external_asset_field_resolves_to_asset_url
*/
public function test_same_origin_path_url_is_accepted(): void
{
$normalized = TemplateExternals::normalize([
['id' => 'x', 'type' => 'style', 'url' => '/api/templates/assets/t/vendor/a.css'],
], 't', null);
$this->assertCount(1, $normalized);
$this->assertSame('/api/templates/assets/t/vendor/a.css', $normalized[0]['url']);
}
/**
* 브라우저가 외부 origin 으로 해석하는 형태는 same-origin 으로 인정하지 않는다.
*
* 접두 문자열만 보면 path 처럼 보이지만 브라우저는 authority 로 읽는 값들이다.
*
* @scenario asset_class=vendored, outcome=failed
*
* @effects template_external_invalid_url_is_logged_not_silently_dropped
*/
public function test_authority_bypass_shapes_are_rejected(): void
{
$normalized = TemplateExternals::normalize([
['id' => 'a', 'type' => 'style', 'url' => '//evil.example.com/x.css'],
['id' => 'b', 'type' => 'style', 'url' => '/\\evil.example.com/x.css'],
['id' => 'c', 'type' => 'style', 'url' => 'ftp://evil.example.com/x.css'],
], 't', null);
$this->assertSame([], $normalized);
}
/**
* asset 경로가 확장 디렉토리를 벗어나면 거부한다.
*
*
* @effects template_external_invalid_url_is_logged_not_silently_dropped
*/
public function test_asset_path_escape_is_rejected(): void
{
$normalized = TemplateExternals::normalize([
['id' => 'a', 'type' => 'style', 'asset' => '../../.env'],
['id' => 'b', 'type' => 'style', 'asset' => '/etc/passwd'],
], 'sirsoft-basic', null);
$this->assertSame([], $normalized);
}
/**
* 구동 자산(style/webfont/script)에는 실패 신호가 붙는다.
*
* 이 태그들은 서버가 HTML 에 직접 심으므로, 브라우저가 자산에 도달하지 못해도
* 자바스크립트에는 아무 신호가 오지 않는다. 실패는 "아이콘이 0×0 으로 사라진 화면"
* 으로만 나타나고 자체 서버 로그에도 흔적이 남지 않아 운영자가 원인을 특정할 수 없다.
* `onerror` 가 그 유일한 통로다.
*/
public function test_runtime_assets_carry_a_failure_signal(): void
{
$normalized = TemplateExternals::normalize([
['id' => 'fontawesome', 'type' => 'style', 'asset' => 'vendor/font-awesome/6.4.0/css/all.css'],
['id' => 'pretendard', 'type' => 'webfont', 'asset' => 'vendor/pretendard/1.3.9/pretendard.css'],
['id' => 'bootstrapper', 'type' => 'script', 'asset' => 'vendor/boot/boot.js'],
], 'sirsoft-basic', 1);
$this->assertCount(3, $normalized);
foreach ($normalized as $item) {
$attributes = $item['type'] === 'script'
? TemplateExternals::scriptAttributes($item)
: TemplateExternals::linkAttributes($item);
$this->assertArrayHasKey('onerror', $attributes, $item['id'].' 에 실패 신호가 있어야 한다');
$this->assertStringContainsString('__g7ExternalAssetFailed', $attributes['onerror']);
$this->assertStringContainsString("'".$item['id']."'", $attributes['onerror']);
}
}
/**
* 힌트(preload/preconnect 등)에는 실패 신호를 붙이지 않는다.
*
* 힌트는 실패해도 화면 기능이 사라지지 않는다. 여기에까지 배너를 띄우면 안내가
* 잡음이 되어 정작 조작 불능을 만드는 실패가 묻힌다.
*/
public function test_resource_hints_do_not_carry_a_failure_signal(): void
{
$normalized = TemplateExternals::normalize([
['id' => 'pre', 'type' => 'preload', 'asset' => 'vendor/font.woff2', 'as' => 'font'],
], 'sirsoft-basic', 1);
$this->assertCount(1, $normalized);
$this->assertArrayNotHasKey('onerror', TemplateExternals::linkAttributes($normalized[0]));
}
/**
* 실패 신호 라벨은 id 가 없으면 파일명으로 떨어진다 (운영자가 대상을 알아볼 수 있어야 한다).
*/
public function test_failure_signal_label_falls_back_to_file_name(): void
{
$normalized = TemplateExternals::normalize([
['type' => 'style', 'asset' => 'vendor/font-awesome/6.4.0/css/all.inlined.css'],
], 'sirsoft-basic', 1);
$this->assertCount(1, $normalized);
$this->assertStringContainsString("'all.inlined.css'", TemplateExternals::linkAttributes($normalized[0])['onerror']);
}
}
@@ -0,0 +1,81 @@
feature: 사용자 추가 에셋 화면 관리 (레이아웃 편집기 + 전용 권한)
description: |
운영자가 자기 CSS·JS·폰트·이미지를 **화면에서** 넣고 고칠 수 있게 한다.
종전에는 FTP 나 서버 셸이 유일한 경로였다 — 그 접근이 없는 운영자에게는 기능 자체가
없는 것과 같았고, 있는 운영자에게도 "고쳤는데 화면에 안 나온다"(정적 게시본 미갱신)가
남았다.
핵심 동작:
- 전용 권한(`core.extensions.custom_assets.manage`) 하나가 세 확장 타입의 관리 API 를 가드한다
- 레이아웃 편집 권한만으로는 통과하지 못한다 (여기서 올린 스크립트는 사이트 전역에서 실행된다)
- 모듈·플러그인·템플릿이 한 엔드포인트(`extensions/{type}/{id}/custom-assets`)를 공유한다
- 텍스트(css/js/mjs/json)는 본문 편집, 그 밖(폰트·이미지)은 업로드·삭제만
- 쓰기 뒤 확장 캐시 버전을 올려 정적 게시본을 갱신한다
- 경로 탈출은 세그먼트 판정으로 차단한다 (문자열 접두 비교는 형제 디렉토리를 통과시킨다)
- `?custom=off` 로 다시 열면 서버가 목록을 비운다 — 자기 CSS 에 갇히지 않기 위한 탈출구
이 기능의 결함은 대체로 오류를 남기지 않는다. 약한 경로가 정상 200 을 내보내는 것이
유일한 증상이므로 테스트가 유일한 방어선이다.
coverage_strategy: pairwise
axes:
manage_actor: [with_permission, layout_edit_only, unauthenticated]
manage_action: [list, read, save, upload, delete]
exclusions:
- { manage_actor: unauthenticated, manage_action: read, reason: "인증 게이트가 동작 종류보다 앞이라 list 1건으로 대표된다" }
- { manage_actor: unauthenticated, manage_action: save, reason: "동일" }
- { manage_actor: unauthenticated, manage_action: upload, reason: "동일" }
- { manage_actor: unauthenticated, manage_action: delete, reason: "동일" }
- { manage_actor: layout_edit_only, manage_action: upload, reason: "권한 미들웨어가 라우트별로 동일해 list/read/save 3건으로 대표된다" }
- { manage_actor: layout_edit_only, manage_action: delete, reason: "동일" }
effects:
- custom_asset_manage_requires_dedicated_permission
- custom_asset_manage_path_traversal_blocked
- custom_asset_manage_upload_extension_whitelist
- custom_asset_manage_binary_rejects_text_save
- custom_asset_editor_save_invalidates_published_copy
- custom_asset_manager_surfaces_permission_denial_as_guidance
- custom_asset_binary_file_offers_replace_not_text_edit
- custom_asset_permission_reaches_existing_sites_via_core_sync
- custom_assets_disabled_by_request_parameter
- custom_asset_manage_all_extension_types_parity
- custom_asset_published_for_all_extension_types
test_files:
- tests/Feature/Api/Admin/AdminExtensionCustomAssetControllerTest.php
- tests/Feature/Permission/CustomAssetPermissionSyncTest.php
- tests/Feature/View/CustomAssetInjectionTest.php
- resources/js/core/template-engine/layout-editor/__tests__/components/CustomAssetsModal.test.tsx
- tests/Playwright/specs/custom-asset-management.spec.ts
- tests/Feature/Services/ExtensionStaticCacheServiceTest.php
sub_flows:
- id: custom_manage_type_parity
description: |
세 확장 타입이 같은 강도로 동작해야 한다. 타입별로 경로·검증이 갈리면 그중 약한 쪽이
조용한 우회로가 되고, 반대로 한 타입만 동작하면 운영자는 "모듈에서는 왜 안 되는지" 를
알 수 없다.
게시 축도 같다 — 모듈·플러그인 `custom/` 을 게시하지 않으면 그쪽만 CSS 내부 상대
`url()` 이 해석되지 않는다(그 자산만 요청마다 PHP 를 거치는 것은 부수 효과다).
- id: custom_manage_no_upgrade_step_needed
description: |
신규 권한은 별도 업그레이드 스텝 없이 코어 업데이트의 표준 동기화
(`CoreUpdateService::syncCoreRolesAndPermissions`)로 기설치 사이트에 도달한다.
관리자 역할이 `all_leaf` 이므로 부여까지 자동이다.
그 전제가 사실인지를 테스트가 잠근다 — 사실이 아니면 권한이 정의만 되고 아무에게도
부여되지 않아, 화면은 정상인데 모두 403 이 된다.
- id: custom_manage_escape_hatch
description: |
운영자가 넣은 CSS 한 줄이 화면을 조작 불능으로 만들면, 그것을 고칠 편집기에도
같은 CSS 가 실려 스스로 갇힌다. `?custom=off` 는 **서버가** 목록을 비우는 방식이라
이미 깨진 화면에서 자바스크립트가 돌기를 기대하지 않아도 된다.
판정은 수집 맨 앞에 있어야 한다 — 뒤에 두면 빈 목록이 변경 감지에 도달해 요청마다
캐시 버전이 오르내리고 재게시가 끝없이 돈다.
@@ -0,0 +1,87 @@
feature: 사용자 추가 에셋 (확장 custom/ 디렉토리)
description: |
운영자가 자기 CSS·JS·정적 파일을 덧붙일 자리를 각 확장이 제공한다.
종전에는 그런 자리가 없어, CSS 한 줄을 더하려면 확장 소스를 고치고 빌드해야 했고 —
그렇게 넣은 파일은 다음 확장 업데이트에 통째로 사라졌다(확장 교체가 활성 디렉토리를
전부 갈아끼운다).
핵심 동작:
- 규약 스캔(custom/*.css·*.js) 또는 선언 파일(custom/assets.json) 로 목록을 만든다
- 확장 병합 번들 뒤에 로드해 운영자 재정의가 성립한다
- 확장 교체의 두 경로(rename · 제자리 동기화 폴백) 모두에서 보존된다
- 템플릿 custom 은 확장 자산과 같은 메커니즘으로 정적 게시된다 (상대 url() 성립 조건)
- 운영자가 파일을 고치면 그것을 감지해 확장 캐시 버전을 올려 재게시를 예약한다
- 모듈·플러그인 custom 은 개별 자산 게시 대상이 아니라 파일 서명 + API 서빙을 쓴다
- 운영자가 화면(레이아웃 편집기)에서 직접 넣고 고칠 수 있다 (별도 권한으로 가드)
- `?custom=off` 로 다시 열면 서버가 목록을 비운다 — 자기 CSS 에 갇히지 않기 위한 탈출구
coverage_strategy: pairwise
axes:
custom_source: [none, convention_scan, assets_json, assets_json_broken]
custom_asset: [css, js, static_file, external_url]
exclusions:
- { custom_source: none, custom_asset: css, reason: "출처가 없으면 자산 종류 축이 성립하지 않는다" }
- { custom_source: none, custom_asset: js, reason: "동일" }
- { custom_source: none, custom_asset: static_file, reason: "동일" }
- { custom_source: none, custom_asset: external_url, reason: "동일" }
- { custom_source: convention_scan, custom_asset: external_url, reason: "외부 URL 은 assets.json 선언에서만 허용 (D14)" }
- { custom_source: assets_json_broken, custom_asset: js, reason: "깨진 선언은 종류와 무관하게 빈 목록" }
- { custom_source: assets_json_broken, custom_asset: static_file, reason: "동일" }
- { custom_source: assets_json_broken, custom_asset: external_url, reason: "동일" }
effects:
- custom_asset_loaded_after_extension_bundles
- custom_assets_json_declaration_overrides_convention_scan
- custom_broken_declaration_yields_empty_list_without_scan_fallback
- custom_dir_survives_extension_update
- custom_dir_survives_inplace_sync_fallback
- custom_dir_archived_on_uninstall
- custom_backup_path_surfaced_in_admin_ui
- custom_asset_url_busts_on_file_change
- custom_asset_cascade_order_module_plugin_template
- custom_script_execution_order_matches_declaration
- custom_external_url_allowed_only_from_declaration
- inactive_extension_custom_not_served
- custom_path_traversal_blocked
- custom_disallowed_extension_blocked
- custom_asset_containment_base_matches_serving_root
- custom_asset_published_with_extension_assets
- custom_assets_disabled_by_request_parameter
test_files:
- tests/Unit/Support/CustomAssetsTest.php
- tests/Feature/View/CustomAssetInjectionTest.php
- tests/Feature/Extension/CustomAssetPreservationTest.php
- resources/js/core/__tests__/ModuleAssetLoader.customAssets.test.ts
- tests/Playwright/specs/custom-assets.spec.ts
- tests/Feature/Extension/VendoredAssetServingTest.php
- tests/Feature/Extension/BundledExtensionNoExternalAssetTest.php
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-uninstall-preserved-backups.test.tsx
sub_flows:
- id: custom_manage_surface
description: |
운영자가 화면에서 직접 넣고 고치는 경로(권한·API·편집기 모달)는 축이 다르므로
별도 매니페스트가 다룬다 — `tests/scenarios/custom-asset-management.yaml`.
여기 축(출처 × 자산 종류)과 교차시키면 서로 무관한 조합이 곱해진다.
- id: custom_extension_type_variants
description: |
확장 타입(template / module / plugin)은 해석기가 같은 코드로 다루고 서빙 경로만
다르다. 타입별 경로 해석은 VendoredAssetServingTest 가 직접 잠근다.
- id: custom_override_wins
description: |
운영자 custom CSS 가 확장 병합 번들 CSS 보다 뒤에 적용된다.
브라우저 실측(E2E)이 **이기는 근거**까지 단언한다 — 번들 규칙은 `@layer` 안,
custom 은 레이어 밖이라 순서와 무관하게 이긴다. custom 끼리는 모두 레이어 밖·
같은 specificity 라 순수하게 소스 순서로 갈린다. 근거가 바뀌면(번들이 레이어를
걷어내면) 그 단언이 red 가 된다.
- id: custom_lifecycle_transitions
description: |
확장 수명주기 전이(설치 · 배치 · 비활성 · 재활성 · 업데이트 · 삭제 · 복원)에서
custom 자산의 적용/미적용이 상태와 일치한다. 자동화하려면 라이브 사이트에
확장을 설치·삭제해야 하므로 Chrome MCP 매트릭스(계획서 §19, T12)로 수행한다.
@@ -0,0 +1,75 @@
feature: 구동 에셋 자체 제공 + 로드 실패 폴백
description: |
브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하는 의존을 없애고(자체 제공),
그럼에도 자산을 못 불러온 경우를 사용자가 알고 조치할 수 있게 한다(폴백 + 안내 + 재시도).
더불어 운영자가 자기 CSS/JS/정적 파일을 덧붙일 자리(`custom/`)를 규약으로 연다.
핵심 동작:
- 확장·템플릿의 구동 자산을 `dist/vendor/{lib}/{version}/` 에 동봉하고 same-origin 으로 서빙
- 레이아웃 scripts / 확장 CSS·JS 로드에 재시도 계층 적용 후 최종 실패를 표면화
- CKEditor5·Monaco 확보 실패 시 textarea 폴백, 우편번호 SDK 부재 시 직접 입력 허용
- 운영자 `custom/` 자산은 확장 교체(rename 경로 · 제자리 동기화 폴백)에서 보존
- 외부 URL 은 확장 manifest 의 사유 선언분 또는 운영자의 `custom/assets.json` 등록분만
coverage_strategy: pairwise
axes:
# 자산 이름이 아니라 **동작이 갈리는 부류**를 축으로 둔다 — 이름별 열거는 테스트가
# 실제로 하는 일이 아니다(전수 스캔은 전부를 한 번에 보고, 로더는 종류와 무관하다).
# vendored : 동봉 자산 (실패 시 폴백·안내)
# service_sdk : 자체 제공 불가 (외부 의존 잔존, 직접 입력이 대안)
# translation : 선택적 열화 (실패해도 영어로 동작 — 폴백이 아니라 경고)
asset_class: [vendored, service_sdk, translation]
outcome: [loaded, failed]
exclusions:
- { asset_class: translation, outcome: loaded, reason: "번역은 실패 축만 의미가 있다 — 성공 경로는 vendored 와 같은 코드다" }
effects:
- runtime_asset_served_same_origin
- no_third_party_request_on_page_load
- vendored_asset_survives_rebuild
- vendored_asset_declared_path_exists_on_disk
- template_external_asset_field_resolves_to_asset_url
- template_external_same_origin_ignores_preconnect_and_crossorigin
- template_external_invalid_url_is_logged_not_silently_dropped
- bot_render_path_uses_same_origin_asset
- layout_script_retries_before_failing
- layout_script_url_converted_to_current_mode
- failed_layout_script_recorded_and_others_continue
- failed_asset_shows_retry_notice
- failed_asset_falls_back_to_plain_input
- retry_recovers_editor
- stylesheet_failure_retries_then_surfaces
- address_fields_editable_when_sdk_missing
- notice_renders_without_host_component_on_standalone_layout
test_files:
- tests/Unit/Support/TemplateExternalsTest.php
- tests/Feature/View/TemplateExternalsRenderingTest.php
- tests/Feature/Extension/BundledExtensionNoExternalAssetTest.php
- tests/Feature/Extension/VendoredAssetServingTest.php
- plugins/_bundled/sirsoft-ckeditor5/tests/Unit/VendoredAssetManifestTest.php
- resources/js/core/__tests__/TemplateApp.layoutScripts.test.ts
- resources/js/core/__tests__/G7CoreGlobals.test.ts
- resources/js/core/__tests__/AssetFailureNotice.test.ts
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
- resources/js/core/template-engine/__tests__/networkResilience.stylesheet.test.ts
- plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/html-editor-fallback.test.tsx
- plugins/_bundled/sirsoft-daum_postcode/resources/js/__tests__/postcode-fallback.test.ts
- templates/_bundled/sirsoft-admin_basic/__tests__/components/CodeEditor.fallback.test.tsx
- tests/Playwright/specs/self-hosted-assets.spec.ts
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts
sub_flows:
- id: asset_url_mode_variants
description: |
자산 URL 모드(extension / extensionless)와 정적 게시(on / off)는 환경 토글이라
단위 테스트가 조합으로 enumerate 하지 않는다. 전용 테스트(VendoredAssetServingTest
"자산 url 은 두 모드 모두 same origin", TemplateApp.layoutScripts "모드 보정")와
브라우저 점검 매트릭스가 그 변종을 맡는다.
- id: multilingual_editor_fallback
description: 다국어 탭 × textarea 폴백 — 탭 전환 시 현재 값 커밋, form.content 는 로케일 맵 유지
- id: bot_render_path
description: seo.blade.php 봇 렌더 경로에서도 자산이 same-origin 이어야 한다