fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를 --no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는 `composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를 찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상 종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다. (sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10) 3계층으로 막는다. 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다. 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다. 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없다). 업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고 CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의 복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다. 실측 중 드러난 결함 2건을 함께 고쳤다. - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다. - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다). 인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인 카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를 정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다. 코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3). 코어 7.0.10 → 7.0.11.
This commit is contained in:
1 parent
21177c8e17
commit
ffab451b4a
42 files changed
+2660
-82
No files matched your search
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ComposerInstallInfo;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* vendor 개발용 설치 판정 SSoT 테스트 (dev-g7 #658).
|
||||
*
|
||||
* 인스톨러(순수 PHP)와 코어 업데이트가 같은 vendor 를 두고 서로 다른 답을 내놓지 않도록
|
||||
* 판정을 이 클래스 하나가 소유한다. "판정 불가"(null)와 "개발용 아님"(false)을 구분하는 것이
|
||||
* 핵심이다 — 뭉뚱그리면 installed.json 이 없는 환경이 "운영용 구성 확인됨" 으로 보인다.
|
||||
*/
|
||||
class ComposerInstallInfoTest extends TestCase
|
||||
{
|
||||
private string $vendorPath;
|
||||
|
||||
private string $basePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->basePath = storage_path('framework/testing/composer-install-info-'.uniqid());
|
||||
$this->vendorPath = $this->basePath.'/vendor';
|
||||
|
||||
File::ensureDirectoryExists($this->vendorPath.'/composer');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->basePath)) {
|
||||
File::deleteDirectory($this->basePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* `dev: true` + 이름 목록 → 개발용 설치.
|
||||
*
|
||||
* @effects core_detects_dev_vendor_from_installed_json
|
||||
*/
|
||||
#[Test]
|
||||
public function dev_true_와_이름_목록이_있으면_개발용_설치로_판정한다(): void
|
||||
{
|
||||
$this->writeInstalledJson([
|
||||
'packages' => [],
|
||||
'dev' => true,
|
||||
'dev-package-names' => ['laravel/boost', 'laravel/mcp', 'laravel/pail'],
|
||||
]);
|
||||
|
||||
$info = ComposerInstallInfo::inspect($this->vendorPath);
|
||||
|
||||
$this->assertTrue($info['dev']);
|
||||
$this->assertSame(['laravel/boost', 'laravel/mcp', 'laravel/pail'], $info['packages']);
|
||||
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
$this->assertCount(3, ComposerInstallInfo::devPackageNames($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* `--no-dev` 설치 → 개발용 아님.
|
||||
*
|
||||
* @effects core_reports_no_dev_packages_for_no_dev_vendor
|
||||
*/
|
||||
#[Test]
|
||||
public function dev_false_이고_이름_목록이_비면_운영용_설치로_판정한다(): void
|
||||
{
|
||||
$this->writeInstalledJson([
|
||||
'packages' => [],
|
||||
'dev' => false,
|
||||
'dev-package-names' => [],
|
||||
]);
|
||||
|
||||
$info = ComposerInstallInfo::inspect($this->vendorPath);
|
||||
|
||||
$this->assertFalse($info['dev']);
|
||||
$this->assertSame([], $info['packages']);
|
||||
$this->assertFalse(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
$this->assertSame([], ComposerInstallInfo::devPackageNames($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* `dev` 는 false 인데 이름 목록이 남아 있으면 개발용으로 본다 — 두 신호는 OR 다.
|
||||
* Composer 버전에 따라 한쪽만 채워질 수 있고, 등재된 provider 가 있다는 사실이 중요하다.
|
||||
*/
|
||||
#[Test]
|
||||
public function dev_false_라도_이름_목록이_비어있지_않으면_개발용으로_판정한다(): void
|
||||
{
|
||||
$this->writeInstalledJson([
|
||||
'packages' => [],
|
||||
'dev' => false,
|
||||
'dev-package-names' => ['laravel/mcp'],
|
||||
]);
|
||||
|
||||
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* installed.json 이 없으면 판정 불가(null) — "개발용 아님"(false)과 구분한다.
|
||||
*
|
||||
* @effects core_returns_unknown_when_installed_json_missing
|
||||
*/
|
||||
#[Test]
|
||||
public function installed_json_이_없으면_판정_불가다(): void
|
||||
{
|
||||
$info = ComposerInstallInfo::inspect($this->vendorPath);
|
||||
|
||||
$this->assertNull($info['dev']);
|
||||
$this->assertSame([], $info['packages']);
|
||||
$this->assertNull(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 파싱 실패도 판정 불가다.
|
||||
*/
|
||||
#[Test]
|
||||
public function json_이_깨졌으면_판정_불가다(): void
|
||||
{
|
||||
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, '{ not json');
|
||||
|
||||
$this->assertNull(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer 1 형식(최상위가 패키지 배열)은 dev 정보가 없어 판정 불가다.
|
||||
*/
|
||||
#[Test]
|
||||
public function composer1_형식은_판정_불가다(): void
|
||||
{
|
||||
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, json_encode([
|
||||
['name' => 'vendor/pkg', 'version' => '1.0.0'],
|
||||
]));
|
||||
|
||||
$this->assertNull(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름 목록의 비문자열·공백 항목은 걸러낸다 (건수를 그대로 화면에 표시하므로).
|
||||
*/
|
||||
#[Test]
|
||||
public function 이름_목록의_비문자열과_공백_항목을_걸러낸다(): void
|
||||
{
|
||||
$this->writeInstalledJson([
|
||||
'packages' => [],
|
||||
'dev' => true,
|
||||
'dev-package-names' => ['laravel/mcp', '', ' ', 123, null, ['nested'], 'laravel/pail'],
|
||||
]);
|
||||
|
||||
$this->assertSame(['laravel/mcp', 'laravel/pail'], ComposerInstallInfo::devPackageNames($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로 끝의 구분자 유무와 무관하게 같은 결과를 낸다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 경로_끝_구분자_유무와_무관하게_동작한다(): void
|
||||
{
|
||||
$this->writeInstalledJson([
|
||||
'packages' => [],
|
||||
'dev' => true,
|
||||
'dev-package-names' => ['laravel/mcp'],
|
||||
]);
|
||||
|
||||
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath.'/'));
|
||||
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* installed.json 픽스처를 씁니다.
|
||||
*
|
||||
* @param array<string, mixed> $payload JSON 으로 직렬화할 내용
|
||||
*/
|
||||
private function writeInstalledJson(array $payload): void
|
||||
{
|
||||
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, json_encode($payload));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -59,6 +61,84 @@ class ConfigCacheHelperContainerTest extends TestCase
|
||||
$this->assertSame($original, Container::getInstance());
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백이 파사드 애플리케이션을 바꿔 놓아도 실행 후 원래 앱으로 돌아온다.
|
||||
*
|
||||
* `config:cache` 가 부팅하는 일회용 앱의 `registerBaseBindings()` 는 컨테이너 인스턴스만이 아니라
|
||||
* `Facade::clearResolvedInstances()` + `Facade::setFacadeApplication()` 도 호출한다. 컨테이너만
|
||||
* 되돌리면 그 뒤의 **모든 파사드 호출**(`Artisan`·`Log`·`DB`·`Cache` …)이 종료된 일회용 앱에서
|
||||
* 인스턴스를 해석한다.
|
||||
*
|
||||
* 코어 업데이트에서 드러난 형태: Step 11 의 `ConfigCacheHelper::rebuild()` 뒤에 오는
|
||||
* `RouteCacheHelper::rebuild()` 의 `Artisan::call('route:clear')` 가 일회용 앱에서 **새 콘솔 Kernel**
|
||||
* 을 해석하고, 그 Kernel 이 콘솔 Application 을 처음부터 구성하며 등록된 커맨드를 전부 resolve 해
|
||||
* `Target class [command.tinker] does not exist.` 로 업데이트가 실패·롤백했다 (2026-09-07 실측).
|
||||
*
|
||||
* @effects config_cache_rebuild_preserves_facade_application
|
||||
*/
|
||||
#[Test]
|
||||
public function 콜백이_파사드_애플리케이션을_바꿔도_원래_앱으로_되돌린다(): void
|
||||
{
|
||||
$original = Facade::getFacadeApplication();
|
||||
$this->assertNotNull($original, '전제: 파사드 애플리케이션이 설정되어 있다');
|
||||
|
||||
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||
// 일회용 앱 부팅이 하는 일과 같은 형태 — 해석 캐시를 비우고 다른 앱을 가리킨다.
|
||||
Facade::clearResolvedInstances();
|
||||
Facade::setFacadeApplication(new Container);
|
||||
});
|
||||
|
||||
$this->assertSame(
|
||||
$original,
|
||||
Facade::getFacadeApplication(),
|
||||
'config:cache 뒤 파사드가 일회용 앱을 가리킨 채 남으면 후속 Artisan::call 이 새 콘솔 Kernel 을 만든다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백이 예외를 던져도 파사드 애플리케이션은 되돌린다.
|
||||
*
|
||||
* @effects config_cache_rebuild_preserves_facade_application
|
||||
*/
|
||||
#[Test]
|
||||
public function 콜백이_실패해도_파사드_애플리케이션을_되돌린다(): void
|
||||
{
|
||||
$original = Facade::getFacadeApplication();
|
||||
|
||||
try {
|
||||
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||
Facade::setFacadeApplication(new Container);
|
||||
|
||||
throw new \RuntimeException('boom');
|
||||
});
|
||||
$this->fail('예외가 전파되어야 한다');
|
||||
} catch (\RuntimeException) {
|
||||
// 기대한 예외
|
||||
}
|
||||
|
||||
$this->assertSame($original, Facade::getFacadeApplication());
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌린 뒤 파사드가 원래 앱의 인스턴스를 해석한다 (해석 캐시 오염 없음).
|
||||
*
|
||||
* @effects config_cache_rebuild_preserves_facade_application
|
||||
*/
|
||||
#[Test]
|
||||
public function 되돌린_뒤_파사드가_원래_앱의_인스턴스를_해석한다(): void
|
||||
{
|
||||
$original = Facade::getFacadeApplication();
|
||||
$kernelBefore = app(Kernel::class);
|
||||
|
||||
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||
Facade::clearResolvedInstances();
|
||||
Facade::setFacadeApplication(new Container);
|
||||
});
|
||||
|
||||
$this->assertSame($original, Facade::getFacadeApplication());
|
||||
$this->assertSame($kernelBefore, app(Kernel::class), '콘솔 Kernel 이 새로 만들어지면 등록된 커맨드를 전부 다시 resolve 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* `rebuild()` 가 `config:cache` 호출을 보존 래퍼로 감싼다 (소스 구조).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\CoreUpdateContext;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 코어 업데이트 트리 판정 단일 SSoT 테스트 (dev-g7 #658).
|
||||
*
|
||||
* 이 판정 하나가 세 가지를 게이트한다 — 확장 자동 비활성화 스킵, 코어 버전의 env 우선 판독,
|
||||
* `bootstrap/app.php` 의 패키지 매니페스트 자가 치유. 판정이 어긋나면 그중 한 경로만 조용히
|
||||
* 다르게 동작하므로 세 채널($_ENV/$_SERVER/putenv)과 argv 를 각각 잠근다.
|
||||
*/
|
||||
class CoreUpdateContextTest extends TestCase
|
||||
{
|
||||
/** @var string|null 원래 env 값 (3채널 복원용) */
|
||||
private ?string $originalFlag = null;
|
||||
|
||||
/** @var array<int, string> 원래 argv */
|
||||
private array $originalArgv = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$current = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
||||
$this->originalFlag = ($current === false || $current === null) ? null : (string) $current;
|
||||
$this->originalArgv = $_SERVER['argv'] ?? [];
|
||||
|
||||
$this->clearFlag();
|
||||
$_SERVER['argv'] = ['artisan', 'inspire'];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->originalFlag === null) {
|
||||
$this->clearFlag();
|
||||
} else {
|
||||
$_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalFlag;
|
||||
$_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalFlag;
|
||||
putenv('G7_UPDATE_IN_PROGRESS='.$this->originalFlag);
|
||||
}
|
||||
|
||||
$_SERVER['argv'] = $this->originalArgv;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 플래그도 업데이트 argv 도 없으면 트리 밖이다.
|
||||
*
|
||||
* @effects CoreUpdateContext_isInProgress_detects_env_flag_or_update_argv
|
||||
*/
|
||||
#[Test]
|
||||
public function 플래그와_argv_가_모두_없으면_트리_밖이다(): void
|
||||
{
|
||||
$this->assertFalse(CoreUpdateContext::isInProgress());
|
||||
$this->assertFalse(CoreUpdateContext::hasEnvFlag());
|
||||
}
|
||||
|
||||
/**
|
||||
* `$_ENV` 채널만으로도 감지한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function env_배열_채널의_플래그를_감지한다(): void
|
||||
{
|
||||
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||
|
||||
$this->assertTrue(CoreUpdateContext::isInProgress());
|
||||
$this->assertTrue(CoreUpdateContext::hasEnvFlag());
|
||||
}
|
||||
|
||||
/**
|
||||
* `$_SERVER` 채널만으로도 감지한다 (`variables_order` 에 `E` 가 없으면 `$_ENV` 가 빈다).
|
||||
*/
|
||||
#[Test]
|
||||
public function server_배열_채널의_플래그를_감지한다(): void
|
||||
{
|
||||
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||
|
||||
$this->assertTrue(CoreUpdateContext::isInProgress());
|
||||
$this->assertTrue(CoreUpdateContext::hasEnvFlag());
|
||||
}
|
||||
|
||||
/**
|
||||
* putenv 채널만으로도 감지한다 (proc_open 이 자식에 넘긴 프로세스 환경 테이블).
|
||||
*/
|
||||
#[Test]
|
||||
public function putenv_채널의_플래그를_감지한다(): void
|
||||
{
|
||||
putenv('G7_UPDATE_IN_PROGRESS=1');
|
||||
|
||||
$this->assertTrue(CoreUpdateContext::isInProgress());
|
||||
$this->assertTrue(CoreUpdateContext::hasEnvFlag());
|
||||
}
|
||||
|
||||
/**
|
||||
* '1' 이 아닌 값은 플래그가 아니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 플래그_값이_1_이_아니면_트리_밖이다(): void
|
||||
{
|
||||
$_ENV['G7_UPDATE_IN_PROGRESS'] = '0';
|
||||
|
||||
$this->assertFalse(CoreUpdateContext::isInProgress());
|
||||
$this->assertFalse(CoreUpdateContext::hasEnvFlag());
|
||||
}
|
||||
|
||||
/**
|
||||
* 플래그가 전파되지 않은 극단 상황 대비 — argv 로도 판정한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 업데이트_커맨드_argv_로도_트리_안으로_판정한다(): void
|
||||
{
|
||||
foreach (['core:update', 'core:execute-upgrade-steps'] as $command) {
|
||||
$_SERVER['argv'] = ['artisan', $command, '--force'];
|
||||
|
||||
$this->assertTrue(CoreUpdateContext::isInProgress(), "argv {$command} 는 업데이트 트리로 판정한다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `hasEnvFlag()` 는 argv 보조 판정을 섞지 않는다 — 섞으면 단독 실행이 spawn 자식으로
|
||||
* 오판되어 사전·사후 단계를 통째로 건너뛴다.
|
||||
*/
|
||||
#[Test]
|
||||
public function has_env_flag_는_argv_만으로는_참이_되지_않는다(): void
|
||||
{
|
||||
$_SERVER['argv'] = ['artisan', 'core:execute-upgrade-steps', '--force'];
|
||||
|
||||
$this->assertTrue(CoreUpdateContext::isInProgress(), 'isInProgress 는 argv 로도 참');
|
||||
$this->assertFalse(CoreUpdateContext::hasEnvFlag(), 'hasEnvFlag 는 env 플래그만 본다');
|
||||
}
|
||||
|
||||
/**
|
||||
* `bootstrap/app.php` 자가 치유 블록의 판정 조건이 이 클래스와 동형이다.
|
||||
*
|
||||
* 그 블록은 부팅 전이라 `App\` 클래스를 참조할 수 없어 같은 판정을 순수 PHP 로 **복제**한다.
|
||||
* 복제본은 어긋나도 예외를 내지 않는다 — 옛 부모(7.0.9·7.0.10) 아래에서 도는 신버전 자식의
|
||||
* 유일한 방어가 조용히 사라지고, 증상은 그 조합에서만 나타나므로 개발 저장소에서는 영영
|
||||
* 드러나지 않는다. 그래서 두 조건이 같은지를 여기서 잠근다 — 종전에는 양쪽 주석의 상호
|
||||
* 참조가 유일한 방어였다.
|
||||
*
|
||||
* 대조 항목은 판정을 이루는 네 축 전부다: 환경변수 이름 · 읽는 채널 3종 · 참으로 받는 값
|
||||
* 3종 · argv 로 인정하는 커맨드 목록(리플렉션으로 이 클래스에서 파생 — 손으로 적으면
|
||||
* 커맨드가 하나 늘어도 통과한다).
|
||||
*/
|
||||
#[Test]
|
||||
public function bootstrap_자가치유_조건이_이_판정기와_동형이다(): void
|
||||
{
|
||||
$bootstrap = (string) file_get_contents(base_path('bootstrap/app.php'));
|
||||
|
||||
$start = strpos($bootstrap, 'Core Update: Stale Package Manifest Self-Heal');
|
||||
$this->assertNotFalse($start, '자가 치유 블록의 주석 마커를 찾지 못했다 — 마커가 바뀌었다면 이 검사부터 고친다');
|
||||
|
||||
$end = strpos($bootstrap, 'return $app;', $start);
|
||||
$this->assertNotFalse($end, '자가 치유 블록의 끝(return $app;)을 찾지 못했다');
|
||||
|
||||
$block = substr($bootstrap, $start, $end - $start);
|
||||
|
||||
// ① 환경변수 이름 · ② 읽는 채널 3종 — CoreUpdateContext::hasEnvFlag() 와 같아야 한다.
|
||||
foreach ([
|
||||
"\$_ENV['G7_UPDATE_IN_PROGRESS']",
|
||||
"\$_SERVER['G7_UPDATE_IN_PROGRESS']",
|
||||
"getenv('G7_UPDATE_IN_PROGRESS')",
|
||||
] as $channel) {
|
||||
$this->assertStringContainsString(
|
||||
$channel,
|
||||
$block,
|
||||
"자가 치유 블록이 판정 채널 {$channel} 을 읽지 않는다 — 그 채널만 살아 있는 호스팅에서 방어가 빠진다"
|
||||
);
|
||||
}
|
||||
|
||||
// ③ 참으로 받는 값 3종.
|
||||
foreach (["=== '1'", '=== 1', '=== true'] as $accepted) {
|
||||
$this->assertStringContainsString(
|
||||
$accepted,
|
||||
$block,
|
||||
"자가 치유 블록이 플래그 값 비교 `{$accepted}` 를 갖지 않는다"
|
||||
);
|
||||
}
|
||||
|
||||
// ④ argv 로 인정하는 커맨드 — 목록은 이 클래스에서 파생한다.
|
||||
$reflection = new \ReflectionClass(CoreUpdateContext::class);
|
||||
$commands = $reflection->getConstant('UPDATE_COMMANDS');
|
||||
|
||||
$this->assertIsArray($commands);
|
||||
$this->assertNotEmpty($commands, '커맨드 목록이 비면 이 대조는 공허하게 통과한다');
|
||||
|
||||
foreach ($commands as $command) {
|
||||
$this->assertStringContainsString(
|
||||
"'{$command}'",
|
||||
$block,
|
||||
"자가 치유 블록의 argv 판정에 `{$command}` 가 없다 — 그 커맨드로 도는 자식은 방어를 받지 못한다"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세 채널($_ENV/$_SERVER/putenv)에서 플래그를 제거합니다.
|
||||
*/
|
||||
private function clearFlag(): void
|
||||
{
|
||||
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
|
||||
putenv('G7_UPDATE_IN_PROGRESS');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?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 재지정이 경로를 정한다');
|
||||
|
||||
PackageManifestCacheHelper::clear();
|
||||
|
||||
$this->assertFileDoesNotExist($this->packagesPath);
|
||||
$this->assertFileDoesNotExist($this->servicesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 없어도 예외를 던지지 않는다 (부팅 직전에 불리므로 실패가 부팅을 막으면 안 된다).
|
||||
*/
|
||||
#[Test]
|
||||
public function clear_는_파일이_없으면_아무_일도_하지_않는다(): void
|
||||
{
|
||||
$this->assertFileDoesNotExist($this->packagesPath, '전제: 파일이 없다');
|
||||
|
||||
PackageManifestCacheHelper::clear();
|
||||
|
||||
$this->assertFileDoesNotExist($this->packagesPath);
|
||||
$this->assertFileDoesNotExist($this->servicesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user