Files
Gnuboard7/tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php
T
HeuJung 7e569efe4c test(core): 개발 환경 파일을 건드리는 테스트 격리
테스트가 개발자의 `.env` 를 삭제한 사고 이후 부트스트랩에 복원 안전망을 넣었지만,
그것은 프로세스 종료 시점 복원이라 같은 실행 안에서 뒤 테스트가 훼손된 파일을 읽는 구간이
남아 있었다. 복원이 아니라 애초에 건드리지 않는 구조로 바꾼다.

앱 루트를 임시 디렉토리로 돌리면 `base_path` 를 쓰는 제품 코드는 한 줄도 바뀌지 않은 채
격리된 파일만 다룬다. 공용 트레이트로 묶어 세 클래스가 같은 방식을 쓰게 했다.

격리 자체를 증명하기 위해 안전망을 임시로 끄고 돌렸다. 안전망 없이도 `.env` 가 바이트
단위로 그대로였다 — "green 이니 됐다" 로는 안전망이 뒤에서 복원해 준 것과 구분되지 않는다.

이 과정에서 격리로는 막을 수 없는 유형을 하나 더 찾았다. 별도 프로세스를 띄워 업그레이드
커맨드를 실행하는 테스트가 자식 쪽에서 `.env` 의 버전 값을 덮어쓰고 있었다. 부모의 앱 루트
전환도, 테스트 부트스트랩의 안전망도 자식에는 닿지 않는다. 테스트 환경 지정은 읽는 파일만
바꿀 뿐 쓰기 대상은 언제나 프로젝트 루트의 `.env` 다. 그 테스트가 검증하는 것은 자식이 방금
쓴 클래스를 읽어들이는지 뿐이므로, 커맨드가 이미 제공하는 부수효과 차단 옵션을 붙였다.

같은 구조의 문제가 업그레이드 스텝 임시 파일에도 있었다 — 실제 `upgrades/` 에 쓰고 정리를
tearDown 에 의존하므로 중단되면 개발 체크아웃에 정체불명의 스텝이 남는다. 실제로 두 번
발견해 지웠고, 부트스트랩 셧다운 훅이 그 패턴을 쓸어내도록 했다.

검증: 세 클래스 개별 green, 안전망을 끈 통합 실행 28건 green + `.env` 불변,
자식 프로세스 테스트 green, 전 구간 `.env` 해시 불변, audit 위반 0.

알려진 잔여 문제: 인스톨러와 업그레이드 스위트를 한 프로세스로 합쳐 돌리면 모듈 클래스
중복 선언 fatal 로 중단된다. 각각 따로 돌리면 297 / 301 건 모두 통과하며, 이번 변경 이전에도
같은 증상이었다. 별도 조사가 필요해 이번 범위에서 다루지 않았다.
2026-07-23 11:39:59 +09:00

334 lines
13 KiB
PHP

