diff --git a/tests/Concerns/IsolatesInstallerBasePath.php b/tests/Concerns/IsolatesInstallerBasePath.php new file mode 100644 index 00000000..2b797efb --- /dev/null +++ b/tests/Concerns/IsolatesInstallerBasePath.php @@ -0,0 +1,119 @@ +isolateInstallerBasePath(['.env.example']); + * + * // 프로젝트 실제 파일을 읽어야 할 때만 realBasePath() 사용 + * require_once $this->realBasePath('upgrades/data/7.0.4/migrations/01_Foo.php'); + * } + * + * protected function tearDown(): void + * { + * $this->releaseInstallerBasePath(); + * parent::tearDown(); + * } + * ``` + * + * @see CoreUpdateServiceTest::withIsolatedBasePath 콜백 단위 격리 변형 + */ +trait IsolatesInstallerBasePath +{ + /** 격리 이전의 실제 앱 루트 */ + private ?string $realProjectBasePath = null; + + /** 이번 테스트가 사용하는 임시 앱 루트 */ + private ?string $isolatedBasePath = null; + + /** + * 앱 루트를 임시 디렉토리로 전환합니다. + * + * 인스톨러 코드가 흔히 기대하는 `storage/`, `storage/app/` 을 미리 만들어 둔다. + * + * @param array $copyFromProject 실제 프로젝트에서 복사해올 파일 (루트 기준 상대경로) + * @return string 생성된 임시 앱 루트 절대경로 + */ + protected function isolateInstallerBasePath(array $copyFromProject = []): string + { + $this->realProjectBasePath = $this->app->basePath(); + $this->isolatedBasePath = sys_get_temp_dir() + .DIRECTORY_SEPARATOR.'g7-installer-isolated-'.bin2hex(random_bytes(6)); + + File::ensureDirectoryExists($this->isolatedBasePath.'/storage/app'); + + foreach ($copyFromProject as $relative) { + $source = $this->realProjectBasePath.DIRECTORY_SEPARATOR.ltrim($relative, '/\\'); + if (is_file($source)) { + $destination = $this->isolatedBasePath.DIRECTORY_SEPARATOR.ltrim($relative, '/\\'); + File::ensureDirectoryExists(dirname($destination)); + File::copy($source, $destination); + } + } + + $this->app->setBasePath($this->isolatedBasePath); + + return $this->isolatedBasePath; + } + + /** + * 앱 루트를 원래대로 되돌리고 임시 디렉토리를 제거합니다. + * + * tearDown 에서 호출한다. 호출되지 않아도 실제 프로젝트 파일은 애초에 건드리지 않았으므로 + * 남는 것은 임시 디렉토리뿐이다 — 이것이 복원 의존 방식과의 결정적 차이다. + */ + protected function releaseInstallerBasePath(): void + { + if ($this->realProjectBasePath !== null) { + $this->app->setBasePath($this->realProjectBasePath); + } + + if ($this->isolatedBasePath !== null && is_dir($this->isolatedBasePath)) { + File::deleteDirectory($this->isolatedBasePath); + } + + $this->realProjectBasePath = null; + $this->isolatedBasePath = null; + } + + /** + * 실제 프로젝트 루트 기준 경로를 반환합니다 (격리와 무관). + * + * 업그레이드 마이그레이션 파일처럼 **실제 소스**를 읽어야 할 때만 사용한다. + * + * @param string $relative 프로젝트 루트 기준 상대경로 + * @return string 절대경로 + */ + protected function realBasePath(string $relative = ''): string + { + $root = $this->realProjectBasePath ?? $this->app->basePath(); + + return $relative === '' ? $root : $root.DIRECTORY_SEPARATOR.ltrim($relative, '/\\'); + } +} diff --git a/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php b/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php index a8d70e5d..b78138df 100644 --- a/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php +++ b/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Upgrade; use App\Extension\UpgradeContext; use App\Upgrades\Data\V7_0_0_beta_7\Migrations\FinalizeOrphanedInstallerRuntime; use Illuminate\Support\Facades\File; +use Tests\Concerns\IsolatesInstallerBasePath; use Tests\TestCase; /** @@ -23,6 +24,8 @@ use Tests\TestCase; */ class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase { + use IsolatesInstallerBasePath; + private string $runtimePath; private string $envPath; @@ -31,16 +34,6 @@ class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase private string $stateJsonPath; - private ?string $originalEnvContent = null; - - private bool $originalEnvExisted = false; - - private bool $originalStateExisted = false; - - private ?string $originalStateContent = null; - - private bool $originalRuntimeDirExisted = false; - /** * @var array axis 12 가 putenv / $_ENV / $_SERVER 를 * 덮어쓰므로 후속 테스트 / 다음 테스트 파일의 DB 자격증명이 오염되지 @@ -58,23 +51,17 @@ class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase { parent::setUp(); + // 앱 루트를 임시 디렉토리로 돌린다. 본 테스트는 `.env`·runtime.php·state.json 을 + // 삭제/재생성/머지하므로, 실제 프로젝트 루트에서 돌면 개발자의 `.env` 를 파괴한다. + // tearDown 복원에 기대지 않고 애초에 실제 파일을 건드리지 않는다. + // axis 5 가 `.env.example` 기반 신규 생성을 검증하므로 그 파일만 복사해 온다. + $this->isolateInstallerBasePath(['.env.example']); + $this->runtimePath = base_path('storage/installer/runtime.php'); $this->envPath = base_path('.env'); $this->envExamplePath = base_path('.env.example'); $this->stateJsonPath = base_path('storage/installer-state.json'); - // 실제 운영 .env 와 state.json 보존 - if (is_file($this->envPath)) { - $this->originalEnvExisted = true; - $this->originalEnvContent = file_get_contents($this->envPath); - } - if (is_file($this->stateJsonPath)) { - $this->originalStateExisted = true; - $this->originalStateContent = file_get_contents($this->stateJsonPath); - } - - $this->originalRuntimeDirExisted = is_dir(dirname($this->runtimePath)); - // process ENV 원본 스냅샷 — axis 12 가 putenv() 로 자격증명을 갱신하므로 // tearDown 에서 정확히 원본 상태로 복원 (다음 테스트 / 파일 오염 차단) foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) { @@ -86,8 +73,9 @@ class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase // 이미 정의되어 있을 수 있다. 본 테스트는 그 상수를 신뢰하지 않고 명시 시나리오마다 // 자체 시그널을 사용하므로 추가 정의 불필요. - // DataMigration 은 autoload 대상이 아님 — 동적 require - require_once base_path('upgrades/data/7.0.0-beta.7/migrations/01_FinalizeOrphanedInstallerRuntime.php'); + // DataMigration 은 autoload 대상이 아님 — 동적 require. + // 임시 루트에는 upgrades/ 가 없으므로 **실제** 프로젝트 경로에서 읽는다. + require_once $this->realBasePath('upgrades/data/7.0.0-beta.7/migrations/01_FinalizeOrphanedInstallerRuntime.php'); $this->cleanupArtifacts(); } @@ -96,25 +84,6 @@ class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase { $this->cleanupArtifacts(); - // .env 복원 - if ($this->originalEnvExisted && $this->originalEnvContent !== null) { - file_put_contents($this->envPath, $this->originalEnvContent); - } elseif (! $this->originalEnvExisted && is_file($this->envPath)) { - @unlink($this->envPath); - } - - // state.json 복원 - if ($this->originalStateExisted && $this->originalStateContent !== null) { - file_put_contents($this->stateJsonPath, $this->originalStateContent); - } elseif (! $this->originalStateExisted && is_file($this->stateJsonPath)) { - @unlink($this->stateJsonPath); - } - - // 본 테스트가 생성한 runtime 디렉토리 정리 (원래 없었을 때만) - if (! $this->originalRuntimeDirExisted && is_dir(dirname($this->runtimePath))) { - @rmdir(dirname($this->runtimePath)); - } - // process ENV 복원 — axis 12 가 putenv() 로 자격증명을 갱신했어도 // 다음 테스트 / 파일이 정확히 setUp 직전 상태에서 시작하도록 보장. foreach ($this->originalEnvSnapshot as $key => $value) { @@ -128,6 +97,9 @@ class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase } } + // 임시 앱 루트 해제 및 제거 (실제 프로젝트 파일은 처음부터 건드리지 않았다) + $this->releaseInstallerBasePath(); + parent::tearDown(); } diff --git a/tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php b/tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php index 21acdf86..6118269f 100644 --- a/tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php +++ b/tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php @@ -113,6 +113,12 @@ PHP; '--to=0.0.1', '--force', '--env=testing', + // 본 테스트가 보는 것은 "spawn 된 자식이 방금 쓴 클래스를 로드하는가" 뿐이다. + // 이 옵션이 없으면 자식이 `.env` 의 APP_VERSION 을 0.0.1 로 덮어쓴다 — + // `--env=testing` 은 읽는 파일만 바꿀 뿐 쓰기 대상은 언제나 `base_path('.env')` + // 이므로 개발자의 실제 `.env` 가 훼손된다. 자식 프로세스라 부모의 base path + // 격리도, 테스트 부트스트랩의 복원 안전망도 닿지 않는다. + '--skip-version-env', ])).' 2>&1'; $descriptors = [ diff --git a/tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php b/tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php index b22b6bd6..f1b46330 100644 --- a/tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php +++ b/tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Upgrade; use App\Extension\UpgradeContext; use App\Upgrades\Data\V7_0_4\Migrations\RedactResidualInstallerStateSecrets; +use Tests\Concerns\IsolatesInstallerBasePath; use Tests\TestCase; /** @@ -20,49 +21,31 @@ use Tests\TestCase; */ class RedactResidualInstallerStateSecretsTest extends TestCase { + use IsolatesInstallerBasePath; + private string $statePath; private string $envPath; private string $installedFlagPath; - private bool $originalEnvExisted = false; - - private ?string $originalEnvContent = null; - - private bool $originalStateExisted = false; - - private ?string $originalStateContent = null; - - private bool $originalFlagExisted = false; - - private ?string $originalFlagContent = null; - protected function setUp(): void { parent::setUp(); + // DataMigration 은 autoload 대상이 아님 — 동적 require. + // 격리 전에 **실제** 경로에서 읽는다 (임시 루트에는 upgrades/ 가 없다). + require_once base_path('upgrades/data/7.0.4/migrations/01_RedactResidualInstallerStateSecrets.php'); + + // 앱 루트를 임시 디렉토리로 돌린다. 본 테스트는 `.env`·state.json·설치 플래그를 + // 삭제/변조하므로, 실제 프로젝트 루트에서 돌면 개발자의 `.env` 를 파괴한다. + // tearDown 복원에 기대지 않고 애초에 실제 파일을 건드리지 않는다. + $this->isolateInstallerBasePath(); + $this->statePath = base_path('storage/installer-state.json'); $this->envPath = base_path('.env'); $this->installedFlagPath = base_path('storage/app/g7_installed'); - // 실 운영 아티팩트 백업 — 본 테스트가 삭제/변조하므로 tearDown 에서 정확히 복원 - if (is_file($this->envPath)) { - $this->originalEnvExisted = true; - $this->originalEnvContent = file_get_contents($this->envPath); - } - if (is_file($this->statePath)) { - $this->originalStateExisted = true; - $this->originalStateContent = file_get_contents($this->statePath); - } - if (is_file($this->installedFlagPath)) { - $this->originalFlagExisted = true; - $this->originalFlagContent = file_get_contents($this->installedFlagPath); - } - - // DataMigration 은 autoload 대상이 아님 — 동적 require - require_once base_path('upgrades/data/7.0.4/migrations/01_RedactResidualInstallerStateSecrets.php'); - $this->clearArtifacts(); } @@ -70,15 +53,8 @@ class RedactResidualInstallerStateSecretsTest extends TestCase { $this->clearArtifacts(); - if ($this->originalEnvExisted && $this->originalEnvContent !== null) { - file_put_contents($this->envPath, $this->originalEnvContent); - } - if ($this->originalStateExisted && $this->originalStateContent !== null) { - file_put_contents($this->statePath, $this->originalStateContent); - } - if ($this->originalFlagExisted && $this->originalFlagContent !== null) { - file_put_contents($this->installedFlagPath, $this->originalFlagContent); - } + // 임시 앱 루트 해제 및 제거 (실제 프로젝트 파일은 처음부터 건드리지 않았다) + $this->releaseInstallerBasePath(); parent::tearDown(); } diff --git a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php index 2a615db3..d6df52bb 100644 --- a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php +++ b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Providers; use App\Providers\InstallerRuntimeServiceProvider; use Illuminate\Support\Facades\Config; +use Tests\Concerns\IsolatesInstallerBasePath; use Tests\TestCase; /** @@ -16,16 +17,14 @@ use Tests\TestCase; */ class InstallerRuntimeServiceProviderTest extends TestCase { + use IsolatesInstallerBasePath; + private string $runtimePath; private ?string $originalEnv = null; private string $envPath; - private ?string $originalEnvContent = null; - - private bool $originalEnvExisted = false; - /** @var array */ private array $originalEnvSnapshot = []; @@ -39,20 +38,14 @@ class InstallerRuntimeServiceProviderTest extends TestCase { parent::setUp(); + // 앱 루트를 임시 디렉토리로 돌린다. 본 테스트는 `.env` 와 runtime.php 를 직접 + // 쓰고 지우므로, 실제 프로젝트 루트에서 돌면 개발자의 `.env` 를 파괴한다. + // tearDown 복원에 기대지 않고 애초에 실제 파일을 건드리지 않는다. + $this->isolateInstallerBasePath(); + $this->runtimePath = base_path('storage/installer/runtime.php'); $this->envPath = base_path('.env'); - // 테스트 시작 시 runtime.php 정리 (이전 테스트의 잔재 제거) - if (is_file($this->runtimePath)) { - @unlink($this->runtimePath); - } - - // .env 원본 보존 - if (is_file($this->envPath)) { - $this->originalEnvExisted = true; - $this->originalEnvContent = file_get_contents($this->envPath); - } - // process ENV 스냅샷 (stale ENV 보정 테스트가 갱신하므로 다음 테스트 격리) foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) { $value = getenv($key); @@ -77,13 +70,6 @@ class InstallerRuntimeServiceProviderTest extends TestCase $this->originalEnv = null; } - // .env 복원 - if ($this->originalEnvExisted && $this->originalEnvContent !== null) { - file_put_contents($this->envPath, $this->originalEnvContent); - } elseif (! $this->originalEnvExisted && is_file($this->envPath)) { - @unlink($this->envPath); - } - // process ENV 복원 foreach ($this->originalEnvSnapshot as $key => $value) { if ($value === null) { @@ -96,6 +82,9 @@ class InstallerRuntimeServiceProviderTest extends TestCase } } + // 임시 앱 루트 해제 및 제거 (실제 프로젝트 파일은 처음부터 건드리지 않았다) + $this->releaseInstallerBasePath(); + parent::tearDown(); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index cbe9c08f..c6f44f01 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -110,6 +110,25 @@ if ($realEnvSnapshot !== false && $realEnvSnapshot !== null) { }); } +/* +|-------------------------------------------------------------------------- +| 임시 upgrade 스텝 파일 잔재 정리 +|-------------------------------------------------------------------------- +| +| 업그레이드 커맨드 테스트는 실제 `upgrades/` 에 임시 스텝 파일을 쓴다 — 그 디렉토리를 +| 스캔해야 스텝 발견 로직이 동작하기 때문이다. 각 테스트가 tearDown 에서 지우지만, +| 스위트가 fatal 로 중단되면 개발 체크아웃에 정체불명의 upgrade 스텝이 남는다. +| +| 파일명에 `_test_` 가 들어가는 것은 이 임시 파일들뿐이므로(실제 스텝은 `Upgrade_7_0_4` +| 처럼 버전만 갖는다) 그 패턴만 셧다운 시점에 쓸어낸다. +| +*/ +register_shutdown_function(static function (): void { + foreach (glob(__DIR__.'/../upgrades/Upgrade_*_test_*.php') ?: [] as $leftover) { + @unlink($leftover); + } +}); + /* |-------------------------------------------------------------------------- | Config 캐시 삭제 (테스트 환경 보장)