Files
Gnuboard7/tests/Feature/View/CustomAssetInjectionTest.php
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00

451 lines
20 KiB
PHP

<?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\Carbon;
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_asset=css, custom_change_detection=top_level_asset
*
* @effects custom_asset_signature_is_scoped_per_render, custom_signature_scoped_per_host
*/
#[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);
}
// 두 렌더의 서명이 **각자** 기억된다 — 하나의 자리를 서로 덮어쓰지 않는다.
// 스코프 키는 `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버별로 기억한다(#651 F9).
$host = gethostname() ?: 'host';
$stored = $cache->get(CustomAssets::SIGNATURE_CACHE_KEY);
$this->assertIsArray($stored, '서명이 스코프별로 기억되지 않는다 (단일 값으로 덮어쓰기)');
$this->assertArrayHasKey($adminTemplate.'@'.$host, $stored);
$this->assertArrayHasKey($userTemplate.'@'.$host, $stored);
$this->assertNotSame($stored[$adminTemplate.'@'.$host], $stored[$userTemplate.'@'.$host]);
// 파일은 그대로다 — 렌더를 오간다고 버전이 올라서는 안 된다
$this->assertFalse(
$sync->invoke($composer, $adminTemplate),
'관리자 렌더가 사용자 렌더의 서명을 덮어써 파일 변경으로 오독했다'
);
$this->assertFalse(
$sync->invoke($composer, $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, $userTemplate),
'파일을 고쳤는데 변경 감지가 발화하지 않았다'
);
} finally {
foreach ([$adminTemplate, $userTemplate] as $identifier) {
File::deleteDirectory(base_path('templates/'.$identifier));
}
CustomAssets::flushCache();
}
}
/**
* `custom/` **하위 디렉토리**의 글꼴·이미지 변경도 감지한다 — 게시와 같은 범위 (#651 F7).
*
* 종전 서명은 로드 목록(최상위 css/js)의 mtime 만 봤다. 게시는 `custom/**` 를 재귀로
* 복사하므로, 문서가 권장하는 `url('./img/marker.svg')` 의 대상 파일만 교체하면 게시본이
* 영영 갱신되지 않았다 — 실측(g7_2.dev): svg 62B→78B 교체 뒤 버전 불변, 게시본 62B.
* mtime 이 같은 재작성도 크기로 잡는다.
*
* @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file
*
* @effects custom_signature_covers_publishable_tree
*/
#[Test]
public function custom_하위_파일_크기_변경은_버전을_올린다(): void
{
$template = 'g7test-nested_tpl';
$dir = base_path('templates/'.$template.'/custom');
File::ensureDirectoryExists($dir.'/img');
File::put($dir.'/custom.css', ".x{background:url('./img/marker.svg')}");
File::put($dir.'/img/marker.svg', '<svg/>');
try {
$composer = app(TemplateComposer::class);
$sync = new ReflectionMethod(TemplateComposer::class, 'syncCustomAssetCacheVersion');
$cache = app(CacheInterface::class);
$cache->forget(CustomAssets::SIGNATURE_CACHE_KEY);
$cache->put('ext.cache_version', 1000);
CustomAssets::flushCache();
$this->assertFalse($sync->invoke($composer, $template), '첫 관측은 기록만 해야 한다');
$this->assertFalse($sync->invoke($composer, $template), '변경이 없으면 올리지 않는다');
// 최상위 css 는 그대로, 하위 svg 만 내용(크기)이 바뀐다 — mtime 은 그대로 둔다
$mtime = filemtime($dir.'/img/marker.svg');
File::put($dir.'/img/marker.svg', '<svg><!-- v2 --></svg>');
touch($dir.'/img/marker.svg', $mtime);
CustomAssets::flushCache();
$this->assertTrue(
$sync->invoke($composer, $template),
'하위 디렉토리 파일의 크기 변경이 감지되지 않았다 — 게시본의 글꼴·이미지가 영영 갱신되지 않는다'
);
$this->assertGreaterThan(1000, (int) $cache->get('ext.cache_version'));
} finally {
File::deleteDirectory(base_path('templates/'.$template));
CustomAssets::flushCache();
}
}
/**
* 서명 키는 만료되지 않는다 — 기본 TTL 로 만료되면 그 경계의 첫 관측이 "기록만" 이라 변경 1회를 놓친다 (#651 F11).
*
* @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=no_change
*
* @effects cache_version_key_does_not_expire
*/
#[Test]
public function 서명_키는_이틀_뒤에도_남아_있다(): void
{
$this->mock(PluginManager::class, function ($mock) {
$mock->shouldReceive('getActivePlugins')->andReturn([self::FAKE_PLUGIN => []]);
$mock->shouldReceive('loadPlugins')->andReturnNull();
});
$composer = app(TemplateComposer::class);
$collect = new ReflectionMethod(TemplateComposer::class, 'collectCustomAssets');
$cache = app(CacheInterface::class);
$cache->forget(CustomAssets::SIGNATURE_CACHE_KEY);
CustomAssets::flushCache();
$collect->invoke($composer, null);
$this->assertIsArray($cache->get(CustomAssets::SIGNATURE_CACHE_KEY));
try {
Carbon::setTestNow(now()->addDays(2));
$this->assertIsArray(
$cache->get(CustomAssets::SIGNATURE_CACHE_KEY),
'서명 키가 기본 TTL 로 만료됐다 — 만료 경계의 실제 변경 1회가 조용히 삼켜진다'
);
} finally {
Carbon::setTestNow();
}
}
}