Files
Gnuboard7/tests/Feature/Console/CoreUpdateCommandHandoffTest.php
2026-05-12 17:58:34 +09:00

365 lines
13 KiB
PHP

<?php
namespace Tests\Feature\Console;
use App\Console\Commands\Core\CoreUpdateCommand;
use App\Exceptions\UpgradeHandoffException;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* CoreUpdateCommand 의 핸드오프 통합 계약 테스트 (spawn 파싱 경로)
*
* `handle()` 전체 체인(11 Step) 을 통과시키는 통합 테스트는 GitHub 연동·migration·
* vendor 설치 등 사이드이펙트가 크고 mock 면적이 방대하다. 따라서 본 테스트는
* handle() 의 **catch (UpgradeHandoffException) 분기로 들어가는 입구**에 해당하는
* `spawnUpgradeStepsProcess` 의 파싱 계약을 검증한다. 이 입구가 보장되면:
*
* - spawn 자식의 [HANDOFF] + exit 75 → 부모가 UpgradeHandoffException 재구성 → throw
* - 재구성된 예외는 handle() 의 try 전체를 덮는 catch 블록이 포착하여 cleanup 분기로 진입
*
* handle() catch 블록 내부 cleanup 시퀀스 (updateVersionInEnv(toVersion),
* clearAllCaches, restoreOwnership, cleanupPending, disableMaintenanceMode,
* 사용자 안내) 는 본 테스트 범위에서 제외되며, 운영자 Linux 서버 수동 검증으로 보완한다.
*
* 본 테스트는 실제 proc_open 으로 자식 PHP 프로세스를 띄우므로 proc_open 미지원
* 환경에서는 skip.
*/
class CoreUpdateCommandHandoffTest extends TestCase
{
private string $handoffStepPath;
private string $failingStepPath;
protected function setUp(): void
{
parent::setUp();
$this->handoffStepPath = base_path('upgrades/Upgrade_0_0_1_test_integration_handoff.php');
$this->failingStepPath = base_path('upgrades/Upgrade_0_0_1_test_integration_fail.php');
}
protected function tearDown(): void
{
foreach ([$this->handoffStepPath, $this->failingStepPath] as $p) {
if (File::exists($p)) {
File::delete($p);
}
}
parent::tearDown();
}
/**
* 자식 프로세스가 UpgradeHandoffException 을 던지면 부모의
* spawnUpgradeStepsProcess 가 [HANDOFF] stdout + exit 75 를 파싱해
* UpgradeHandoffException 을 재구성하여 상위로 throw 한다.
*/
public function test_spawn_rethrows_handoff_exception_from_child(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
File::put($this->handoffStepPath, <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_integration_handoff implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
throw new UpgradeHandoffException(
afterVersion: '0.0.0',
reason: '자식 프로세스 핸드오프',
resumeCommand: 'php artisan custom:resume',
);
}
}
PHP);
$command = $this->makeCommandWithDummyIo();
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
$logs = [];
$logCollector = function (string $message) use (&$logs) {
$logs[] = $message;
};
try {
$method->invoke($command, '0.0.0', '0.0.1', true, $logCollector);
$this->fail('UpgradeHandoffException 이 상위로 전파되어야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertSame('0.0.0', $e->afterVersion);
$this->assertSame('자식 프로세스 핸드오프', $e->reason);
$this->assertSame('php artisan custom:resume', $e->resumeCommand);
}
// 부모 로그에 핸드오프 수신 흔적이 있어야 한다
$handoffLog = implode("\n", $logs);
$this->assertStringContainsString('핸드오프', $handoffLog);
}
/**
* 자식 프로세스가 정상 완료(exit 0)되면 spawn 은 true 를 반환한다.
* Handoff 파싱 로직이 정상 경로를 손상시키지 않는지 회귀 가드.
*
* 본 테스트는 실 step 1건이 실행되는 시나리오 — `spawn_failure_mode` 의 silent skip 가드
* (step 0건 차단) 을 통과하기 위해 동일 버전 + --force 로 무해한 step 1건만 실행시킨다.
*/
public function test_spawn_returns_true_on_normal_exit(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
File::put($this->handoffStepPath, <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_integration_handoff implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
// no-op — step 1건 실행되도록만 보장
}
}
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->assertTrue($result, '정상 종료 시 true 반환해야 한다');
}
/**
* 자식 프로세스가 일반 실패(exit != 0, != 75)면 spawn 은 false 를 반환하여
* 상위 호출자가 in-process fallback 경로로 전환할 수 있게 한다.
*
* 본 회귀 가드는 `spawn_failure_mode=fallback` 의 호환 모드 동작을 검증한다.
* 기본 abort 모드 동작은 `CoreUpdateCommandSpawnFailureTest` 가 별도로 검증한다.
*/
public function test_spawn_returns_false_on_generic_failure(): 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_integration_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, '일반 실패 시 false 반환 (in-process fallback 유도)');
}
/**
* resumeCommand 자동 생성 계약:
* 자식이 resumeCommand=null 로 핸드오프 신호를 보낸 경우, 재구성된
* UpgradeHandoffException::resumeCommand 는 null 이어야 한다 (부모 CoreUpdateCommand
* 의 catch 블록이 sprintf 로 자동 생성하는 기본값 트리거 조건).
*/
public function test_spawn_preserves_null_resume_command_for_auto_generation(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
File::put($this->handoffStepPath, <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_integration_handoff implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
throw new UpgradeHandoffException(
afterVersion: '0.0.0',
reason: '자동 생성 필요',
);
}
}
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('UpgradeHandoffException 이 전파되어야 한다');
} catch (UpgradeHandoffException $e) {
$this->assertNull(
$e->resumeCommand,
'resumeCommand 는 null 로 유지되어야 CoreUpdateCommand 가 자동 생성한다'
);
}
}
/**
* isValidHandoffPayload — 빈 afterVersion 거부.
*
* 회귀 시나리오: 자식 stdout 의 [HANDOFF] 라인이 `afterVersion=""` 으로 도착하면
* Step 11 fromVersion 해석이 혼란스러워진다 (CoreUpdateCommand catch 분기의 sprintf
* 자동 생성에서도 비정상 명령어 출력). 검증 거부 후 정상 출력으로 fall through.
*/
public function test_isValidHandoffPayload_rejects_empty_after_version(): void
{
$command = app(CoreUpdateCommand::class);
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'isValidHandoffPayload');
$method->setAccessible(true);
$this->assertFalse($method->invoke($command, [
'afterVersion' => '',
'reason' => '정상 사유',
'resumeCommand' => null,
]));
$this->assertFalse($method->invoke($command, [
'afterVersion' => 'not-a-version',
'reason' => '정상 사유',
'resumeCommand' => null,
]));
$this->assertTrue($method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => '정상 사유',
'resumeCommand' => null,
]));
}
/**
* isValidHandoffPayload — reason 과도 길이 거부.
*
* 회귀 시나리오: 자식이 비정상적으로 긴 reason 문자열을 보내면 부모 로그 / 운영자
* 안내 출력이 폭증한다. 500자 이상은 비정상으로 간주하고 거부.
*/
public function test_isValidHandoffPayload_rejects_oversized_reason(): void
{
$command = app(CoreUpdateCommand::class);
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'isValidHandoffPayload');
$method->setAccessible(true);
$this->assertFalse($method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => str_repeat('a', 500),
'resumeCommand' => null,
]));
$this->assertTrue($method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => str_repeat('a', 499),
'resumeCommand' => null,
]));
}
/**
* isValidHandoffPayload — resumeCommand shell metacharacter / 과도 길이 거부.
*
* 회귀 시나리오: 자식이 손상되거나 악의적으로 조작된 resumeCommand 를 보내면
* 부모가 운영자 안내에 shell injection 문자열을 출력. 정상 명령(`php artisan ...`)
* 에 등장하지 않는 metacharacter 가 포함되면 거부.
*/
public function test_isValidHandoffPayload_rejects_shell_metacharacters_in_resume_command(): void
{
$command = app(CoreUpdateCommand::class);
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'isValidHandoffPayload');
$method->setAccessible(true);
foreach (['; rm -rf /', '`whoami`', '$(id)', 'php a && php b', 'php a | tee x', 'php a > x'] as $bad) {
$this->assertFalse(
$method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => '정상 사유',
'resumeCommand' => $bad,
]),
"shell metacharacter 가 포함된 resumeCommand 거부: {$bad}",
);
}
$this->assertFalse($method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => '정상 사유',
'resumeCommand' => str_repeat('a', 1000),
]));
$this->assertTrue($method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => '정상 사유',
'resumeCommand' => 'php artisan core:update --force --from=7.0.0-beta.4',
]));
$this->assertTrue($method->invoke($command, [
'afterVersion' => '7.0.0-beta.4',
'reason' => '정상 사유',
'resumeCommand' => null,
]));
}
/**
* CoreUpdateCommand 를 OutputStyle 주입 없이 리플렉션 호출 가능한 형태로 준비.
* spawnUpgradeStepsProcess 내부에서 $this->line / $this->info 를 호출하므로
* OutputStyle 이 필요. Artisan 의 기본 Buffered Output 로 대체.
*/
private function makeCommandWithDummyIo(): CoreUpdateCommand
{
$command = app(CoreUpdateCommand::class);
$input = new \Symfony\Component\Console\Input\ArrayInput([]);
$output = new \Symfony\Component\Console\Output\BufferedOutput;
$style = new \Illuminate\Console\OutputStyle($input, $output);
$reflection = new \ReflectionClass($command);
$property = $reflection->getProperty('output');
$property->setAccessible(true);
$property->setValue($command, $style);
// laravel-zero 등 컨테이너 의존 호출 대비 input 도 설정
if ($reflection->hasProperty('input')) {
$inputProp = $reflection->getProperty('input');
$inputProp->setAccessible(true);
$inputProp->setValue($command, $input);
}
return $command;
}
}