https://github.com/gnuboard/g7/issues/125 — 상품 상세설명을 HTML 로 저장할 때 HTMLPurifier 가 모듈 vendor 폴더 안에 정의 캐시를 만들려다 실패해 저장이 매번 500 으로 끝나던 문제를 고친다. vendor 를 읽기 전용으로 두는 표준 배포에서 그 쓰기는 예외가 아니라 PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시킨다. 캐시는 설정 해시당 1회만 기록되므로 캐시가 영영 생기지 않아 재시도해도 같은 결과였다. 캐시 경로를 storage 아래로 옮기고, 그 경로마저 확보하지 못하면 캐시만 끄고 정화는 그대로 수행한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 전자의 실패가 후자를 건너뛰게 만들면 안 된다. 저장은 성공하므로 운영자에게 도달하는 흔적이 로그 하나뿐이라 error 수준으로 남긴다 (출하 기본 로그 수준이 error 라 warning 은 기본 설치 상태에서 파일에 남지 않는다). 그 과정에서 갈라져 있던 두 축을 코어 한 곳으로 모은다. - 쓰기 디렉토리 확보: 억제 생성·chmod·setgid·소유권 상속·쓰기 판정 절차가 정적 게시와 정의 캐시 두 곳에 서로 다른 하드닝으로 복제돼 있었다(억제 mkdir·setgid·clearstatcache 가 사본마다 한쪽씩 빠져 있었다). FilePermissionHelper 의 ensureWritableDirectory 와 hardenDirectory 로 통합하고, 실패 사유는 out 파라미터로 올려 정책(조용한 성능 저하 대 시끄러운 실패)은 호출부가 정하게 둔다. - 확장 저장 경로: storage_path('app/modules/…') 손조립이 30곳에 흩어져 있어 테스트 격리 분기를 넣으려면 사본마다 복제해야 했고, 한 곳만 빠뜨려도 그 확장의 테스트가 운영 설정 파일을 덮어쓴다. 디스크 root 를 단일 출처로 읽는 ExtensionStoragePath 로 전환하고 테스트 분기는 config/filesystems.php 한 줄에서 끝낸다. 함께 고친 것 - 테스트가 운영 라우트 캐시로 부팅해 확장 allowlist 가 라우트 축에서 통째로 무력화되던 문제. 삭제가 아니라 경로를 돌린다 — 라우트 캐시는 확장 작업 전까지 재생성되지 않아, 삭제하면 운영 사이트가 그때까지 라우트 파일 스캔 경로로 떨어진다. - PHPUnit 프로세스가 확장 vendor 의 제3자 composer 패키지를 오토로드하지 않아 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였던 문제. 확장 자신의 오토로더를 그대로 쓰면 활성 디렉토리가 _bundled 를 이기고 base path 유추까지 깨지므로, 생성된 맵에서 제3자 항목만 골라 별도 로더로 등록한다. - 게시 폴더가 setgid 를 갖지 않아, 명령줄과 웹이 번갈아 만든 하위 폴더를 다른 쪽이 쓰지 못하던 문제. - 관리자 템플릿이 HTML 정화 라이브러리를 직접 지정하지 않아 전이 의존으로 딸려온 구버전이 쓰이던 문제. 동반 산출물 - 규정 표(·AGENTS.md) 6행 + storage-driver/service-repository/testing-guide 문서 - audit 룰 2종 + coverage 6항목. 저장소가 이미 전량 전환돼 전수 실행이 공허 통과하므로 판정식은 픽스처 36건이 잠근다 - INSTALL.md 에 설치 후 파일 권한 절 추가 (vendor 쓰기 권한 불요를 명시)
187 lines
6.9 KiB
PHP
187 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Helpers;
|
|
|
|
use App\Extension\Helpers\FilePermissionHelper;
|
|
use Illuminate\Support\Facades\File;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* `FilePermissionHelper::ensureWritableDirectory()` 단위 테스트.
|
|
*
|
|
* 이 프리미티브의 존재 이유는 "확보 실패가 다시 500 을 내지 않는 것" 이다. 제3자 라이브러리에
|
|
* 쓰기 경로를 지정하는 목적 자체가 vendor 쓰기 실패로 인한 500 을 막는 것인데, 확보 지점이
|
|
* PHP 경고를 내면 Laravel `HandleExceptions` 가 이를 `ErrorException` 으로 승격시켜 같은 500 이
|
|
* 다른 줄에서 그대로 난다 (공개 #125 의 2차 결함).
|
|
*
|
|
* 그래서 실패 케이스는 반환값만 보지 않고 **경고가 나지 않았다는 것까지** 단언한다 — 경고를
|
|
* 예외로 바꾸는 핸들러를 씌운 채로 호출해, 승격이 일어나면 테스트가 실패하도록 만든다.
|
|
*/
|
|
class FilePermissionHelperWritableDirectoryTest extends TestCase
|
|
{
|
|
/** 테스트가 만든 경로 (tearDown 정리 대상) */
|
|
private string $root;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->root = storage_path('framework/testing/writable-dir-'.getmypid());
|
|
File::deleteDirectory($this->root);
|
|
File::ensureDirectoryExists($this->root);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
File::deleteDirectory($this->root);
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* PHP 경고를 `ErrorException` 으로 승격시키는 핸들러 아래에서 콜백을 실행합니다.
|
|
*
|
|
* `Illuminate\Foundation\Bootstrap\HandleExceptions::handleError()` 와 **동형**이어야 한다 —
|
|
* 그 핸들러는 `error_reporting() & $level` 을 확인하므로 `@` 로 억제된 진단은 승격시키지
|
|
* 않는다. 이 검사를 빠뜨리면 억제를 존중하는 올바른 코드까지 실패로 보고해, 실제 운영에서는
|
|
* 나지 않는 500 을 있다고 말하게 된다.
|
|
*
|
|
* 따라서 이 헬퍼가 잡아내는 것은 정확히 하나다 — **억제되지 않은 경고가 새어 나가는가.**
|
|
* `File::makeDirectory(..., force: true)` 를 `ensureDirectoryExists()` 로 되돌리면 여기서 걸린다.
|
|
*
|
|
* @param \Closure $callback 실행할 콜백
|
|
* @return mixed 콜백 반환값
|
|
*/
|
|
private function withWarningsAsExceptions(\Closure $callback): mixed
|
|
{
|
|
set_error_handler(static function (int $level, string $message, string $file = '', int $line = 0): bool {
|
|
if (error_reporting() & $level) {
|
|
throw new \ErrorException($message, 0, $level, $file, $line);
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
try {
|
|
return $callback();
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 없는 디렉토리를 만들고 쓰기 가능으로 판정합니다.
|
|
*/
|
|
public function test_creates_missing_directory_and_reports_writable(): void
|
|
{
|
|
$target = $this->root.'/created/deeply/nested';
|
|
|
|
$this->assertFalse(is_dir($target));
|
|
|
|
$result = FilePermissionHelper::ensureWritableDirectory($target, 0775, $failure);
|
|
|
|
$this->assertTrue($result);
|
|
$this->assertNull($failure);
|
|
$this->assertDirectoryExists($target);
|
|
$this->assertTrue(is_writable($target));
|
|
}
|
|
|
|
/**
|
|
* 이미 있는 쓰기 가능 디렉토리는 그대로 통과합니다 (재생성하지 않음).
|
|
*/
|
|
public function test_returns_true_for_existing_writable_directory(): void
|
|
{
|
|
$target = $this->root.'/existing';
|
|
File::ensureDirectoryExists($target);
|
|
File::put($target.'/keep.txt', 'keep');
|
|
|
|
$this->assertTrue(FilePermissionHelper::ensureWritableDirectory($target, 0775, $failure));
|
|
$this->assertNull($failure);
|
|
|
|
// 내용이 보존됐다 = 지우고 다시 만들지 않았다.
|
|
$this->assertFileExists($target.'/keep.txt');
|
|
}
|
|
|
|
/**
|
|
* 같은 이름의 파일이 자리를 차지하면 경고 없이 실패 사유를 돌려줍니다.
|
|
*/
|
|
public function test_reports_occupied_by_file_without_raising_warning(): void
|
|
{
|
|
$target = $this->root.'/occupied';
|
|
File::put($target, 'not a directory');
|
|
|
|
$failure = null;
|
|
$result = $this->withWarningsAsExceptions(
|
|
function () use ($target, &$failure) {
|
|
return FilePermissionHelper::ensureWritableDirectory($target, 0775, $failure);
|
|
}
|
|
);
|
|
|
|
$this->assertFalse($result);
|
|
$this->assertSame('occupied_by_file', $failure['reason']);
|
|
$this->assertSame($target, $failure['path']);
|
|
}
|
|
|
|
/**
|
|
* 경로 중간이 파일이라 생성이 불가능해도 경고 없이 실패 사유를 돌려줍니다.
|
|
*
|
|
* 상위(`$this->root`)는 쓰기 가능하므로 `ancestor_not_writable` 로 걸러지지 않고 실제
|
|
* `mkdir` 까지 가서 실패하는 경로다 — 억제되지 않은 `mkdir` 이었다면 여기서 경고가 난다.
|
|
*/
|
|
public function test_reports_create_failed_when_a_path_segment_is_a_file(): void
|
|
{
|
|
$blocker = $this->root.'/blocker';
|
|
File::put($blocker, 'file in the middle of the path');
|
|
|
|
$target = $blocker.'/sub/cache';
|
|
|
|
$failure = null;
|
|
$result = $this->withWarningsAsExceptions(
|
|
function () use ($target, &$failure) {
|
|
return FilePermissionHelper::ensureWritableDirectory($target, 0775, $failure);
|
|
}
|
|
);
|
|
|
|
$this->assertFalse($result);
|
|
$this->assertSame('create_failed', $failure['reason']);
|
|
$this->assertSame($target, $failure['path']);
|
|
$this->assertDirectoryDoesNotExist($target);
|
|
}
|
|
|
|
/**
|
|
* 실재하는 최근접 상위를 찾아 올라갑니다.
|
|
*/
|
|
public function test_nearest_existing_ancestor_walks_up_to_first_existing_directory(): void
|
|
{
|
|
$this->assertSame(
|
|
rtrim($this->root, '/\\'),
|
|
rtrim((string) FilePermissionHelper::nearestExistingAncestor($this->root.'/a/b/c'), '/\\')
|
|
);
|
|
|
|
$existing = $this->root.'/present';
|
|
File::ensureDirectoryExists($existing);
|
|
|
|
$this->assertSame(
|
|
rtrim($existing, '/\\'),
|
|
rtrim((string) FilePermissionHelper::nearestExistingAncestor($existing.'/child'), '/\\')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 정합화는 대상이 없어도 예외·경고를 내지 않습니다.
|
|
*
|
|
* `hardenDirectory()` 는 `@` 억제 chmod 와 소유권 상속만 수행하므로, 경합으로 대상이
|
|
* 사라진 상황에서도 호출자에게 실패를 던지지 않아야 한다.
|
|
*/
|
|
public function test_harden_directory_is_silent_when_target_is_absent(): void
|
|
{
|
|
$missing = $this->root.'/vanished';
|
|
|
|
$this->withWarningsAsExceptions(function () use ($missing): void {
|
|
FilePermissionHelper::hardenDirectory($missing, 0775);
|
|
});
|
|
|
|
$this->assertDirectoryDoesNotExist($missing);
|
|
}
|
|
}
|