testModulePath = base_path('modules/test-backup-mod'); $this->backupsBasePath = storage_path('app/extension_backups'); File::ensureDirectoryExists($this->testModulePath.'/src'); File::put($this->testModulePath.'/module.json', '{"identifier": "test-backup-mod", "version": "1.0.0"}'); File::put($this->testModulePath.'/src/Service.php', 'testModulePath)) { File::deleteDirectory($this->testModulePath); } if (File::isDirectory($this->backupsBasePath)) { File::deleteDirectory($this->backupsBasePath); } parent::tearDown(); } /** * 백업이 storage에 생성되는지 확인합니다. */ public function test_create_backup_copies_directory_to_storage(): void { $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); $this->assertDirectoryExists($backupPath); $this->assertStringContainsString('extension_backups/modules/test-backup-mod_', $backupPath); } /** * 백업 파일 내용이 원본과 일치하는지 확인합니다. */ public function test_create_backup_preserves_file_contents(): void { $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); $this->assertFileExists($backupPath.'/module.json'); $this->assertEquals( '{"identifier": "test-backup-mod", "version": "1.0.0"}', File::get($backupPath.'/module.json') ); } /** * 중첩 디렉토리 구조가 보존되는지 확인합니다. */ public function test_create_backup_preserves_nested_structure(): void { $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); $this->assertFileExists($backupPath.'/src/Service.php'); $this->assertEquals( 'testModulePath.'/module.json', '{"version": "2.0.0"}'); // 복원 ExtensionBackupHelper::restoreFromBackup('modules', 'test-backup-mod', $backupPath); $this->assertEquals( '{"identifier": "test-backup-mod", "version": "1.0.0"}', File::get($this->testModulePath.'/module.json') ); } /** * 복원 후 백업에 없던 파일이 삭제되는지 확인합니다. */ public function test_restore_from_backup_removes_extra_files(): void { $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); // 원본에 새 파일 추가 File::put($this->testModulePath.'/extra.txt', 'extra content'); // 복원 ExtensionBackupHelper::restoreFromBackup('modules', 'test-backup-mod', $backupPath); $this->assertFileDoesNotExist($this->testModulePath.'/extra.txt'); } /** * 백업 삭제 후 디렉토리가 제거되는지 확인합니다. */ public function test_delete_backup_removes_directory(): void { $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); ExtensionBackupHelper::deleteBackup($backupPath); $this->assertDirectoryDoesNotExist($backupPath); } /** * 존재하지 않는 소스로 백업 시 예외가 발생하는지 확인합니다. */ public function test_create_backup_with_nonexistent_source_throws(): void { $this->expectException(\RuntimeException::class); ExtensionBackupHelper::createBackup('modules', 'nonexistent-module'); } /** * 백업 디렉토리가 부모(php-fpm) 소유권을 상속한다 (POSIX best-effort). * * 회귀 가드 (버그 ③): sudo 업데이트가 백업 부모/디렉토리를 root 소유로 잔존시키면 * 이후 www-data 의 mkdir 이 실패한다. createBackup 은 부모 소유권을 상속해 이를 * 차단해야 한다. sudo 없는 일반 환경에서 상속은 no-op(이미 부모와 동일 owner) 이므로 * "백업 owner == 부모 owner" 가 항상 성립해야 한다 — 상속이 owner 를 잘못 바꾸면 깨짐. */ public function test_create_backup_inherits_parent_ownership(): void { if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('fileowner')) { $this->markTestSkipped('소유권 검증은 POSIX 환경 전용 (Windows 로컬 자동 스킵)'); } $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); // 백업 루트(extension_backups/modules) 의 owner 가 부모(extension_backups) 와 일치 $backupTypeDir = dirname($backupPath); $this->assertSame( fileowner(dirname($backupTypeDir)), fileowner($backupTypeDir), '백업 타입 디렉토리는 부모 소유권을 상속해야 함', ); // 백업 타임스탬프 디렉토리도 부모 owner 와 일치 $this->assertSame( fileowner($backupTypeDir), fileowner($backupPath), '백업 디렉토리는 부모 소유권을 상속해야 함', ); } /** * 백업 부모 디렉토리(extension_backups/{type})가 g-w(0755)로 미리 존재해도 백업 생성이 * 성공하고, 부모 체인이 g+w(0775)로 정상화된다 (POSIX best-effort). * * 회귀 가드 (버그 ③): sudo 코어 업데이트가 extension_backups/modules 등 백업 부모 * 디렉토리를 g-w(0755)로 만들어 두면 이후 php-fpm 그룹이 그 안에 백업을 mkdir 하지 * 못해 "mkdir(): Permission denied" 로 module:update 가 백업 생성 단계에서 실패한다. * createBackup 은 부모 체인을 g+w 로 보장한 뒤 백업을 생성해야 한다. */ public function test_create_backup_succeeds_when_parent_dir_is_group_unwritable(): void { if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('fileperms')) { $this->markTestSkipped('권한 검증은 POSIX 환경 전용 (Windows 로컬 자동 스킵)'); } // 백업 부모 체인을 미리 g-w(0755)로 만들어 둔다 (sudo 업데이트 잔재 재현) $backupRoot = storage_path('app/extension_backups'); $backupTypeDir = $backupRoot.'/modules'; File::ensureDirectoryExists($backupTypeDir); @chmod($backupRoot, 0755); @chmod($backupTypeDir, 0755); // g-w 상태에서도 백업 생성이 mkdir 오류 없이 성공해야 한다 $backupPath = ExtensionBackupHelper::createBackup('modules', 'test-backup-mod'); $this->assertDirectoryExists($backupPath); // 부모 체인이 g+w(0775)로 정상화됨 — group write 비트(0020) 확인 $this->assertSame(0020, fileperms($backupRoot) & 0020, 'extension_backups 가 g+w 여야 함'); $this->assertSame(0020, fileperms($backupTypeDir) & 0020, 'extension_backups/modules 가 g+w 여야 함'); } }