Files
Gnuboard7/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php
HeuJung 0a6ff23132 fix(core-update): --prune 시 public/storage symlink 삭제로 업로드 파일 404 되는 문제
--prune 은 릴리즈 소스에 없는 public/storage 런타임 symlink 를 orphan 으로
삭제해 업로드 파일이 404 되었다. 심층 방어 2층으로 차단한다.

- 층1(예방): FilePermissionHelper 에 preserveLinkPaths 화이트리스트 추가.
 applyUpdate 가 public 타깃에 ['storage'] 전달 → 매칭 orphan 링크/junction 만
 삭제 제외 (정밀 보호)
- 층2(복구): StorageLinkHelper::ensurePublicStorageLink 신규 정적 헬퍼.
 CoreUpdateCommand 종료 시점(정상+핸드오프) 호출로 부재/손상 링크 멱등 재생성
 (Windows junction 폴백). beta.5 migration 05 는 이 헬퍼로 위임(상위호환)
2026-07-16 12:42:54 +09:00

519 lines
22 KiB
PHP

<?php
namespace Tests\Feature\Upgrades;
use App\Extension\UpgradeContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* Upgrade_7_0_0_beta_5 의 단발성 복구 인프라 검증.
*
* 7.0.0-beta.5 도입 "버전별 데이터 스냅샷" 인프라로 재구성:
* - 핫픽스 4종이 upgrades/data/7.0.0-beta.5/migrations/ 안에 격리됨
* - 본 테스트는 각 Migration 클래스의 run() 을 직접 호출하여 회귀 가드 유지
*
* 회귀 시나리오 (#347): beta.4 의 `applyDiscoveredTopLevelPaths` 결함으로 활성 확장
* 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`) 가
* 손실된 환경에서, beta.5 step 이 DB active row 와 `_bundled/{id}` 를 매칭하여 자동 복원.
*/
class Upgrade_7_0_0_beta_5RecoveryTest extends TestCase
{
use RefreshDatabase;
private UpgradeContext $context;
/** @var array<int, string> 정리할 임시 디렉토리 */
private array $tempDirs = [];
private ?string $originalBasePath = null;
private ?string $originalPendingPath = null;
protected function setUp(): void
{
parent::setUp();
$migrationsDir = base_path('upgrades/data/7.0.0-beta.5/migrations');
require_once $migrationsDir.'/01_RecoverActiveExtensionDirs.php';
require_once $migrationsDir.'/02_RecoverPendingStubFiles.php';
require_once $migrationsDir.'/03_VerifyBundledLangPacksFallback.php';
require_once $migrationsDir.'/05_RecoverPublicStorageSymlink.php';
$this->context = new UpgradeContext(
fromVersion: '7.0.0-beta.4',
toVersion: '7.0.0-beta.5',
currentStep: '7.0.0-beta.5',
);
$this->originalBasePath = base_path();
$this->originalPendingPath = config('app.update.pending_path');
}
protected function tearDown(): void
{
if ($this->originalBasePath !== null) {
app()->setBasePath($this->originalBasePath);
}
if ($this->originalPendingPath !== null) {
config(['app.update.pending_path' => $this->originalPendingPath]);
}
foreach ($this->tempDirs as $dir) {
if (File::isDirectory($dir)) {
File::deleteDirectory($dir);
}
}
parent::tearDown();
}
/**
* 활성 디렉토리 부재 + DB active + `_bundled` 존재 → 활성 디렉토리 자동 복원.
*/
public function test_recover_active_dir_when_bundled_exists(): void
{
$fakeBase = $this->arrangeFakeBase();
// DB: templates 테이블 active row 1건
DB::table('templates')->insert([
'identifier' => 'sirsoft-basic',
'vendor' => 'sirsoft',
'name' => json_encode(['ko' => '테스트 템플릿', 'en' => 'Test Template']),
'version' => '1.0.0',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
// _bundled 만 존재, 활성 디렉토리 부재
File::ensureDirectoryExists($fakeBase.'/templates/_bundled/sirsoft-basic');
File::put($fakeBase.'/templates/_bundled/sirsoft-basic/manifest.json', '{"identifier":"sirsoft-basic"}');
$this->invokeRecover();
$this->assertFileExists(
$fakeBase.'/templates/sirsoft-basic/manifest.json',
'활성 templates/sirsoft-basic 이 _bundled 에서 자동 복원되어야 한다',
);
}
/**
* 활성 디렉토리 정상 존재 시 silent skip (멱등).
*/
public function test_recover_skips_when_active_dir_present(): void
{
$fakeBase = $this->arrangeFakeBase();
DB::table('templates')->insert([
'identifier' => 'sirsoft-basic',
'vendor' => 'sirsoft',
'name' => json_encode(['ko' => '테스트 템플릿', 'en' => 'Test Template']),
'version' => '1.0.0',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
// 활성 디렉토리에 사용자 수정 콘텐츠
File::ensureDirectoryExists($fakeBase.'/templates/sirsoft-basic');
File::put($fakeBase.'/templates/sirsoft-basic/manifest.json', '{"installed":true,"user_modified":true}');
// _bundled 에 다른 내용
File::ensureDirectoryExists($fakeBase.'/templates/_bundled/sirsoft-basic');
File::put($fakeBase.'/templates/_bundled/sirsoft-basic/manifest.json', '{"identifier":"sirsoft-basic"}');
$this->invokeRecover();
// 활성 콘텐츠가 덮어쓰여지지 않아야 함
$manifest = json_decode(File::get($fakeBase.'/templates/sirsoft-basic/manifest.json'), true);
$this->assertTrue($manifest['user_modified'] ?? false, '정상 활성 디렉토리는 복구 대상에서 제외되어야 한다');
}
/**
* 외부 확장 (DB active + `_bundled` 부재) → DB row 단발성 비활성화.
*/
public function test_external_extension_marked_deactivated_when_bundled_missing(): void
{
$fakeBase = $this->arrangeFakeBase();
DB::table('modules')->insert([
'identifier' => 'external-vendor-x',
'vendor' => 'external-vendor',
'name' => json_encode(['ko' => '외부 모듈', 'en' => 'External Module']),
'version' => '1.0.0',
'status' => 'active',
'github_url' => 'https://github.com/external-vendor/x',
'created_at' => now(),
'updated_at' => now(),
]);
// _bundled 디렉토리는 만들지만 해당 식별자는 부재 (외부 확장)
File::ensureDirectoryExists($fakeBase.'/modules/_bundled');
$this->invokeRecover();
$row = DB::table('modules')->where('identifier', 'external-vendor-x')->first();
$this->assertSame('inactive', $row->status, '외부 확장 row 는 inactive 로 전환되어야 한다');
$this->assertSame('extension_dir_lost_beta5', $row->deactivated_reason);
$this->assertNotNull($row->deactivated_at);
}
/**
* `_pending/.gitignore` 와 `_pending/.gitkeep` 가 부재 시 stub 재생성.
*/
public function test_pending_stub_files_recreated_when_missing(): void
{
$fakeBase = $this->arrangeFakeBase();
// _pending 자체는 존재하나 stub 부재
foreach (['modules', 'plugins', 'templates', 'lang-packs'] as $domain) {
File::ensureDirectoryExists($fakeBase.'/'.$domain.'/_pending');
}
$this->invokeRecoverPendingStubs();
foreach (['modules', 'plugins', 'templates', 'lang-packs'] as $domain) {
$this->assertFileExists($fakeBase.'/'.$domain.'/_pending/.gitignore');
$this->assertFileExists($fakeBase.'/'.$domain.'/_pending/.gitkeep');
$content = File::get($fakeBase.'/'.$domain.'/_pending/.gitignore');
$this->assertStringContainsString('*', $content);
$this->assertStringContainsString('!.gitkeep', $content);
}
}
/**
* `_pending/.gitignore` 가 이미 존재하면 덮어쓰지 않음 (멱등).
*/
public function test_pending_stub_files_preserved_when_present(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/modules/_pending');
File::put($fakeBase.'/modules/_pending/.gitignore', "# customized\n*\n!.gitignore\n!.gitkeep\n");
File::put($fakeBase.'/modules/_pending/.gitkeep', '');
$this->invokeRecoverPendingStubs();
$content = File::get($fakeBase.'/modules/_pending/.gitignore');
$this->assertStringContainsString('# customized', $content, '기존 stub 은 덮어쓰여지지 않아야 한다');
}
/**
* 4개 도메인 (modules/plugins/templates/language_packs) 일괄 복구.
*/
public function test_all_four_domains_recovered_in_one_pass(): void
{
$fakeBase = $this->arrangeFakeBase();
DB::table('modules')->insert([
'identifier' => 'sirsoft-ecommerce',
'vendor' => 'sirsoft',
'name' => json_encode(['ko' => '이커머스', 'en' => 'Ecommerce']),
'version' => '1.0.0',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('plugins')->insert([
'identifier' => 'sirsoft-payment',
'vendor' => 'sirsoft',
'name' => json_encode(['ko' => '결제', 'en' => 'Payment']),
'version' => '1.0.0',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('templates')->insert([
'identifier' => 'sirsoft-basic',
'vendor' => 'sirsoft',
'name' => json_encode(['ko' => '템플릿', 'en' => 'Template']),
'version' => '1.0.0',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
// language_packs 는 마이그레이션 이후 컬럼 구조가 다름 — 컬럼이 있을 때만 시드
if (Schema::hasTable('language_packs') && Schema::hasColumn('language_packs', 'identifier')) {
DB::table('language_packs')->insert([
'identifier' => 'g7-core-ja',
'vendor' => 'g7',
'scope' => 'core',
'target_identifier' => null,
'locale' => 'ja',
'locale_name' => 'Japanese',
'locale_native_name' => '日本語',
'text_direction' => 'ltr',
'version' => '1.0.0',
'status' => 'active',
'manifest' => json_encode([]),
'created_at' => now(),
'updated_at' => now(),
]);
File::ensureDirectoryExists($fakeBase.'/lang-packs/_bundled/g7-core-ja');
File::put($fakeBase.'/lang-packs/_bundled/g7-core-ja/language-pack.json', '{"identifier":"g7-core-ja"}');
}
File::ensureDirectoryExists($fakeBase.'/modules/_bundled/sirsoft-ecommerce');
File::put($fakeBase.'/modules/_bundled/sirsoft-ecommerce/module.json', '{"identifier":"sirsoft-ecommerce"}');
File::ensureDirectoryExists($fakeBase.'/plugins/_bundled/sirsoft-payment');
File::put($fakeBase.'/plugins/_bundled/sirsoft-payment/plugin.json', '{"identifier":"sirsoft-payment"}');
File::ensureDirectoryExists($fakeBase.'/templates/_bundled/sirsoft-basic');
File::put($fakeBase.'/templates/_bundled/sirsoft-basic/manifest.json', '{"identifier":"sirsoft-basic"}');
$this->invokeRecover();
$this->assertFileExists($fakeBase.'/modules/sirsoft-ecommerce/module.json');
$this->assertFileExists($fakeBase.'/plugins/sirsoft-payment/plugin.json');
$this->assertFileExists($fakeBase.'/templates/sirsoft-basic/manifest.json');
if (Schema::hasTable('language_packs') && Schema::hasColumn('language_packs', 'identifier')) {
$this->assertFileExists($fakeBase.'/lang-packs/g7-core-ja/language-pack.json');
}
}
/**
* 복원된 활성 디렉토리에 `.preserve-ownership` 마커가 작성되지 않아야 한다.
*
* Step 11 `restoreOwnership` 의 재귀 chown 이 마커가 있으면 skip 하므로, 본 step 이
* 우발적으로 마커를 만들면 영구 권한 비대칭 위험. `_bundled` 에 마커가 없으면 활성도
* 마커 없이 복원되어야 한다.
*/
public function test_recover_does_not_create_preserve_ownership_marker(): void
{
$fakeBase = $this->arrangeFakeBase();
DB::table('templates')->insert([
'identifier' => 'sirsoft-basic',
'vendor' => 'sirsoft',
'name' => json_encode(['ko' => '템플릿', 'en' => 'Template']),
'version' => '1.0.0',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
File::ensureDirectoryExists($fakeBase.'/templates/_bundled/sirsoft-basic');
File::put($fakeBase.'/templates/_bundled/sirsoft-basic/manifest.json', '{"identifier":"sirsoft-basic"}');
$this->invokeRecover();
$this->assertFileDoesNotExist(
$fakeBase.'/templates/sirsoft-basic/.preserve-ownership',
'복원된 활성 디렉토리에 .preserve-ownership 마커가 우발적으로 작성되어서는 안 된다',
);
}
/**
* lang-packs/_bundled 정상 존재 시 fallback skip (멱등 확인).
*/
public function test_lang_packs_fallback_skipped_when_already_present(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/lang-packs/_bundled/g7-core-ja');
File::put($fakeBase.'/lang-packs/_bundled/g7-core-ja/language-pack.json', '{"identifier":"g7-core-ja","preserve":true}');
$this->invokeLangPacksFallback();
$manifest = json_decode(File::get($fakeBase.'/lang-packs/_bundled/g7-core-ja/language-pack.json'), true);
$this->assertTrue($manifest['preserve'] ?? false, '정상 데이터가 fallback 으로 덮어쓰여지면 안 된다');
}
/**
* lang-packs/_bundled 부재 + _pending 에 소스 존재 시 fallback 복원.
*/
public function test_lang_packs_fallback_recovers_from_deep_pending_source(): void
{
$fakeBase = $this->arrangeFakeBase();
$pending = config('app.update.pending_path');
// 비표준 깊이(5단계) 의 소스 — beta.4 의 3단계 제약을 회피하는 경로
$deepSource = $pending.'/core_20260511_120000/extracted/wrapper/extra-layer/lang-packs/_bundled';
File::ensureDirectoryExists($deepSource.'/g7-core-ja');
File::put($deepSource.'/g7-core-ja/language-pack.json', '{"identifier":"g7-core-ja","source":"deep"}');
$this->invokeLangPacksFallback();
$this->assertFileExists($fakeBase.'/lang-packs/_bundled/g7-core-ja/language-pack.json');
$manifest = json_decode(File::get($fakeBase.'/lang-packs/_bundled/g7-core-ja/language-pack.json'), true);
$this->assertSame('deep', $manifest['source'] ?? null);
}
// ──────────────────────────────────────────
// recoverPublicStorageSymlink (계획서 §5)
// ──────────────────────────────────────────
/**
* 정상 symlink 상태면 silent skip (멱등).
*/
public function test_recover_public_storage_symlink_skips_when_already_symlink(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/public');
File::ensureDirectoryExists($fakeBase.'/storage/app/public');
if (! @symlink($fakeBase.'/storage/app/public', $fakeBase.'/public/storage')) {
$this->markTestSkipped('symlink 생성 권한 부족 (Windows 일반 사용자)');
}
$linkTargetBefore = readlink($fakeBase.'/public/storage');
$this->invokeRecoverPublicStorageSymlink();
$this->assertTrue(is_link($fakeBase.'/public/storage'), '정상 symlink 상태는 보존되어야 한다');
$this->assertSame($linkTargetBefore, readlink($fakeBase.'/public/storage'), 'symlink target 미변경');
}
/**
* 일반 디렉토리 + Laravel storage:link source 존재 → rename 보존 + symlink 재생성.
*/
public function test_recover_public_storage_symlink_recovers_corrupted_directory(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/public/storage');
File::ensureDirectoryExists($fakeBase.'/storage/app/public');
// 손상 상태 시뮬레이션 — public/storage 안에 dereferenced 복사 콘텐츠
File::put($fakeBase.'/public/storage/uploaded.txt', 'dereferenced content');
File::put($fakeBase.'/storage/app/public/uploaded.txt', 'original content');
if (! @symlink($fakeBase.'/storage/app/public', $fakeBase.'/test_symlink_capability')) {
// 권한 없음 → recoverPublicStorageSymlink 가 rename 후 symlink 실패 → 원복 경로 검증으로 대체
@unlink($fakeBase.'/test_symlink_capability');
$this->markTestSkipped('symlink 생성 권한 부족 (Windows 일반 사용자)');
}
@unlink($fakeBase.'/test_symlink_capability');
$this->invokeRecoverPublicStorageSymlink();
// 1) public/storage 가 symlink 로 재생성
$this->assertTrue(is_link($fakeBase.'/public/storage'), 'public/storage 가 symlink 로 재생성되어야 한다');
$this->assertSame($fakeBase.'/storage/app/public', readlink($fakeBase.'/public/storage'));
// 2) .broken.{timestamp} 백업 디렉토리 존재 + dereferenced 콘텐츠 보존
$backups = array_values(array_filter(
scandir($fakeBase.'/public'),
fn ($e) => str_starts_with($e, 'storage.broken.'),
));
$this->assertCount(1, $backups, '.broken.{timestamp} 백업 디렉토리 1개 존재');
$this->assertFileExists($fakeBase.'/public/'.$backups[0].'/uploaded.txt');
$this->assertSame('dereferenced content', File::get($fakeBase.'/public/'.$backups[0].'/uploaded.txt'));
}
/**
* Laravel storage:link source (storage/app/public) 부재 → 운영자 의도적 구성으로 보고 skip.
*/
public function test_recover_public_storage_symlink_skips_when_source_absent(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/public/storage');
File::put($fakeBase.'/public/storage/custom.txt', 'operator content');
// storage/app/public 의도적 미생성 — Laravel 표준 미사용 운영자 시뮬
$this->invokeRecoverPublicStorageSymlink();
// public/storage 는 일반 디렉토리로 유지 + 콘텐츠 보존
$this->assertFalse(is_link($fakeBase.'/public/storage'), 'source 부재 시 symlink 생성 안 함');
$this->assertTrue(is_dir($fakeBase.'/public/storage'));
$this->assertFileExists($fakeBase.'/public/storage/custom.txt');
$this->assertSame('operator content', File::get($fakeBase.'/public/storage/custom.txt'));
// .broken.* 백업도 없어야 함 (rename 미실행)
$entries = array_values(array_filter(
scandir($fakeBase.'/public'),
fn ($e) => str_starts_with($e, 'storage.broken.'),
));
$this->assertEmpty($entries, 'source 부재 시 rename 백업도 생성되지 않아야 한다');
}
/**
* public/storage 자체가 부재 (prune 삭제 또는 storage:link 미실행) → 재생성 (#43).
*
* `StorageLinkHelper` 위임 이후 동작이 상위호환으로 확장됐다 — 기존 스텝은 "부재" 를
* skip 했지만, 헬퍼는 Laravel storage:link source 가 존재하면 symlink 를 신규 생성한다.
* 이는 `--prune` 이 orphan 으로 삭제한 갭을 복구하는 핵심 케이스다.
*/
public function test_recover_public_storage_symlink_recreates_when_public_storage_absent(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/public');
File::ensureDirectoryExists($fakeBase.'/storage/app/public');
// public/storage 의도적 미생성 — prune 이 삭제했거나 storage:link 미실행 환경
// symlink 생성 권한 확인 (Windows 일반 사용자면 skip)
if (! @symlink($fakeBase.'/storage/app/public', $fakeBase.'/symlink_probe')) {
$this->markTestSkipped('symlink 생성 권한 부족 (Windows SeCreateSymbolicLink)');
}
@unlink($fakeBase.'/symlink_probe');
$this->invokeRecoverPublicStorageSymlink();
$this->assertTrue(is_link($fakeBase.'/public/storage'), 'source 존재 시 부재 public/storage 를 symlink 로 재생성해야 한다 (#43)');
$this->assertSame($fakeBase.'/storage/app/public', readlink($fakeBase.'/public/storage'));
}
/**
* public/storage 부재 + Laravel storage:link source(storage/app/public) 도 부재 →
* 표준 미사용 환경으로 보고 skip (false positive 차단).
*/
public function test_recover_public_storage_symlink_skips_when_both_absent(): void
{
$fakeBase = $this->arrangeFakeBase();
File::ensureDirectoryExists($fakeBase.'/public');
// public/storage 와 storage/app/public 모두 의도적 미생성
$this->invokeRecoverPublicStorageSymlink();
$this->assertFalse(is_link($fakeBase.'/public/storage'), 'source 부재 시 자동 생성 안 함');
$this->assertFalse(file_exists($fakeBase.'/public/storage'), 'public/storage 자체가 생성되지 않아야 한다');
}
/**
* 격리된 base_path + pending_path 구성.
*/
private function arrangeFakeBase(): string
{
$fakeBase = storage_path('test_beta5_recovery_'.uniqid());
File::ensureDirectoryExists($fakeBase);
$this->tempDirs[] = $fakeBase;
app()->setBasePath($fakeBase);
$fakePending = $fakeBase.'/storage/app/core_pending';
File::ensureDirectoryExists($fakePending);
config(['app.update.pending_path' => $fakePending]);
return $fakeBase;
}
private function invokeRecover(): void
{
$class = 'App\\Upgrades\\Data\\V7_0_0_beta_5\\Migrations\\RecoverActiveExtensionDirs';
(new $class)->run($this->context);
}
private function invokeRecoverPendingStubs(): void
{
$class = 'App\\Upgrades\\Data\\V7_0_0_beta_5\\Migrations\\RecoverPendingStubFiles';
(new $class)->run($this->context);
}
private function invokeRecoverPublicStorageSymlink(): void
{
$class = 'App\\Upgrades\\Data\\V7_0_0_beta_5\\Migrations\\RecoverPublicStorageSymlink';
(new $class)->run($this->context);
}
private function invokeLangPacksFallback(): void
{
$class = 'App\\Upgrades\\Data\\V7_0_0_beta_5\\Migrations\\VerifyBundledLangPacksFallback';
(new $class)->run($this->context);
}
}