testBasePath = storage_path('app/test_core_backup'); $this->backupsBasePath = storage_path('app/core_backups'); // 테스트용 디렉토리 구조 생성 + 기존 backup 산출물(이전 테스트/실제 코어 업데이트 // 흔적) 으로 인한 카운트 오염 차단 File::ensureDirectoryExists($this->testBasePath); if (File::isDirectory($this->backupsBasePath)) { File::deleteDirectory($this->backupsBasePath); } } protected function tearDown(): void { if (File::isDirectory($this->testBasePath)) { File::deleteDirectory($this->testBasePath); } if (File::isDirectory($this->backupsBasePath)) { File::deleteDirectory($this->backupsBasePath); } parent::tearDown(); } // ======================================================================== // restoreFromBackup() - 개별 target 실패 시 중단 방지 // ======================================================================== /** * 전체 복원 성공 시 빈 배열을 반환하는지 검증합니다. */ public function test_restore_from_backup_returns_empty_array_on_full_success(): void { // 백업 소스 준비 $backupPath = $this->testBasePath.'/backup'; File::ensureDirectoryExists($backupPath.'/app'); File::put($backupPath.'/app/test.php', 'assertIsArray($failedTargets); $this->assertEmpty($failedTargets); // 원복 File::put($destComposer, $originalComposer); File::delete(base_path('app/test.php')); } /** * 백업에 없는 target은 건너뛰고 성공으로 처리하는지 검증합니다. */ public function test_restore_from_backup_skips_missing_targets(): void { $backupPath = $this->testBasePath.'/backup'; File::ensureDirectoryExists($backupPath); File::put($backupPath.'/composer.json', '{}'); // 'nonexistent_dir'은 백업에 없으므로 건너뜀 $failedTargets = CoreBackupHelper::restoreFromBackup( $backupPath, ['nonexistent_dir', 'also_missing'], null, [] ); $this->assertEmpty($failedTargets); } /** * 개별 target 복원 실패 시 나머지 target은 계속 복원되는지 검증합니다. */ public function test_restore_continues_on_individual_target_failure(): void { $backupPath = $this->testBasePath.'/backup'; $restoreTarget = $this->testBasePath.'/restore_dest'; // 백업 소스: 정상 파일 + 복원 실패할 디렉토리 File::ensureDirectoryExists($backupPath.'/good_dir'); File::put($backupPath.'/good_dir/file.txt', 'good content'); File::ensureDirectoryExists($backupPath.'/another_good'); File::put($backupPath.'/another_good/file.txt', 'another content'); // good_dir, bad_dir, another_good 순서로 복원 // bad_dir은 백업에 없으므로 스킵 (실패가 아님) // 실제 복원 실패를 시뮬레이션하려면 FilePermissionHelper를 모킹해야 하므로 // 여기서는 반환값의 타입과 정상 케이스 동작을 검증 $failedTargets = CoreBackupHelper::restoreFromBackup( $backupPath, ['good_dir', 'another_good'], null, [] ); $this->assertEmpty($failedTargets); // 복원된 파일 확인 (base_path 기준) $this->assertFileExists(base_path('good_dir/file.txt')); $this->assertFileExists(base_path('another_good/file.txt')); // 정리 File::deleteDirectory(base_path('good_dir')); File::deleteDirectory(base_path('another_good')); } /** * 복원 실패한 target이 failedTargets 배열에 포함되는지 검증합니다. * 읽기 전용 디렉토리를 대상으로 실제 복원 실패를 유발합니다. */ public function test_restore_reports_failed_targets(): void { // Windows에서는 퍼미션 기반 실패 유발이 어려우므로 // 존재하지 않는 복원 대상 경로 대신, 잘못된 백업 구조를 활용 // 이 테스트는 반환 타입이 array인지 검증하는 데 초점 $backupPath = $this->testBasePath.'/backup'; File::ensureDirectoryExists($backupPath); $failedTargets = CoreBackupHelper::restoreFromBackup( $backupPath, ['target_that_does_not_exist_in_backup'], null, [] ); // 백업에 없는 target은 skip되므로 failed에 포함되지 않음 $this->assertIsArray($failedTargets); $this->assertEmpty($failedTargets); } // ======================================================================== // createBackup() - vendor 포함 백업 // ======================================================================== /** * targets에 vendor가 포함되면 vendor 디렉토리가 백업되는지 검증합니다. */ public function test_create_backup_includes_vendor_when_in_targets(): void { // vendor 디렉토리는 base_path에 있으므로 실제 존재 여부 확인 $this->assertDirectoryExists(base_path('vendor')); // excludes에서 vendor를 제거한 상태로 백업 (현재 config 반영) $backupPath = CoreBackupHelper::createBackup( ['vendor'], null, [] // vendor를 excludes에서 제거 ); $this->assertDirectoryExists($backupPath.'/vendor'); $this->assertFileExists($backupPath.'/vendor/autoload.php'); // 정리 CoreBackupHelper::deleteBackup($backupPath); } /** * excludes에 vendor가 있으면 다른 target 내 vendor 하위 디렉토리가 제외되는지 검증합니다. * (vendor 자체가 target이면 최상위로 복사됨) */ public function test_create_backup_excludes_nested_vendor_dirs(): void { // 테스트용 디렉토리 생성 $testDir = $this->testBasePath.'/exclude_test'; File::ensureDirectoryExists($testDir.'/src'); File::ensureDirectoryExists($testDir.'/vendor/package'); File::put($testDir.'/src/App.php', 'assertFileExists($backupPath.'/test_dir/src/App.php'); $this->assertFileDoesNotExist($backupPath.'/test_dir/vendor/package/lib.php'); // 정리 File::deleteDirectory($backupPath); } // ======================================================================== // config 설정 검증 // ======================================================================== /** * config targets에 vendor가 포함되지 않고 backup_only에 있는지 검증합니다. */ public function test_config_vendor_in_backup_only_not_targets(): void { $targets = config('app.update.targets', []); $backupOnly = config('app.update.backup_only', []); $this->assertNotContains('vendor', $targets); $this->assertContains('vendor', $backupOnly); } /** * config excludes에서 vendor가 제거되었는지 검증합니다. */ public function test_config_excludes_does_not_contain_vendor(): void { $excludes = config('app.update.excludes', []); $this->assertNotContains('vendor', $excludes); } // ======================================================================== // deleteBackup() / listBackups() // ======================================================================== /** * 백업 삭제가 정상 동작하는지 검증합니다. */ public function test_delete_backup_removes_directory(): void { $backupPath = $this->backupsBasePath.'/core_test_delete'; File::ensureDirectoryExists($backupPath); File::put($backupPath.'/test.txt', 'test'); CoreBackupHelper::deleteBackup($backupPath); $this->assertDirectoryDoesNotExist($backupPath); } /** * 백업 목록이 올바르게 반환되는지 검증합니다. */ public function test_list_backups_returns_correct_structure(): void { File::ensureDirectoryExists($this->backupsBasePath.'/core_20260101_120000'); File::ensureDirectoryExists($this->backupsBasePath.'/core_20260102_120000'); $backups = CoreBackupHelper::listBackups(); // Windows 환경에서 setUp 의 deleteDirectory 가 file lock 으로 일부 실패할 수 있으므로 // 테스트가 명시적으로 만든 fixture 만 필터링하여 검증 (격리 우회). $fixtureBackups = array_values(array_filter($backups, fn ($b) => str_starts_with( $b['name'] ?? '', 'core_2026010' ))); $this->assertCount(2, $fixtureBackups); $this->assertArrayHasKey('path', $fixtureBackups[0]); $this->assertArrayHasKey('name', $fixtureBackups[0]); $this->assertArrayHasKey('created_at', $fixtureBackups[0]); } /** * 코어 백업 디렉토리가 부모(php-fpm) 소유권을 상속한다 (POSIX best-effort). * * 회귀 가드 (버그 ③): sudo 업데이트가 백업 루트(core_backups)/디렉토리를 root 소유로 * 잔존시키면 이후 www-data 의 mkdir 이 실패한다. createBackup 은 백업 루트와 타임스탬프 * 디렉토리 양쪽에 부모 소유권을 상속해야 한다. sudo 없는 환경에서 상속은 no-op 이므로 * "백업 owner == 부모 owner" 가 항상 성립해야 한다 — 상속이 owner 를 잘못 바꾸면 깨짐. */ public function test_create_backup_inherits_parent_ownership(): void { if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('fileowner')) { $this->markTestSkipped('소유권 검증은 POSIX 환경 전용 (Windows 로컬 자동 스킵)'); } // 가벼운 단일 파일 target 으로 백업 생성 File::put($this->testBasePath.'/sample.txt', 'core backup ownership test'); $backupPath = CoreBackupHelper::createBackup(['app/test_core_backup/sample.txt']); // 백업 루트(core_backups) 가 부모(storage/app) 소유권을 상속 $this->assertSame( fileowner(dirname($this->backupsBasePath)), fileowner($this->backupsBasePath), '백업 루트(core_backups)는 부모 소유권을 상속해야 함', ); // 타임스탬프 디렉토리(core_{ts})도 부모(core_backups) owner 와 일치 $this->assertSame( fileowner($this->backupsBasePath), fileowner($backupPath), '백업 타임스탬프 디렉토리는 부모 소유권을 상속해야 함', ); CoreBackupHelper::deleteBackup($backupPath); } }