Files
Gnuboard7/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php
T
HeuJung d6e1f98a4f fix(core): 확장 오토로드 캐시 공집합 덮어쓰기로 인한 전역 500 + 소스맵 노출 차단 복원
오토로드 캐시(psr4)가 공집합으로 덮어써져 모든 모듈/플러그인 컨트롤러가
해석 불가능해지고 확장 화면 전체가 500 이 됐다. 이 매핑은 "DB 설치 목록 ×
디스크 활성 디렉토리" 교집합이라 DB 쪽만 일시적으로 비어도(테이블 부재·
설치 중간 실패·testing DB) 정상 매핑이 통째로 소실된다. 코어 라우트는 200
인데 확장 라우트만 전부 500 인 비대칭이 이 결함의 진단 단서다.

- 디스크에 확장 디렉토리가 남아 있는데 매핑이 0건이면 기록하지 않고 보존.
 정상적인 0건(신규 설치·마지막 확장 삭제)은 디스크도 함께 비어 통과한다
- generateAutoloadFile 에 testing 실 캐시 경로 쓰기 차단. updateComposerAutoload
 에만 있던 가드를 생성기 자체로 옮겨 어느 경로로 들어와도 막히게 했다
- extension:update-autoload 가 훅 캐시 재생성 전에 런타임 오토로드를 재등록.
 미재등록 시 깨진 매핑으로 훅 캐시가 modules/plugins 0건으로 생성되어
 확장 훅이 조용히 사라지고, 복구에 커맨드 2회 실행이 필요했다

소스맵 서빙은 에서 차단했으나 이번 브랜치에서 화이트리스트에 재추가되어
운영에서 원본 코드 전문(sourcesContent)이 노출될 수 있었다. 이 화이트리스트가
확장 에셋 서빙의 유일한 방어선이다. dev 빌드의 sourceMappingURL 404 를 피하면서
운영을 막도록 local 환경에서만 허용하도록 분기하고, 산출물을 production 으로
재빌드해 docroot 에 남아 있던 소스맵을 제거했다. --production 빌드는 소스맵을
생성하지 않을 뿐 기존 파일을 지우지 않는다.

CoreUpdateCommandSpawnFailureTest 는 in-process fallback 이 실제 업그레이드
스텝을 돌려 코어 역할·권한·메뉴를 커밋하는데 RefreshDatabase 가 없어, 이후
모든 테스트 클래스가 중복 키로 깨졌다(단독 실행 시 미재현).
2026-07-23 13:04:59 +09:00

421 lines
17 KiB
PHP

