Files
Gnuboard7/tests/Unit/Helpers/FilePermissionHelperWritableDirectoryTest.php
T
HeuJung 85e266d361 fix(core,extensions): 확장 쓰기 경로 공통화 · HTMLPurifier 정의 캐시 storage 이전
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 쓰기 권한 불요를 명시)
2026-08-28 15:22:22 +09:00

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);
}
}