Files
Gnuboard7/tests/Unit/Support/PackageManifestCacheHelperTest.php
T
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

211 lines
8.0 KiB
PHP

<?php
namespace Tests\Unit\Support;
use App\Support\PackageManifestCacheHelper;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 패키지 매니페스트 캐시 헬퍼 단위 테스트 (dev-g7 #658).
*
* 이 헬퍼가 삭제 로직의 단일 지점이다 — spawn 직전 선정리(`CoreUpdateCommand`)와 업데이트
* 마무리(`CoreUpdateService::clearAllCaches()`)가 같은 코드를 쓴다.
*
* 격리: 실제 `bootstrap/cache/*` 를 건드리지 않도록 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE`
* 를 base_path 상대 경로로 재지정한다(`Application::normalizeCachePath` 규칙).
*/
class PackageManifestCacheHelperTest extends TestCase
{
/** @var array<string, string|null> 테스트가 덮어쓴 env 의 원값 (3채널 복원용) */
private array $originalEnv = [];
private string $packagesPath;
private string $servicesPath;
protected function setUp(): void
{
parent::setUp();
$uniq = uniqid();
$relPackages = "storage/framework/testing/manifest-helper-{$uniq}/packages.php";
$relServices = "storage/framework/testing/manifest-helper-{$uniq}/services.php";
$this->packagesPath = base_path($relPackages);
$this->servicesPath = base_path($relServices);
File::ensureDirectoryExists(dirname($this->packagesPath));
$this->setEnv('APP_PACKAGES_CACHE', $relPackages);
$this->setEnv('APP_SERVICES_CACHE', $relServices);
}
protected function tearDown(): void
{
foreach ($this->originalEnv as $key => $value) {
if ($value === null) {
unset($_ENV[$key], $_SERVER[$key]);
putenv($key);
} else {
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
putenv($key.'='.$value);
}
}
$this->originalEnv = [];
$dir = dirname($this->packagesPath);
if (File::isDirectory($dir)) {
File::deleteDirectory($dir);
}
parent::tearDown();
}
/**
* env 로 재지정한 경로의 두 파일을 지운다 (하드코딩된 bootstrap/cache 가 아니라).
*
* @effects PackageManifestCacheHelper_clear_unlinks_packages_and_services_at_configured_paths
*/
#[Test]
public function clear_는_설정된_경로의_packages_와_services_를_지운다(): void
{
File::put($this->packagesPath, "<?php return [];\n");
File::put($this->servicesPath, "<?php return [];\n");
$this->assertSame($this->packagesPath, $this->app->getCachedPackagesPath(), '전제: env 재지정이 경로를 정한다');
$this->assertSame($this->servicesPath, $this->app->getCachedServicesPath(), '전제: env 재지정이 경로를 정한다');
$remaining = PackageManifestCacheHelper::clear();
$this->assertFileDoesNotExist($this->packagesPath);
$this->assertFileDoesNotExist($this->servicesPath);
$this->assertSame([], $remaining, '전부 지웠으면 남은 파일이 없다');
}
/**
* 파일이 없어도 예외를 던지지 않는다 (부팅 직전에 불리므로 실패가 부팅을 막으면 안 된다).
*/
#[Test]
public function clear_는_파일이_없으면_아무_일도_하지_않는다(): void
{
$this->assertFileDoesNotExist($this->packagesPath, '전제: 파일이 없다');
$remaining = PackageManifestCacheHelper::clear();
$this->assertFileDoesNotExist($this->packagesPath);
$this->assertFileDoesNotExist($this->servicesPath);
$this->assertSame([], $remaining, '원래 없던 파일은 "지우지 못한 파일" 이 아니다');
}
/**
* 지우지 못한 파일은 경로로 돌려주고, 지울 수 있는 형제 파일은 그대로 지운다.
*
* 호출부(spawn 직전)가 이 목록을 업그레이드 로그에 남긴다 — 권한·소유권 불일치면 자식의 자가
* 치유도 같은 이유로 실패해 증상은 「Class not found」 그대로인데, 그 기록이 원인을 가리키는
* 유일한 흔적이다. 삭제 실패는 플랫폼마다 실제로 만든다: Windows 는 열린 핸들이, POSIX 는 부모
* 디렉토리의 쓰기 권한이 삭제를 막는다.
*
* @effects PackageManifestCacheHelper_clear_returns_paths_it_could_not_remove
*/
#[Test]
public function clear_는_지우지_못한_파일의_경로를_돌려준다(): void
{
File::put($this->packagesPath, "<?php return [];\n");
File::put($this->servicesPath, "<?php return [];\n");
$release = $this->makeUndeletable($this->packagesPath);
try {
$remaining = PackageManifestCacheHelper::clear();
} finally {
$release();
}
$this->assertSame([$this->packagesPath], $remaining, '지우지 못한 파일만 경로로 돌려준다');
$this->assertFileExists($this->packagesPath, '삭제가 막힌 파일은 그대로 남는다');
$this->assertFileDoesNotExist($this->servicesPath, '지울 수 있는 형제 파일은 지운다');
}
/**
* 파일을 현재 프로세스가 삭제할 수 없는 상태로 만들고, 되돌리는 클로저를 반환합니다.
*
* Windows 는 읽기 전용 속성(0444)이 삭제를 막고(PHP 7.3+ 는 파일을 `FILE_SHARE_DELETE` 로 열어
* 열린 핸들로는 막히지 않는다 — 실측), POSIX 는 부모 디렉토리의 쓰기 권한이 삭제를 막는다(root 는
* 권한을 우회하므로 이 방법으로 실패를 만들 수 없다).
*
* @param string $path 삭제를 막을 파일
* @return \Closure 원상 복구 클로저
*/
private function makeUndeletable(string $path): \Closure
{
if (PHP_OS_FAMILY === 'Windows') {
$this->assertTrue(chmod($path, 0444), '전제: 읽기 전용 속성을 건다');
return static function () use ($path): void {
if (is_file($path)) {
chmod($path, 0644);
}
};
}
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
$this->markTestSkipped('root 는 디렉토리 권한으로 삭제 실패를 만들 수 없다 — 비-root 로 실행해야 이 축이 측정된다');
}
$dir = dirname($path);
$this->assertTrue(chmod($dir, 0555), '전제: 부모 디렉토리의 쓰기 권한을 뺀다');
return static function () use ($dir): void {
chmod($dir, 0755);
};
}
/**
* rebuild() 는 삭제 후 package:discover 로 다시 만든다.
*
* @effects clearAllCaches_rebuilds_package_manifest_via_helper
*/
#[Test]
public function rebuild_는_지운_뒤_package_discover_를_한_번_호출한다(): void
{
File::put($this->packagesPath, "<?php return [];\n");
File::put($this->servicesPath, "<?php return [];\n");
Artisan::shouldReceive('call')->once()->with('package:discover')->andReturn(0);
PackageManifestCacheHelper::rebuild();
$this->assertFileDoesNotExist($this->packagesPath, 'rebuild 는 먼저 지운다');
$this->assertFileDoesNotExist($this->servicesPath, 'rebuild 는 먼저 지운다');
}
/**
* env 를 세 채널($_ENV/$_SERVER/putenv)에 세우고 원값을 복원용으로 보관합니다.
*
* @param string $key 환경변수 이름
* @param string|null $value 세울 값. null 이면 제거
*/
private function setEnv(string $key, ?string $value): void
{
if (! array_key_exists($key, $this->originalEnv)) {
$current = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
$this->originalEnv[$key] = ($current === false || $current === null) ? null : (string) $current;
}
if ($value === null) {
unset($_ENV[$key], $_SERVER[$key]);
putenv($key);
return;
}
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
putenv($key.'='.$value);
}
}