<?php
namespace Tests\Feature\Console;
use App\Console\Commands\Core\CoreUpdateCommand;
use App\Exceptions\UpgradeHandoffException;
use App\Services\CoreUpdateService;
use Illuminate\Console\OutputStyle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Tests\TestCase;
/**
* `spawn_failure_mode` + `[STEPS_EXECUTED]` 신호 + stale 메모리 가드 회귀 테스트 (§2/§2.1/§6).
*
* 회귀 시나리오 (gnuboard/g7#28):
* - spawn 자식 실패 → in-process fallback 진입 → 부모 메모리 stale → upgrade step
* 안에서 신규 메서드 호출 fatal (예: `Call to undefined method ensureWritableDirectories()`)
*
* 본 테스트는 `spawnUpgradeStepsProcess` 의 mode 분기 + `failSpawnWithMode` + 자식의
* `[STEPS_EXECUTED]` 발행 + `runUpgradeSteps` 의 stale 메모리 가드를 단위 수준에서 검증한다.
* 실제 `core:update` 통합 흐름은 운영자의 Linux 서버 수동 검증 (계획서 §"통합 시나리오") 으로 보완.
*/
class CoreUpdateCommandSpawnFailureTest extends TestCase
{
// in-process fallback 은 실제 업그레이드 스텝을 실행하고, 그 스텝이 코어
// 역할·권한·메뉴를 동기화한다(`fresh config 기반 코어 데이터 재동기화`).
// 트랜잭션 격리가 없으면 이 시드 데이터가 testing DB 에 영구 커밋되어,
// 이후 모든 테스트 클래스의 `Role::create(identifier: 'admin')` 이
// UniqueConstraintViolation 으로 깨진다(단독 실행에서는 재현되지 않음).
use RefreshDatabase;
private string $failingStepPath;
private string $silentStepPath;
protected function setUp(): void
{
parent::setUp();
$this->failingStepPath = base_path('upgrades/Upgrade_0_0_1_test_spawn_failure_fail.php');
$this->silentStepPath = base_path('upgrades/Upgrade_0_0_1_test_spawn_failure_silent.php');
}
protected function tearDown(): void
{
foreach ([$this->failingStepPath, $this->silentStepPath] as $p) {
if (File::exists($p)) {
File::delete($p);
}
}
parent::tearDown();
}
#[Test]
public function fail_spawn_with_mode_abort_모드는_upgrade_handoff_exception_을_throw_한다(): void
{
config(['app.update.spawn_failure_mode' => 'abort']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'failSpawnWithMode');
$method->setAccessible(true);
try {
$method->invoke($command, '테스트 사유', fn () => null, '7.0.0-beta.3', '7.0.0-beta.5');
$this->fail('mode=abort 일 때 UpgradeHandoffException 이 throw 되어야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertSame('7.0.0-beta.3', $e->afterVersion);
$this->assertStringContainsString('테스트 사유', $e->reason);
$this->assertStringContainsString('fail-fast', $e->reason);
$this->assertNotNull($e->resumeCommand);
$this->assertStringContainsString('core:execute-upgrade-steps', $e->resumeCommand);
$this->assertStringContainsString('--from=7.0.0-beta.3', $e->resumeCommand);
$this->assertStringContainsString('--to=7.0.0-beta.5', $e->resumeCommand);
}
}
#[Test]
public function fail_spawn_with_mode_fallback_모드는_false_를_반환한다(): void
{
config(['app.update.spawn_failure_mode' => 'fallback']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'failSpawnWithMode');
$method->setAccessible(true);
$logs = [];
$result = $method->invoke(
$command,
'테스트 사유',
function (string $msg) use (&$logs): void {
$logs[] = $msg;
},
'7.0.0-beta.3',
'7.0.0-beta.5',
);
$this->assertFalse($result, 'fallback 모드는 false 반환 (in-process fallback 진입)');
$this->assertNotEmpty($logs, '로그 기록 필요');
$allLogs = implode("\n", $logs);
$this->assertStringContainsString('테스트 사유', $allLogs);
$this->assertStringContainsString('stale 메모리 위험', $allLogs);
}
#[Test]
public function spawn_자식_범위내_스텝_파일이_없어_0건이면_정상_통과한다(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
// upgrade 파일 없이 호출 → 자식은 [STEPS_EXECUTED] executed=0, discovered=0 발행 + exit=0
// "범위 내 스텝 파일 자체가 없음" 은 정상 상황 (예: 스텝 불필요 릴리즈).
// discovered=0 이면 fail-fast 를 발동하지 않고 정상 통과해야 한다 (케이스 B).
config(['app.update.spawn_failure_mode' => 'abort']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
$logs = [];
$log = function (string $msg) use (&$logs): void {
$logs[] = $msg;
};
// 실 스텝 파일이 존재하지 않는 버전 범위 (9.9.8 → 9.9.9)
$result = $method->invoke($command, '9.9.8', '9.9.9', true, $log);
$this->assertTrue($result, '범위 내 스텝 파일이 없어 0건이면 정상 통과(true)여야 한다');
$allLogs = implode("\n", $logs);
$this->assertStringContainsString('실행할 스텝 없음', $allLogs, '스텝 부재 정상 통과 로그');
}
#[Test]
public function spawn_자식_범위내_스텝이_있는데_0건_실행이면_abort_throw_한다(): void
{
// 범위 내에 스텝 파일이 실제로 존재(discovered>0)하는데 executed=0 인 경우 —
// 이전 버전 자식이 신규 스텝을 놓치고 silent skip 한 gnuboard/g7#28 케이스 (케이스 A).
// 이 경우에만 fail-fast 를 발동해야 한다.
//
// 재현: 스텝 파일은 존재하나 자식의 runUpgradeSteps 가 executed 를 0 으로 보고하도록,
// discovered 는 세되 onStep 은 호출하지 않는 상황을 자식 프로세스 안에서 만들 수 없으므로,
// 부모 가드 로직(handleSpawnExit)을 직접 호출해 discovered>0 && executed=0 분기를 검증한다.
config(['app.update.spawn_failure_mode' => 'abort']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit');
$method->setAccessible(true);
try {
// exitCode=0, handoffPayload=null, stepsExecuted=0, stepsDiscovered=5
$method->invoke($command, 0, null, 0, 5, '9.9.8', '9.9.9', fn () => null);
$this->fail('discovered>0 && executed=0 이면 mode=abort 에서 throw 되어야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertStringContainsString('step 0건 실행', $e->reason);
$this->assertStringContainsString('발견 5건', $e->reason);
$this->assertSame('9.9.8', $e->afterVersion);
}
}
#[Test]
public function handle_spawn_exit_executed_0_discovered_0_이면_from_lt_to_여도_정상_통과한다(): void
{
// 케이스 B — 범위 내 스텝 파일 부재. from<to 여도 실패 아님 (스텝 불필요 릴리즈).
// proc_open 불필요한 순수 판정 로직 단위 검증.
config(['app.update.spawn_failure_mode' => 'abort']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit');
$method->setAccessible(true);
$logs = [];
$log = function (string $msg) use (&$logs): void {
$logs[] = $msg;
};
// exitCode=0, handoffPayload=null, stepsExecuted=0, stepsDiscovered=0
$result = $method->invoke($command, 0, null, 0, 0, '7.0.0', '7.0.1', $log);
$this->assertTrue($result, 'discovered=0 이면 abort 모드에서도 정상 통과(true)');
$this->assertStringContainsString('실행할 스텝 없음', implode("\n", $logs));
}
#[Test]
public function handle_spawn_exit_구버전_자식_discovered_null_은_레거시_fail_fast_판정을_유지한다(): void
{
// discovered 필드를 모르는 구버전 자식 — executed=0 + discovered=null + from<to.
// 신버전 부모는 이 케이스를 기존(레거시) fail-fast 로 판정해야 한다 (안전 우선).
config(['app.update.spawn_failure_mode' => 'abort']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit');
$method->setAccessible(true);
try {
// stepsExecuted=0, stepsDiscovered=null (구버전 자식)
$method->invoke($command, 0, null, 0, null, '7.0.0', '7.0.1', fn () => null);
$this->fail('discovered=null + executed=0 + from<to 는 레거시 fail-fast 여야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertStringContainsString('step 0건 실행', $e->reason);
$this->assertStringNotContainsString('발견', $e->reason, 'discovered 미상이므로 발견 건수 표기 없음');
}
}
#[Test]
public function spawn_자식_범위내_스텝_파일이_없어_0건이면_fallback_모드도_정상_통과한다(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
config(['app.update.spawn_failure_mode' => 'fallback']);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
$result = $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null);
$this->assertTrue($result, '스텝 파일 부재(discovered=0)는 fallback 모드에서도 정상 통과(true)');
}
#[Test]
public function spawn_자식_정상_종료_시_step_s_execute_d_파싱_후_true_반환한다(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
config(['app.update.spawn_failure_mode' => 'abort']);
// 실제 step 1건이 실행되는 시나리오 — fromVersion == toVersion + --force 로
// 동일 버전 step 만 실행되게 함. but no test step exists → 0건. 회피하려면
// 실제 step 파일을 생성:
File::put($this->silentStepPath, <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_spawn_failure_silent implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
// no-op step — count 가 1 이 되도록만 보장
}
}
PHP);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
$logs = [];
$log = function (string $msg) use (&$logs): void {
$logs[] = $msg;
};
$result = $method->invoke($command, '0.0.0', '0.0.1', true, $log);
$this->assertTrue($result, '정상 step 실행 시 spawn 성공');
$allLogs = implode("\n", $logs);
$this->assertStringContainsString('실행된 step 수: 1', $allLogs, 'STEPS_EXECUTED 파싱 결과 로그');
$this->assertStringContainsString('steps=1', $allLogs, '최종 spawn 완료 로그에 step 수 포함');
}
#[Test]
public function spawn_자식_비정상_종료_시_abort_모드는_throw_한다(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
config(['app.update.spawn_failure_mode' => 'abort']);
File::put($this->failingStepPath, <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_spawn_failure_fail implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
throw new \RuntimeException('자식 비정상 종료 테스트');
}
}
PHP);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
try {
$method->invoke($command, '0.0.0', '0.0.1', true, fn () => null);
$this->fail('비정상 종료 시 mode=abort 에서 throw 되어야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertStringContainsString('spawn 비정상 종료', $e->reason);
$this->assertSame('0.0.0', $e->afterVersion);
}
}
#[Test]
public function spawn_자식_비정상_종료_시_fallback_모드는_false_반환한다(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
config(['app.update.spawn_failure_mode' => 'fallback']);
File::put($this->failingStepPath, <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_spawn_failure_fail implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
throw new \RuntimeException('자식 비정상 종료 테스트');
}
}
PHP);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
$result = $method->invoke($command, '0.0.0', '0.0.1', true, fn () => null);
$this->assertFalse($result, 'fallback 모드는 false 반환');
}
#[Test]
public function run_upgrade_steps_stale_메모리_감지_시_abort_throw_한다(): void
{
config(['app.version' => '7.0.0-beta.3']);
config(['app.update.spawn_failure_mode' => 'abort']);
$service = app(CoreUpdateService::class);
try {
$service->runUpgradeSteps('7.0.0-beta.3', '7.0.0-beta.5');
$this->fail('stale 메모리 감지 시 UpgradeHandoffException 이 throw 되어야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertSame('7.0.0-beta.3', $e->afterVersion);
$this->assertStringContainsString('stale', $e->reason);
$this->assertStringContainsString('memory=7.0.0-beta.3', $e->reason);
$this->assertStringContainsString('target=7.0.0-beta.5', $e->reason);
}
}
#[Test]
public function run_upgrade_steps_stale_메모리_감지_시_fallback_은_경고_후_진행한다(): void
{
config(['app.version' => '7.0.0-beta.3']);
config(['app.update.spawn_failure_mode' => 'fallback']);
$service = app(CoreUpdateService::class);
// step 파일 없으면 stale 가드 통과 후 silent return — 예외 미발생 확인
$service->runUpgradeSteps('7.0.0-beta.3', '7.0.0-beta.5');
// 예외 없이 도달하면 성공 — fallback 모드는 throw 하지 않고 step 실행으로 진입
$this->assertTrue(true);
}
#[Test]
public function run_upgrade_steps_memory_가_target_과_동일하면_가드_미발동(): void
{
config(['app.version' => '7.0.0-beta.5']);
config(['app.update.spawn_failure_mode' => 'abort']);
$service = app(CoreUpdateService::class);
// spawn 자식 시나리오: 메모리 = target → 가드 무관 → step 파일 없으면 silent return
$service->runUpgradeSteps('7.0.0-beta.3', '7.0.0-beta.5');
$this->assertTrue(true);
}
/**
* CoreUpdateCommand 를 OutputStyle 주입 없이 리플렉션 호출 가능한 형태로 준비.
*/
private function makeCommandWithDummyIo(): CoreUpdateCommand
{
$command = app(CoreUpdateCommand::class);
$input = new ArrayInput([]);
$output = new BufferedOutput;
$style = new OutputStyle($input, $output);
$reflection = new \ReflectionClass($command);
$property = $reflection->getProperty('output');
$property->setAccessible(true);
$property->setValue($command, $style);
if ($reflection->hasProperty('input')) {
$inputProp = $reflection->getProperty('input');
$inputProp->setAccessible(true);
$inputProp->setValue($command, $input);
}
return $command;
}
}