<?php
namespace Tests\Feature\Upgrade;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_0_4\Migrations\RedactResidualInstallerStateSecrets;
use Tests\Concerns\IsolatesInstallerBasePath;
use Tests\TestCase;
/**
* 7.0.4 잔존 installer-state.json 비밀 정리 회귀 가드 (이슈 #465).
*
* axis 전수:
* 1. state.json 부재 → no-op
* 2. 완료 증거(.env INSTALLER_COMPLETED=true) + state 존재 → 파일 삭제
* 3. 완료 증거(g7_installed 플래그) + state 존재 → 파일 삭제
* 4. 완료 증거 있음 + 삭제 불가 → redact 폴백 (파일 보존, 비밀만 제거)
* 5. 완료 증거 없음 → 파일 보존 + redact 만 (설치 진행 중 파괴 금지)
* 6. JSON 파싱 실패 → 파일 원본 그대로 보존
* 7. 멱등 — 2회 호출해도 동일 결과
*/
class RedactResidualInstallerStateSecretsTest extends TestCase
{
use IsolatesInstallerBasePath;
private string $statePath;
private string $envPath;
private string $installedFlagPath;
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');
$this->clearArtifacts();
}
protected function tearDown(): void
{
$this->clearArtifacts();
// 임시 앱 루트 해제 및 제거 (실제 프로젝트 파일은 처음부터 건드리지 않았다)
$this->releaseInstallerBasePath();
parent::tearDown();
}
/**
* 각 axis 가 완료 증거를 명시 제어할 수 있도록 세 아티팩트를 모두 제거한다.
*/
private function clearArtifacts(): void
{
if (is_file($this->statePath)) {
// axis 4 가 파일을 0444 로 만들 수 있다 — 단언 실패로 조기 종료된 경우에도
// 정리가 가능하도록 권한을 먼저 되돌린다.
@chmod($this->statePath, 0644);
@unlink($this->statePath);
}
if (is_file($this->installedFlagPath)) {
@unlink($this->installedFlagPath);
}
if (is_file($this->envPath)) {
@unlink($this->envPath);
}
}
private function context(): UpgradeContext
{
return new UpgradeContext('7.0.3', '7.0.4', '7.0.4');
}
private function migration(): RedactResidualInstallerStateSecrets
{
return new RedactResidualInstallerStateSecrets;
}
/**
* 평문 비밀이 들어 있는 레거시 state.json (7.0.3 이하 인스톨러가 남긴 형태).
*
* @return array<string, mixed>
*/
private function legacyState(): array
{
return [
'installation_status' => 'completed',
'current_step' => 5,
'completed_tasks' => ['db_migrate', 'db_seed', 'complete_flag'],
'config' => [
'app_name' => 'G7 Site',
'db_write_host' => '127.0.0.1',
'db_write_database' => 'g7',
'db_write_username' => 'root',
'db_write_password' => 'db-plain-pw',
'db_read_password' => 'read-plain-pw',
'admin_email' => 'admin@example.com',
'admin_password' => 'admin-plain-pw',
'admin_password_confirm' => 'admin-plain-pw',
],
];
}
private function writeState(array $state): void
{
file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT));
}
private function markInstalledViaEnv(): void
{
file_put_contents($this->envPath, "APP_ENV=production\nINSTALLER_COMPLETED=true\n");
}
private function markInstalledViaFlag(): void
{
$dir = dirname($this->installedFlagPath);
if (! is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($this->installedFlagPath, date('Y-m-d H:i:s'));
}
/**
* @return array<string, mixed>
*/
private function readState(): array
{
return json_decode((string) file_get_contents($this->statePath), true);
}
private function assertNoPlaintextSecrets(array $config): void
{
foreach (['db_write_password', 'db_read_password', 'admin_password', 'admin_password_confirm'] as $key) {
$this->assertArrayNotHasKey($key, $config, "{$key} 가 state 에 남으면 안 됨");
}
}
// -----------------------------------------------------------------------
// axis 1: state.json 부재 → no-op
// -----------------------------------------------------------------------
public function test_no_op_when_state_file_absent(): void
{
$this->assertFileDoesNotExist($this->statePath);
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
}
// -----------------------------------------------------------------------
// axis 2: 완료 증거(.env) + state 존재 → 파일 삭제
// -----------------------------------------------------------------------
public function test_deletes_state_file_when_env_marks_installation_complete(): void
{
$this->writeState($this->legacyState());
$this->markInstalledViaEnv();
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath, '설치 완료 환경의 잔존 state.json 은 삭제되어야 함');
}
// -----------------------------------------------------------------------
// axis 3: 완료 증거(g7_installed) + state 존재 → 파일 삭제
// -----------------------------------------------------------------------
public function test_deletes_state_file_when_installed_flag_present(): void
{
$this->writeState($this->legacyState());
$this->markInstalledViaFlag();
// .env 는 없음 — g7_installed 단독 신호로도 완료 판정되어야 함
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
}
// -----------------------------------------------------------------------
// axis 4: 완료 증거 있음 + 삭제 불가 → 예외 없이 계속 진행 (업그레이드 중단 금지)
//
// 파일을 읽기 전용으로 만들어 unlink 를 실제로 실패시킨다. 이 상태에서는
// redact 재저장(file_put_contents) 도 함께 실패하므로 평문 제거는 불가능하다 —
// 파일시스템 레벨 제약이라 코드로 우회할 수 없다. 본 axis 가 고정하는 계약은
// "업그레이드가 중단되지 않고, 파일이 손상되지 않으며, 운영자가 수동 삭제할 수
// 있도록 원본이 보존된다" 이다 (경고 로그는 runInternal 이 기록).
// -----------------------------------------------------------------------
public function test_does_not_abort_upgrade_when_state_file_cannot_be_deleted(): void
{
$original = $this->legacyState();
$this->writeState($original);
$this->markInstalledViaEnv();
$before = file_get_contents($this->statePath);
// 이 환경에서 읽기 전용 파일의 삭제가 실제로 차단되는지 실측 (POSIX 는 삭제 권한이
// 부모 디렉토리 소유라 차단되지 않을 수 있다 — 그 경우 본 axis 는 재현 불가).
if (! $this->unlinkIsBlocked()) {
$this->markTestSkipped(
'이 플랫폼에서는 읽기 전용 파일의 unlink 가 차단되지 않아 삭제 실패 경로를 '
.'결정적으로 재현할 수 없음 (POSIX: 삭제 권한은 부모 디렉토리 소유)'
);
}
@chmod($this->statePath, 0444);
// 예외를 던지지 않아야 한다 (업그레이드 전체 중단 금지)
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath, '삭제 실패 시 파일은 보존되어야 함');
$this->assertSame($before, file_get_contents($this->statePath), '삭제 실패 시 파일이 손상되면 안 됨');
@chmod($this->statePath, 0644);
}
/**
* 현재 플랫폼에서 읽기 전용 파일의 unlink 가 차단되는지 실측한다.
*/
private function unlinkIsBlocked(): bool
{
$probe = base_path('storage/installer-state-unlink-probe.tmp');
file_put_contents($probe, 'x');
@chmod($probe, 0444);
$blocked = @unlink($probe) === false;
@chmod($probe, 0644);
if (is_file($probe)) {
@unlink($probe);
}
return $blocked;
}
// -----------------------------------------------------------------------
// axis 5: 완료 증거 없음 → 파일 보존 + redact 만
// -----------------------------------------------------------------------
public function test_redacts_but_preserves_file_when_installation_not_complete(): void
{
$state = $this->legacyState();
$state['installation_status'] = 'running'; // 설치 진행 중
$this->writeState($state);
// 완료 증거 없음 (.env / g7_installed 모두 부재)
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath, '진행 중인 설치의 state.json 을 삭제하면 안 됨');
$after = $this->readState();
$this->assertNoPlaintextSecrets($after['config']);
// 비-비밀 필드는 보존되어야 설치를 이어갈 수 있다
$this->assertSame('G7 Site', $after['config']['app_name']);
$this->assertSame('127.0.0.1', $after['config']['db_write_host']);
$this->assertSame('root', $after['config']['db_write_username']);
$this->assertSame('admin@example.com', $after['config']['admin_email']);
$this->assertSame(['db_migrate', 'db_seed', 'complete_flag'], $after['completed_tasks']);
$this->assertSame('running', $after['installation_status']);
}
// -----------------------------------------------------------------------
// axis 6: JSON 파싱 실패 → 파일 원본 보존 (파괴 금지)
// -----------------------------------------------------------------------
public function test_preserves_corrupted_state_file(): void
{
$corrupted = '{ this is not valid json';
file_put_contents($this->statePath, $corrupted);
$this->markInstalledViaEnv();
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath, '파싱 실패 파일을 삭제하면 안 됨');
$this->assertSame($corrupted, file_get_contents($this->statePath));
}
// -----------------------------------------------------------------------
// axis 7: 멱등 — 2회 호출해도 동일 결과
// -----------------------------------------------------------------------
public function test_is_idempotent_on_repeated_runs(): void
{
$state = $this->legacyState();
$state['installation_status'] = 'aborted';
$this->writeState($state);
$this->migration()->run($this->context());
$firstPass = file_get_contents($this->statePath);
$this->migration()->run($this->context());
$secondPass = file_get_contents($this->statePath);
$this->assertSame($firstPass, $secondPass, '재실행이 파일을 추가로 변경하면 안 됨');
$this->assertNoPlaintextSecrets($this->readState()['config']);
}
// -----------------------------------------------------------------------
// 삭제 경로 멱등 — 완료 환경에서 2회 호출
// -----------------------------------------------------------------------
public function test_delete_path_is_idempotent(): void
{
$this->writeState($this->legacyState());
$this->markInstalledViaEnv();
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
// 2회차 — 파일 부재 상태에서 예외 없이 no-op
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
}
// -----------------------------------------------------------------------
// config 섹션 없는 state → 예외 없이 통과
// -----------------------------------------------------------------------
public function test_handles_state_without_config_section(): void
{
$this->writeState(['installation_status' => 'running', 'completed_tasks' => []]);
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath);
$this->assertSame('running', $this->readState()['installation_status']);
}
}