Files
Gnuboard7/tests/Feature/Api/Public/ExtensionBundleServingTest.php
HeuJung 0c4f52fc96 fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김
7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
2026-09-08 10:42:19 +09:00

282 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Api\Public;
use App\Services\ExtensionBundleService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Mockery;
use Tests\TestCase;
/**
* [case:backend-27] 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
*
* /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag,
* 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를
* 컨테이너에 mock 바인딩해 활성 확장 조합에 의존하지 않고 컨트롤러 결선을 검증한다.
*/
class ExtensionBundleServingTest extends TestCase
{
private string $fixtureDir;
protected function setUp(): void
{
parent::setUp();
$this->fixtureDir = storage_path('framework/testing/ext-bundle-serving');
File::ensureDirectoryExists($this->fixtureDir);
}
protected function tearDown(): void
{
if (is_dir($this->fixtureDir)) {
foreach (glob($this->fixtureDir.'/*') as $f) {
@unlink($f);
}
@rmdir($this->fixtureDir);
}
Mockery::close();
parent::tearDown();
}
/**
* 번들 서비스를 모킹합니다.
*
* `$memoryContent` / `$declaredCount` / `$missing` 은 빈 경로(`$returnPath === ''`)일 때의
* 분기를 정한다 — 디스크 캐시 실패(메모리 폴백)인지, 병합 결과가 정당하게 비었는지
* (선언 0 이거나 선언한 산출물이 전부 존재하되 비어 있음), 선언한 산출물이 소실됐는지
* (장애)를 컨트롤러가 이 셋으로 가른다.
*
* 503 의 근거는 `$declaredCount` 가 아니라 `$missing` 이다. 선언만으로 판정하면
* "스타일이 비어 있는 확장" 의 정당한 상태가 배포 장애와 구분되지 않는다.
*
* @param string $returnPath getBundleFilePath 반환값
* @param string $memoryContent buildBundleContent 반환값
* @param int $declaredCount countAssetDeclaringExtensions 반환값
* @param list<string> $missing findMissingDeclaredAssets 반환값 (소실 산출물 절대 경로)
*/
private function bindBundleService(
string $returnPath,
string $memoryContent = '',
int $declaredCount = 0,
array $missing = []
): void {
$mock = Mockery::mock(ExtensionBundleService::class);
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
$mock->shouldReceive('buildBundleContent')->andReturn($memoryContent);
$mock->shouldReceive('countAssetDeclaringExtensions')->andReturn($declaredCount);
$mock->shouldReceive('findMissingDeclaredAssets')->andReturn($missing);
$this->app->instance(ExtensionBundleService::class, $mock);
}
/**
* @effects bundle_endpoint_serves_with_etag_and_304
*/
public function test_module_bundle_js_serves_merged_file_with_etag(): void
{
$path = $this->fixtureDir.'/module.777.js';
File::put($path, "(function(){window.A=1})()\n;\n(function(){window.B=2})()");
$this->bindBundleService($path);
$response = $this->get('/api/modules/bundle.js?v=777');
$response->assertOk();
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
$response->assertHeader('ETag');
$this->assertStringContainsString('window.A=1', $response->streamedContent() ?? $response->getContent());
$this->assertStringContainsString("\n;\n", $response->streamedContent() ?? $response->getContent());
}
/**
* @effects bundle_endpoint_serves_with_etag_and_304
*/
public function test_bundle_returns_304_on_matching_etag(): void
{
$path = $this->fixtureDir.'/plugin.777.js';
File::put($path, '(function(){})()');
$this->bindBundleService($path);
$first = $this->get('/api/plugins/bundle.js?v=777');
$first->assertOk();
$etag = $first->headers->get('ETag');
$this->assertNotNull($etag);
$second = $this->get('/api/plugins/bundle.js?v=777', ['If-None-Match' => $etag]);
$second->assertStatus(304);
}
/**
* @effects empty_bundle_returns_empty_path_and_empty_ok_response
*/
public function test_empty_bundle_returns_empty_ok_response(): void
{
// 에셋을 선언한 활성 확장이 0개 → 빈 번들이 정상이다 → 빈 200
$this->bindBundleService('', memoryContent: '', declaredCount: 0);
$response = $this->get('/api/modules/bundle.js?v=777');
$response->assertOk();
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
// 빈 번들은 일반 Response(스트림 아님) — getContent 로 빈 본문 확인
$this->assertSame('', $response->getContent());
}
/**
* 디스크 캐시 실패는 500 이 아니라 메모리 병합 결과 200 이다 (E1 fail-soft).
*
* `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
* `UnableToWriteFile` 이 그대로 올라온다. 캐시는 최적화일 뿐이므로 그 실패가
* 모든 확장의 프론트엔드 JS/CSS 를 통째로 막으면 안 된다.
*
* @effects bundle_disk_write_failure_falls_back_to_memory
*/
public function test_disk_cache_failure_serves_memory_merged_result(): void
{
// 디스크 캐시 실패 → 경로는 빈 문자열, 그러나 병합 결과는 메모리에 있다
$this->bindBundleService('', memoryContent: '(function(){window.MEM=1})()', declaredCount: 2);
$response = $this->get('/api/modules/bundle.js?v=777');
$response->assertOk();
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
$this->assertStringContainsString('window.MEM=1', $response->getContent());
// 캐시에 실패한 산출물이므로 immutable 로 박제하지 않는다
$this->assertStringContainsString('no-cache', (string) $response->headers->get('Cache-Control'));
}
/**
* 선언한 산출물이 소실됐는데 병합 결과가 비면 503 이다 (E2).
*
* 배포 중 `dist` 가 잠깐 비면 **모든 확장이 빠진 빈 번들**이 200 으로 나가고, 프론트는
* 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는
* 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다.
*
* 판정 근거는 선언 축이 아니라 **소실 축**이다. 선언 축만 보면 스타일이 비어 있는
* 확장(정당한 0바이트 산출물)까지 장애로 잡혀 기본 구성이 503 이 된다.
*
* // @scenario ext_type=module, asset_kind=js, active_combo=multiple, file_state=one_missing
*
* @effects bundle_empty_with_declared_assets_returns_503, empty_result_with_missing_declared_artifact_returns_503
*/
public function test_empty_bundle_with_declared_assets_returns_503(): void
{
$this->bindBundleService('', memoryContent: '', declaredCount: 3, missing: ['/x/dist/js/a.js']);
$response = $this->get('/api/modules/bundle.js?v=777');
$response->assertStatus(503);
}
/**
* 선언한 산출물이 **존재하되 비어 있으면** 정상 빈 200 이다 (이 이슈의 재현).
*
* 스타일 소스가 자리표시 주석뿐이라 0바이트 CSS 를 내보내는 확장은 정당한 상태다.
* 종전 판정은 "선언 > 0 && 병합 결과 0" 을 곧 배포 장애로 등치해, 그런 확장만 설치된
* 기본 구성의 사용자 화면마다 실패 안내가 떴다.
*
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=present_empty
*
* @effects empty_result_with_present_empty_artifacts_returns_200
*/
public function test_empty_result_with_present_empty_artifacts_returns_200(): void
{
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: []);
$response = $this->get('/api/modules/bundle.css?v=777');
$response->assertOk();
$this->assertStringStartsWith('text/css', $response->headers->get('Content-Type'));
$this->assertSame('', $response->getContent());
}
/**
* 플러그인 경로도 같은 판정을 쓴다 — 존재하되 비어 있으면 빈 200.
*
* // @scenario ext_type=plugin, asset_kind=css, active_combo=one, file_state=present_empty
*
* @effects bundle_decision_is_shared_across_extension_types, empty_result_with_present_empty_artifacts_returns_200
*/
public function test_plugin_bundle_returns_200_when_declared_artifacts_present_but_empty(): void
{
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: []);
$response = $this->get('/api/plugins/bundle.css?v=777');
$response->assertOk();
$this->assertSame('', $response->getContent());
}
/**
* 503 로그가 **어느 산출물이 없는지**를 싣는다.
*
* 경로가 없으면 운영자는 조치 대상을 짚을 수 없다 — 선언 수만으로는 어느 확장의
* 어느 파일인지 알 수 없다.
*
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=one_missing
*
* @effects missing_artifact_paths_are_logged
*/
public function test_503_log_lists_missing_artifact_paths(): void
{
Log::spy();
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: ['/x/dist/css/a.css']);
$this->get('/api/modules/bundle.css?v=777')->assertStatus(503);
Log::shouldHaveReceived('error')->withArgs(
fn ($message, $context) => ($context['missing'] ?? null) === ['/x/dist/css/a.css']
)->once();
}
/**
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (장애 쪽).
*
* @effects bundle_decision_is_shared_across_extension_types
*/
public function test_plugin_bundle_returns_503_when_declared_but_empty(): void
{
$this->bindBundleService('', memoryContent: '', declaredCount: 1, missing: ['/x/dist/css/a.css']);
$this->get('/api/plugins/bundle.css?v=777')->assertStatus(503);
}
/**
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (정상 쪽).
*
* @effects bundle_decision_is_shared_across_extension_types
*/
public function test_plugin_bundle_returns_empty_ok_when_nothing_declared(): void
{
$this->bindBundleService('', memoryContent: '', declaredCount: 0);
$this->get('/api/plugins/bundle.css?v=777')->assertOk();
}
public function test_module_bundle_css_serves_with_css_content_type(): void
{
$path = $this->fixtureDir.'/module.777.css';
File::put($path, '.a{color:red}');
$this->bindBundleService($path);
$response = $this->get('/api/modules/bundle.css?v=777');
$response->assertOk();
$this->assertStringStartsWith('text/css', $response->headers->get('Content-Type'));
}
public function test_bundle_routes_do_not_collide_with_asset_route(): void
{
// bundle.js 는 assets/{identifier}/{path} 로 매칭되면 안 됨 (정적 세그먼트 우선)
$path = $this->fixtureDir.'/module.777.js';
File::put($path, '(function(){window.BUNDLE=1})()');
$this->bindBundleService($path);
$response = $this->get('/api/modules/bundle.js?v=777');
$response->assertOk();
// 개별 에셋 서빙(serveAsset)이 아니라 번들 서빙(serveBundleJs)이 응답
$this->assertStringContainsString('window.BUNDLE=1', $response->streamedContent() ?? $response->getContent());
}
}