위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던 결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다. - 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는 DB 쓰기에 선행하여 거부 시 상태 불변. - 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체. 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형. - secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙 소유권·비밀·발행 상태 검사 통일. - 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를 런타임·저장측·정적검사 3층 동형화. - 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
766 lines
33 KiB
PHP
766 lines
33 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Extension;
|
|
|
|
use App\Extension\Helpers\ExtensionPendingHelper;
|
|
use Illuminate\Support\Facades\File;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* ExtensionPendingHelper 테스트
|
|
*
|
|
* ## File Facade Spy 패턴 (IMPORTANT)
|
|
*
|
|
* 확장 복사(copyToActive) 관련 실패 시나리오 테스트 시,
|
|
* 실제 파일시스템 기반이 아닌 **File Facade Spy 패턴**을 사용해야 합니다.
|
|
*
|
|
* 이유: ProtectsExtensionDirectories trait이 확장 활성 디렉토리 삭제를
|
|
* 차단하므로, Facade를 통하지 않는 직접 파일 조작은 보호 메커니즘과 충돌합니다.
|
|
*
|
|
* 패턴 (copyToActive는 파일별 복사 — File::copy를 mock):
|
|
* ```php
|
|
* $originalFilesystem = File::getFacadeRoot();
|
|
* $mock = Mockery::mock($originalFilesystem)->makePartial();
|
|
* $mock->shouldReceive('copy')
|
|
* ->andReturnUsing(function ($src, $dst) use ($originalFilesystem) {
|
|
* if (str_contains($dst, '_updating_')) {
|
|
* throw new \RuntimeException('Disk full');
|
|
* }
|
|
* return $originalFilesystem->copy($src, $dst);
|
|
* });
|
|
* File::swap($mock);
|
|
* // ... 테스트 ...
|
|
* File::swap($originalFilesystem); // finally에서 복원
|
|
* ```
|
|
*
|
|
* @see ProtectsExtensionDirectories File Facade Spy 보호 trait
|
|
* @see ExtensionPendingHelper::copyToActive() 원자적 디렉토리 교체 로직
|
|
*/
|
|
class ExtensionPendingHelperTest extends TestCase
|
|
{
|
|
private string $modulesPath;
|
|
|
|
private string $tempBase;
|
|
|
|
private array $tempDirs = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->modulesPath = base_path('modules');
|
|
$this->tempBase = storage_path('app/test_pending_helper_'.uniqid());
|
|
File::ensureDirectoryExists($this->tempBase);
|
|
|
|
// _pending 테스트 디렉토리 생성
|
|
$pendingPath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
File::ensureDirectoryExists($pendingPath.'/src');
|
|
File::put($pendingPath.'/module.json', json_encode([
|
|
'identifier' => 'test-pending-mod',
|
|
'version' => '1.0.0',
|
|
'name' => ['ko' => '테스트 모듈', 'en' => 'Test Module'],
|
|
]));
|
|
File::put($pendingPath.'/src/Module.php', '<?php class Module {}');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// 테스트 디렉토리 정리
|
|
$pendingPath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
if (File::isDirectory($pendingPath)) {
|
|
File::deleteDirectory($pendingPath);
|
|
}
|
|
|
|
$activePath = $this->modulesPath.'/test-pending-mod';
|
|
if (File::isDirectory($activePath)) {
|
|
File::deleteDirectory($activePath);
|
|
}
|
|
|
|
// _pending 디렉토리 내 테스트용 하위 디렉토리만 정리 (디렉토리 자체는 보존)
|
|
$pendingDir = $this->modulesPath.'/_pending';
|
|
if (File::isDirectory($pendingDir)) {
|
|
foreach (File::directories($pendingDir) as $subDir) {
|
|
$dirName = basename($subDir);
|
|
// 테스트에서 생성된 디렉토리만 삭제 (test- 접두사 또는 _updating_/_old_ 임시)
|
|
if (str_starts_with($dirName, 'test-')
|
|
|| str_contains($dirName, '_updating_')
|
|
|| str_contains($dirName, '_old_')) {
|
|
File::deleteDirectory($subDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 추가 임시 디렉토리 정리
|
|
foreach ($this->tempDirs as $dir) {
|
|
if (File::isDirectory($dir)) {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
}
|
|
|
|
if (File::isDirectory($this->tempBase)) {
|
|
File::deleteDirectory($this->tempBase);
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* _pending에서 메타데이터를 올바르게 읽는지 확인합니다.
|
|
*/
|
|
public function test_load_pending_extensions_reads_meta_json(): void
|
|
{
|
|
$result = ExtensionPendingHelper::loadPendingExtensions($this->modulesPath, 'module.json');
|
|
|
|
$this->assertArrayHasKey('test-pending-mod', $result);
|
|
$this->assertEquals('test-pending-mod', $result['test-pending-mod']['identifier']);
|
|
$this->assertEquals('1.0.0', $result['test-pending-mod']['version']);
|
|
$this->assertEquals(['ko' => '테스트 모듈', 'en' => 'Test Module'], $result['test-pending-mod']['name']);
|
|
}
|
|
|
|
/**
|
|
* JSON 없는 디렉토리를 건너뛰는지 확인합니다.
|
|
*
|
|
* 격리된 임시 modulesPath 에서 테스트 — 실제 modules/_pending 디렉토리 상태와
|
|
* 무관하게 동작해야 한다.
|
|
*/
|
|
public function test_load_pending_extensions_skips_invalid_dirs(): void
|
|
{
|
|
$isolatedModulesPath = $this->tempBase.'/isolated-modules';
|
|
$pendingDir = $isolatedModulesPath.'/_pending';
|
|
|
|
// 유효한 pending 모듈 1개
|
|
File::ensureDirectoryExists($pendingDir.'/valid-mod');
|
|
File::put($pendingDir.'/valid-mod/module.json', json_encode([
|
|
'identifier' => 'valid-mod',
|
|
'version' => '1.0.0',
|
|
'name' => 'Valid Module',
|
|
]));
|
|
|
|
// JSON 없는 invalid 디렉토리
|
|
File::ensureDirectoryExists($pendingDir.'/invalid-mod');
|
|
|
|
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedModulesPath, 'module.json');
|
|
|
|
$this->assertCount(1, $result, 'JSON 없는 디렉토리는 결과에 포함되지 않아야 함');
|
|
$this->assertArrayHasKey('valid-mod', $result);
|
|
$this->assertArrayNotHasKey('invalid-mod', $result);
|
|
}
|
|
|
|
/**
|
|
* _pending이 비어있으면 빈 배열을 반환하는지 확인합니다.
|
|
*
|
|
* 격리된 임시 modulesPath 에서 테스트 — 실제 modules/_pending 디렉토리 상태와
|
|
* 무관하게 동작해야 한다.
|
|
*/
|
|
public function test_load_pending_extensions_returns_empty_when_no_pending(): void
|
|
{
|
|
$isolatedModulesPath = $this->tempBase.'/isolated-modules-empty';
|
|
$pendingDir = $isolatedModulesPath.'/_pending';
|
|
|
|
// _pending 디렉토리는 존재하지만 비어있음
|
|
File::ensureDirectoryExists($pendingDir);
|
|
|
|
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedModulesPath, 'module.json');
|
|
|
|
$this->assertEmpty($result);
|
|
}
|
|
|
|
/**
|
|
* _pending 디렉토리 자체가 없으면 빈 배열을 반환하는지 확인합니다.
|
|
*/
|
|
public function test_load_pending_extensions_returns_empty_when_dir_not_exists(): void
|
|
{
|
|
// _pending 디렉토리가 없는 경로 사용
|
|
$result = ExtensionPendingHelper::loadPendingExtensions(sys_get_temp_dir().'/nonexistent', 'module.json');
|
|
|
|
$this->assertEmpty($result);
|
|
}
|
|
|
|
/**
|
|
* 디렉토리명과 manifest 의 identifier 가 일치하지 않는 디렉토리는 스킵되어야 합니다.
|
|
*
|
|
* 업데이트/백업 과정에서 남는 임시 디렉토리(예: sirsoft-admin_basic_20260402_081819,
|
|
* sirsoft-admin_basic_updating_<uniq>, sirsoft-admin_basic_old_<uniq>)는 내부에 원본
|
|
* manifest 를 그대로 가지고 있어 identifier 가 원본과 동일하다. 이를 등록하면 install
|
|
* 시 `getPendingPath({identifier})` 가 존재하지 않는 표준 경로를 반환해 실패한다.
|
|
*/
|
|
public function test_load_extensions_skips_directories_with_mismatched_identifier(): void
|
|
{
|
|
$isolatedPath = $this->tempBase.'/isolated-mismatched';
|
|
$pendingDir = $isolatedPath.'/_pending';
|
|
|
|
// 정상 _pending 디렉토리 (디렉토리명 == identifier)
|
|
File::ensureDirectoryExists($pendingDir.'/sirsoft-board');
|
|
File::put($pendingDir.'/sirsoft-board/module.json', json_encode([
|
|
'identifier' => 'sirsoft-board',
|
|
'version' => '1.0.0',
|
|
'name' => 'Board',
|
|
]));
|
|
|
|
// 업데이트 백업 임시 디렉토리 (디렉토리명에 타임스탬프 suffix, manifest 는 원본 그대로)
|
|
File::ensureDirectoryExists($pendingDir.'/sirsoft-board_20260402_081819');
|
|
File::put($pendingDir.'/sirsoft-board_20260402_081819/module.json', json_encode([
|
|
'identifier' => 'sirsoft-board',
|
|
'version' => '1.0.0',
|
|
'name' => 'Board',
|
|
]));
|
|
|
|
// 업데이트 진행 중 임시 디렉토리
|
|
File::ensureDirectoryExists($pendingDir.'/sirsoft-board_updating_abc123');
|
|
File::put($pendingDir.'/sirsoft-board_updating_abc123/module.json', json_encode([
|
|
'identifier' => 'sirsoft-board',
|
|
'version' => '1.0.0',
|
|
'name' => 'Board',
|
|
]));
|
|
|
|
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedPath, 'module.json');
|
|
|
|
$this->assertCount(1, $result, '정상 디렉토리 1개만 등록되어야 함');
|
|
$this->assertArrayHasKey('sirsoft-board', $result);
|
|
$this->assertEquals(
|
|
'sirsoft-board',
|
|
basename($result['sirsoft-board']['source_path']),
|
|
'source_path 가 표준 디렉토리여야 함 (타임스탬프 suffix 가 붙은 임시 디렉토리가 아님)'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 정상 디렉토리가 없고 임시 디렉토리만 남은 경우 빈 배열을 반환해야 합니다.
|
|
*
|
|
* 이전 업데이트가 비정상 종료되어 원본이 삭제되고 임시 디렉토리만 남는 상황.
|
|
* 이 경우 확장을 "pending" 으로 인식하면 install 이 잘못된 경로로 접근해 실패하므로,
|
|
* 빈 배열을 반환하여 _bundled 폴백으로 넘어가게 해야 한다.
|
|
*/
|
|
public function test_load_extensions_returns_empty_when_only_temp_directories_exist(): void
|
|
{
|
|
$isolatedPath = $this->tempBase.'/isolated-temp-only';
|
|
$pendingDir = $isolatedPath.'/_pending';
|
|
|
|
File::ensureDirectoryExists($pendingDir.'/orphan-mod_old_xyz');
|
|
File::put($pendingDir.'/orphan-mod_old_xyz/module.json', json_encode([
|
|
'identifier' => 'orphan-mod',
|
|
'version' => '1.0.0',
|
|
'name' => 'Orphan',
|
|
]));
|
|
|
|
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedPath, 'module.json');
|
|
|
|
$this->assertEmpty($result, '임시 디렉토리만 있으면 pending 로 인식하지 않아야 함');
|
|
}
|
|
|
|
/**
|
|
* _pending에서 활성 디렉토리로 복사되는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_moves_files(): void
|
|
{
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
|
|
$this->assertDirectoryExists($targetPath);
|
|
$this->assertFileExists($targetPath.'/module.json');
|
|
$this->assertFileExists($targetPath.'/src/Module.php');
|
|
|
|
// 원본도 여전히 존재 확인
|
|
$this->assertDirectoryExists($sourcePath);
|
|
}
|
|
|
|
/**
|
|
* 대상에 이미 디렉토리가 있을 때 덮어쓰는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_overwrites_existing(): void
|
|
{
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/old-file.txt', 'old');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
|
|
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
|
|
$this->assertFileExists($targetPath.'/module.json');
|
|
}
|
|
|
|
/**
|
|
* 확장 디렉토리가 완전히 삭제되는지 확인합니다.
|
|
*/
|
|
public function test_delete_extension_directory_removes_completely(): void
|
|
{
|
|
// 활성 디렉토리 생성
|
|
$activePath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($activePath);
|
|
File::put($activePath.'/file.txt', 'content');
|
|
|
|
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, 'test-pending-mod');
|
|
|
|
$this->assertDirectoryDoesNotExist($activePath);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 경로 삭제 시 예외 없이 통과하는지 확인합니다.
|
|
*/
|
|
public function test_delete_extension_directory_ignores_nonexistent(): void
|
|
{
|
|
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, 'nonexistent-mod');
|
|
|
|
$this->assertTrue(true); // 예외 미발생 확인
|
|
}
|
|
|
|
/**
|
|
* 복사 실패 시 기존 대상 디렉토리가 보존되는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_preserves_target_on_copy_failure(): void
|
|
{
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/existing-file.txt', 'important data');
|
|
|
|
// 존재하지 않는 소스로 복사 시도 → 예외 발생
|
|
$invalidSource = $this->modulesPath.'/_pending/nonexistent-mod';
|
|
|
|
try {
|
|
ExtensionPendingHelper::copyToActive($invalidSource, $targetPath);
|
|
$this->fail('예외가 발생해야 합니다');
|
|
} catch (\RuntimeException $e) {
|
|
// 기존 대상 디렉토리가 보존되는지 확인
|
|
$this->assertDirectoryExists($targetPath);
|
|
$this->assertFileExists($targetPath.'/existing-file.txt');
|
|
$this->assertEquals('important data', File::get($targetPath.'/existing-file.txt'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 정상 교체 후 임시 디렉토리가 남아있지 않은지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_cleans_temp_after_success(): void
|
|
{
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/old-file.txt', 'old');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
|
|
// _pending/ 하위의 _updating_ 임시 디렉토리가 남아있지 않은지 확인
|
|
$pendingPath = $this->modulesPath.'/_pending';
|
|
$identifier = basename($targetPath);
|
|
$tempDirs = glob($pendingPath.'/'.$identifier.'_updating_*');
|
|
$this->assertEmpty($tempDirs, '임시 디렉토리가 정리되어야 합니다');
|
|
$oldDirs = glob($pendingPath.'/'.$identifier.'_old_*');
|
|
$this->assertEmpty($oldDirs, '_old 디렉토리가 정리되어야 합니다');
|
|
|
|
// 새 파일이 정상 복사되었는지 확인
|
|
$this->assertFileExists($targetPath.'/module.json');
|
|
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
|
|
}
|
|
|
|
/**
|
|
* 복사 실패 시 기존 대상 디렉토리가 보존되고 임시 디렉토리가 정리되는지 확인합니다.
|
|
* File Facade Spy 패턴으로 copy(개별 파일 복사)를 가로채어 예외를 발생시킵니다.
|
|
*/
|
|
public function test_copy_to_active_preserves_target_and_cleans_temp_on_copy_failure(): void
|
|
{
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/existing-file.txt', 'important data');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
|
|
// File Facade Spy: copy(개별 파일)를 임시 경로 복사 시에만 예외 발생
|
|
$originalFilesystem = File::getFacadeRoot();
|
|
$mock = Mockery::mock($originalFilesystem)->makePartial();
|
|
$mock->shouldReceive('copy')
|
|
->andReturnUsing(function (string $src, string $dst) use ($originalFilesystem) {
|
|
// 임시 경로(_updating_)로의 복사만 실패시킴
|
|
if (str_contains($dst, '_updating_')) {
|
|
throw new \RuntimeException('Disk full');
|
|
}
|
|
|
|
return $originalFilesystem->copy($src, $dst);
|
|
});
|
|
File::swap($mock);
|
|
|
|
try {
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
$this->fail('예외가 발생해야 합니다');
|
|
} catch (\RuntimeException $e) {
|
|
$this->assertEquals('Disk full', $e->getMessage());
|
|
} finally {
|
|
File::swap($originalFilesystem);
|
|
}
|
|
|
|
// 기존 대상 디렉토리가 보존되는지 확인
|
|
$this->assertDirectoryExists($targetPath);
|
|
$this->assertFileExists($targetPath.'/existing-file.txt');
|
|
$this->assertEquals('important data', File::get($targetPath.'/existing-file.txt'));
|
|
|
|
// _pending/ 하위의 임시 디렉토리가 정리되었는지 확인
|
|
$pendingPath = $this->modulesPath.'/_pending';
|
|
$identifier = basename($targetPath);
|
|
$tempDirs = glob($pendingPath.'/'.$identifier.'_updating_*');
|
|
$this->assertEmpty($tempDirs, '임시 디렉토리가 정리되어야 합니다');
|
|
}
|
|
|
|
// ========================================================================
|
|
// Windows 파일 잠금 대응 — rename 실패 시 파일 단위 폴백 (회귀 테스트)
|
|
//
|
|
// Windows 에서 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의 디렉토리
|
|
// 핸들, 열린 파일 등)이 하나라도 있으면 실패한다. 프로세스 식별/종료는 신뢰할
|
|
// 수 없으므로, rename 이 차단되면 파일 단위 연산(잠금의 영향을 받지 않음)으로
|
|
// 폴백하여 업데이트가 반드시 완료되어야 한다.
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 기존 활성 디렉토리의 rename(활성 → _old)이 차단될 때
|
|
* 파일 단위 제자리 동기화로 폴백하여 교체가 완료되는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_syncs_in_place_when_target_rename_is_blocked(): void
|
|
{
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
|
|
File::put($targetPath.'/old-file.txt', 'stale content');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
|
|
// File Facade Spy: 활성 디렉토리 자체의 rename 만 차단 (워처의 디렉토리 핸들 잠금 재현)
|
|
$originalFilesystem = File::getFacadeRoot();
|
|
$mock = Mockery::mock($originalFilesystem)->makePartial();
|
|
$mock->shouldReceive('moveDirectory')
|
|
->andReturnUsing(function (string $from, string $to, bool $overwrite = false) use ($originalFilesystem, $targetPath) {
|
|
if (rtrim($from, '/\\') === rtrim($targetPath, '/\\')) {
|
|
return false; // 활성 → _old rename 차단
|
|
}
|
|
|
|
return $originalFilesystem->moveDirectory($from, $to, $overwrite);
|
|
});
|
|
File::swap($mock);
|
|
|
|
try {
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
} finally {
|
|
File::swap($originalFilesystem);
|
|
}
|
|
|
|
// 새 버전 파일이 제자리에 반영되어야 함
|
|
$this->assertDirectoryExists($targetPath);
|
|
$this->assertFileExists($targetPath.'/src/Module.php');
|
|
$manifest = json_decode(File::get($targetPath.'/module.json'), true);
|
|
$this->assertEquals('1.0.0', $manifest['version'], '새 버전 manifest 로 교체되어야 함');
|
|
|
|
// 새 버전에 없는 잔존 파일은 제거되어야 함
|
|
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
|
|
|
|
// 임시 디렉토리가 정리되어야 함
|
|
$this->assertEmpty(glob($this->modulesPath.'/_pending/test-pending-mod_updating_*'));
|
|
}
|
|
|
|
/**
|
|
* 스테이징 디렉토리의 rename(_updating_ → 활성)이 차단될 때
|
|
* 파일 단위 복사로 폴백하여 교체가 완료되는지 확인합니다.
|
|
*
|
|
* 실사용 로그의 실패 지점: 활성 → _old 는 성공했으나, 방금 복사된 스테이징
|
|
* 트리를 워처가 이미 열어 _updating_ → 활성 rename 이 차단된 케이스.
|
|
*/
|
|
public function test_copy_to_active_copies_per_file_when_staging_rename_is_blocked(): void
|
|
{
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
|
|
File::put($targetPath.'/old-file.txt', 'stale content');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
|
|
// File Facade Spy: _updating_ → 활성 rename 만 차단
|
|
$originalFilesystem = File::getFacadeRoot();
|
|
$mock = Mockery::mock($originalFilesystem)->makePartial();
|
|
$mock->shouldReceive('moveDirectory')
|
|
->andReturnUsing(function (string $from, string $to, bool $overwrite = false) use ($originalFilesystem) {
|
|
if (str_contains($from, '_updating_')) {
|
|
return false; // 스테이징 → 활성 rename 차단
|
|
}
|
|
|
|
return $originalFilesystem->moveDirectory($from, $to, $overwrite);
|
|
});
|
|
File::swap($mock);
|
|
|
|
try {
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
} finally {
|
|
File::swap($originalFilesystem);
|
|
}
|
|
|
|
// 새 버전 파일이 반영되어야 함
|
|
$this->assertDirectoryExists($targetPath);
|
|
$this->assertFileExists($targetPath.'/src/Module.php');
|
|
$manifest = json_decode(File::get($targetPath.'/module.json'), true);
|
|
$this->assertEquals('1.0.0', $manifest['version'], '새 버전 manifest 로 교체되어야 함');
|
|
|
|
// 원자적 교체(활성 → _old)를 거쳤으므로 잔존 파일도 없어야 함
|
|
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
|
|
|
|
// 임시/백업 디렉토리가 정리되어야 함
|
|
$this->assertEmpty(glob($this->modulesPath.'/_pending/test-pending-mod_updating_*'));
|
|
$this->assertEmpty(glob($this->modulesPath.'/_pending/test-pending-mod_old_*'));
|
|
}
|
|
|
|
/**
|
|
* 실제 Windows 파일 잠금(열린 파일 핸들) 상황에서 교체가 완료되는지 확인합니다.
|
|
*
|
|
* Windows 는 하위 트리에 열린 파일이 있으면 그 경로의 모든 상위 디렉토리
|
|
* rename 을 차단한다. 열린 파일이라도 일반적인 공유 모드(read/write 공유)로
|
|
* 열려 있으면 내용 덮어쓰기는 허용되므로, 파일 단위 폴백은 성공해야 한다.
|
|
*/
|
|
public function test_copy_to_active_survives_open_file_handle_on_windows(): void
|
|
{
|
|
if (PHP_OS_FAMILY !== 'Windows') {
|
|
$this->markTestSkipped('Windows 파일 잠금 동작 검증 전용 테스트');
|
|
}
|
|
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
|
|
File::put($targetPath.'/old-file.txt', 'stale content');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
|
|
// 활성 디렉토리 내 파일에 핸들을 열어 둔 채 교체 시도 (디렉토리 rename 차단 재현)
|
|
$handle = fopen($targetPath.'/module.json', 'rb');
|
|
$this->assertIsResource($handle);
|
|
|
|
try {
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
} finally {
|
|
fclose($handle);
|
|
}
|
|
|
|
$this->assertFileExists($targetPath.'/src/Module.php');
|
|
$manifest = json_decode(File::get($targetPath.'/module.json'), true);
|
|
$this->assertEquals('1.0.0', $manifest['version'], '핸들이 열린 파일도 내용이 교체되어야 함');
|
|
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
|
|
}
|
|
|
|
/**
|
|
* 이전 실패 실행이 남긴 _updating_/_old_ 잔존 디렉토리가
|
|
* 다음 교체 시작 시 자동 정리되는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_cleans_leftover_swap_directories(): void
|
|
{
|
|
$pendingPath = $this->modulesPath.'/_pending';
|
|
File::ensureDirectoryExists($pendingPath.'/test-pending-mod_updating_stale123');
|
|
File::put($pendingPath.'/test-pending-mod_updating_stale123/junk.txt', 'junk');
|
|
File::ensureDirectoryExists($pendingPath.'/test-pending-mod_old_stale456');
|
|
File::put($pendingPath.'/test-pending-mod_old_stale456/junk.txt', 'junk');
|
|
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
File::ensureDirectoryExists($targetPath);
|
|
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
|
|
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
|
|
$this->assertDirectoryDoesNotExist($pendingPath.'/test-pending-mod_updating_stale123');
|
|
$this->assertDirectoryDoesNotExist($pendingPath.'/test-pending-mod_old_stale456');
|
|
$this->assertFileExists($targetPath.'/src/Module.php');
|
|
}
|
|
|
|
/**
|
|
* EXCLUDED_DIRECTORIES 상수에 node_modules가 포함되어 있는지 확인합니다.
|
|
*/
|
|
public function test_excluded_directories_contains_node_modules(): void
|
|
{
|
|
$this->assertContains('node_modules', ExtensionPendingHelper::EXCLUDED_DIRECTORIES);
|
|
}
|
|
|
|
/**
|
|
* copyToActive가 node_modules 디렉토리를 제외하는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_excludes_node_modules(): void
|
|
{
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
|
|
// 소스에 node_modules 디렉토리 생성
|
|
File::ensureDirectoryExists($sourcePath.'/node_modules/some-package');
|
|
File::put($sourcePath.'/node_modules/some-package/index.js', 'module.exports = {}');
|
|
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
|
|
// 일반 파일은 복사됨
|
|
$this->assertFileExists($targetPath.'/module.json');
|
|
$this->assertFileExists($targetPath.'/src/Module.php');
|
|
|
|
// node_modules는 제외됨
|
|
$this->assertDirectoryDoesNotExist($targetPath.'/node_modules');
|
|
}
|
|
|
|
/**
|
|
* 중첩 경로의 node_modules도 제외되는지 확인합니다.
|
|
*/
|
|
public function test_copy_to_active_excludes_nested_node_modules(): void
|
|
{
|
|
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
|
|
$targetPath = $this->modulesPath.'/test-pending-mod';
|
|
|
|
// 중첩 경로에 node_modules 생성
|
|
File::ensureDirectoryExists($sourcePath.'/resources/js/node_modules/lodash');
|
|
File::put($sourcePath.'/resources/js/node_modules/lodash/index.js', 'module.exports = {}');
|
|
File::put($sourcePath.'/resources/js/app.js', 'import _ from "lodash"');
|
|
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
|
|
|
|
// 일반 파일은 복사됨
|
|
$this->assertFileExists($targetPath.'/resources/js/app.js');
|
|
|
|
// 중첩 node_modules는 제외됨
|
|
$this->assertDirectoryDoesNotExist($targetPath.'/resources/js/node_modules');
|
|
}
|
|
|
|
/**
|
|
* isPending이 존재할 때 true를 반환하는지 확인합니다.
|
|
*/
|
|
public function test_is_pending_returns_true_when_exists(): void
|
|
{
|
|
$this->assertTrue(ExtensionPendingHelper::isPending($this->modulesPath, 'test-pending-mod'));
|
|
}
|
|
|
|
/**
|
|
* isPending이 미존재 시 false를 반환하는지 확인합니다.
|
|
*/
|
|
public function test_is_pending_returns_false_when_not_exists(): void
|
|
{
|
|
$this->assertFalse(ExtensionPendingHelper::isPending($this->modulesPath, 'nonexistent-mod'));
|
|
}
|
|
|
|
/**
|
|
* getPendingPath가 올바른 경로를 반환하는지 확인합니다.
|
|
*/
|
|
public function test_get_pending_path_returns_correct_path(): void
|
|
{
|
|
$path = ExtensionPendingHelper::getPendingPath($this->modulesPath, 'test-mod');
|
|
|
|
$this->assertStringEndsWith('_pending'.DIRECTORY_SEPARATOR.'test-mod', $path);
|
|
}
|
|
|
|
// ========================================================================
|
|
// createUpdateStagingPath() - 타임스탬프 기반 스테이징 경로 생성
|
|
// ========================================================================
|
|
|
|
/**
|
|
* createUpdateStagingPath()가 타임스탬프 형식의 디렉토리를 생성하는지 검증합니다.
|
|
*/
|
|
public function test_create_update_staging_path_creates_timestamped_directory(): void
|
|
{
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->modulesPath, 'test-mod');
|
|
$this->tempDirs[] = $stagingPath;
|
|
|
|
// 디렉토리가 생성되었는지 확인
|
|
$this->assertTrue(File::isDirectory($stagingPath));
|
|
|
|
// _pending 하위에 identifier_timestamp 형식인지 확인
|
|
$dirName = basename($stagingPath);
|
|
$this->assertMatchesRegularExpression('/^test-mod_\d{8}_\d{6}$/', $dirName);
|
|
|
|
// _pending 디렉토리 하위인지 확인
|
|
$parentDir = basename(dirname($stagingPath));
|
|
$this->assertEquals('_pending', $parentDir);
|
|
}
|
|
|
|
/**
|
|
* createUpdateStagingPath()가 매 호출마다 고유 경로를 생성하는지 검증합니다.
|
|
*/
|
|
public function test_create_update_staging_path_creates_unique_paths(): void
|
|
{
|
|
$path1 = ExtensionPendingHelper::createUpdateStagingPath($this->modulesPath, 'test-mod');
|
|
$this->tempDirs[] = $path1;
|
|
|
|
sleep(1);
|
|
|
|
$path2 = ExtensionPendingHelper::createUpdateStagingPath($this->modulesPath, 'test-mod');
|
|
$this->tempDirs[] = $path2;
|
|
|
|
$this->assertNotEquals($path1, $path2);
|
|
$this->assertTrue(File::isDirectory($path1));
|
|
$this->assertTrue(File::isDirectory($path2));
|
|
}
|
|
|
|
// ========================================================================
|
|
// stageForUpdate() - 소스를 스테이징으로 복사
|
|
// ========================================================================
|
|
|
|
/**
|
|
* stageForUpdate()가 소스 파일을 스테이징 디렉토리로 복사하는지 검증합니다.
|
|
*/
|
|
public function test_stage_for_update_copies_files_to_staging(): void
|
|
{
|
|
// 소스 디렉토리 생성
|
|
$sourceDir = $this->tempBase.DIRECTORY_SEPARATOR.'stage_source';
|
|
File::ensureDirectoryExists($sourceDir);
|
|
File::put($sourceDir.DIRECTORY_SEPARATOR.'module.json', '{"name": "test"}');
|
|
File::ensureDirectoryExists($sourceDir.DIRECTORY_SEPARATOR.'src');
|
|
File::put($sourceDir.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Provider.php', '<?php // test');
|
|
|
|
// 스테이징 경로 생성
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->modulesPath, 'test-stage');
|
|
$this->tempDirs[] = $stagingPath;
|
|
|
|
// 스테이징 실행
|
|
ExtensionPendingHelper::stageForUpdate($sourceDir, $stagingPath);
|
|
|
|
// 파일이 복사되었는지 확인
|
|
$this->assertTrue(File::exists($stagingPath.DIRECTORY_SEPARATOR.'module.json'));
|
|
$this->assertTrue(File::exists($stagingPath.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Provider.php'));
|
|
$this->assertEquals('{"name": "test"}', File::get($stagingPath.DIRECTORY_SEPARATOR.'module.json'));
|
|
}
|
|
|
|
/**
|
|
* stageForUpdate()가 존재하지 않는 소스에 대해 예외를 발생시키는지 검증합니다.
|
|
*/
|
|
public function test_stage_for_update_throws_on_missing_source(): void
|
|
{
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->modulesPath, 'test-missing');
|
|
$this->tempDirs[] = $stagingPath;
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->expectExceptionMessage('Source directory does not exist');
|
|
|
|
ExtensionPendingHelper::stageForUpdate('/non/existent/path', $stagingPath);
|
|
}
|
|
|
|
// ========================================================================
|
|
// cleanupStaging() - 스테이징 디렉토리 정리
|
|
// ========================================================================
|
|
|
|
/**
|
|
* cleanupStaging()이 스테이징 디렉토리를 완전히 삭제하는지 검증합니다.
|
|
*/
|
|
public function test_cleanup_staging_removes_directory(): void
|
|
{
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->modulesPath, 'test-cleanup');
|
|
|
|
// 파일 추가
|
|
File::put($stagingPath.DIRECTORY_SEPARATOR.'test.txt', 'content');
|
|
|
|
$this->assertTrue(File::isDirectory($stagingPath));
|
|
|
|
ExtensionPendingHelper::cleanupStaging($stagingPath);
|
|
|
|
$this->assertFalse(File::isDirectory($stagingPath));
|
|
}
|
|
|
|
/**
|
|
* cleanupStaging()이 존재하지 않는 경로에서도 예외 없이 처리되는지 검증합니다.
|
|
*/
|
|
public function test_cleanup_staging_does_not_fail_on_missing_path(): void
|
|
{
|
|
$nonExistentPath = $this->tempBase.DIRECTORY_SEPARATOR.'non_existent_staging';
|
|
|
|
// 예외 없이 정상 실행
|
|
ExtensionPendingHelper::cleanupStaging($nonExistentPath);
|
|
|
|
$this->assertFalse(File::isDirectory($nonExistentPath));
|
|
}
|
|
}
|