Files
Gnuboard7/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php
T
HeuJung 0a6ff23132 fix(core-update): --prune 시 public/storage symlink 삭제로 업로드 파일 404 되는 문제
--prune 은 릴리즈 소스에 없는 public/storage 런타임 symlink 를 orphan 으로
삭제해 업로드 파일이 404 되었다. 심층 방어 2층으로 차단한다.

- 층1(예방): FilePermissionHelper 에 preserveLinkPaths 화이트리스트 추가.
 applyUpdate 가 public 타깃에 ['storage'] 전달 → 매칭 orphan 링크/junction 만
 삭제 제외 (정밀 보호)
- 층2(복구): StorageLinkHelper::ensurePublicStorageLink 신규 정적 헬퍼.
 CoreUpdateCommand 종료 시점(정상+핸드오프) 호출로 부재/손상 링크 멱등 재생성
 (Windows junction 폴백). beta.5 migration 05 는 이 헬퍼로 위임(상위호환)
2026-07-16 12:42:54 +09:00

266 lines
12 KiB
PHP

<?php
namespace Tests\Unit\Extension\Helpers;
use App\Extension\Helpers\StorageLinkHelper;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* `StorageLinkHelper::ensurePublicStorageLink` 멱등 복구 동작 회귀 가드.
*
* 코어 업데이트 `--prune` 이후 `public/storage` symlink 가 부재/손상 상태로 남아 업로드
* 파일이 404 되던 결함(#43)의 종료 시점 방어 복구를 검증한다. `app()->setBasePath()` 로
* 격리된 fake base 를 구성하여 `public_path()`/`storage_path()` 가 임시 트리를 가리키게 한다.
*
* Windows: PHP `symlink()` 는 `SeCreateSymbolicLink` 권한 필요. 권한 부족 환경에서는
* junction 폴백이 작동하거나(둘 다 실패 시) markTestSkipped 로 건너뛴다.
*/
class StorageLinkHelperTest extends TestCase
{
private string $fakeBase;
private ?string $originalBasePath = null;
protected function setUp(): void
{
parent::setUp();
$this->originalBasePath = base_path();
$this->fakeBase = storage_path('app/testing/storage_link_helper_'.uniqid('', true));
File::ensureDirectoryExists($this->fakeBase);
app()->setBasePath($this->fakeBase);
}
protected function tearDown(): void
{
if ($this->originalBasePath !== null) {
app()->setBasePath($this->originalBasePath);
}
if (File::isDirectory($this->fakeBase)) {
// symlink/junction 은 target 을 따라가지 않도록 링크만 먼저 제거 (재귀 삭제 사고 차단).
// 백업으로 생긴 `storage.broken.*` 링크까지 포함해 public/ 하위 링크를 모두 정리한다.
$publicDir = $this->fakeBase.'/public';
if (is_dir($publicDir)) {
foreach (scandir($publicDir) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $publicDir.'/'.$entry;
if (is_link($path)) {
@unlink($path);
} elseif (PHP_OS_FAMILY === 'Windows' && ! is_file($path) && @readlink($path) !== false) {
// Windows junction — rmdir 로 링크만 제거 (target 미추적)
@rmdir($path);
}
}
}
File::deleteDirectory($this->fakeBase);
}
parent::tearDown();
}
/**
* symlink 생성 권한이 없으면 테스트를 건너뛴다 (Windows 일반 사용자).
*/
private function skipIfNoSymlink(): void
{
$probe = $this->fakeBase.'/symlink_probe';
$target = $this->fakeBase.'/symlink_probe_target';
File::ensureDirectoryExists($target);
if (! @symlink($target, $probe)) {
$this->markTestSkipped('symlink 생성 권한 부족 (Windows SeCreateSymbolicLink)');
}
@unlink($probe);
File::deleteDirectory($target);
}
#[Test]
public function 이미_정상_symlink_이면_no_op_으로_보존합니다(): void
{
$this->skipIfNoSymlink();
File::ensureDirectoryExists($this->fakeBase.'/public');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
@symlink($this->fakeBase.'/storage/app/public', $this->fakeBase.'/public/storage');
$targetBefore = readlink($this->fakeBase.'/public/storage');
StorageLinkHelper::ensurePublicStorageLink();
$this->assertTrue(is_link($this->fakeBase.'/public/storage'), '정상 symlink 는 보존되어야 한다');
$this->assertSame($targetBefore, readlink($this->fakeBase.'/public/storage'), 'target 미변경 (멱등)');
}
#[Test]
public function public_storage_부재_시_symlink_를_신규_생성합니다(): void
{
$this->skipIfNoSymlink();
File::ensureDirectoryExists($this->fakeBase.'/public');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
// public/storage 의도적 미생성 — prune 이 삭제한 시나리오
StorageLinkHelper::ensurePublicStorageLink();
$this->assertTrue(is_link($this->fakeBase.'/public/storage'), '부재 시 symlink 가 신규 생성되어야 한다');
$this->assertSame($this->fakeBase.'/storage/app/public', readlink($this->fakeBase.'/public/storage'));
}
#[Test]
public function 일반_디렉토리_손상_시_broken_백업_후_재생성합니다(): void
{
$this->skipIfNoSymlink();
File::ensureDirectoryExists($this->fakeBase.'/public/storage');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
// 손상 상태: public/storage 가 dereferenced 콘텐츠를 가진 일반 디렉토리
File::put($this->fakeBase.'/public/storage/uploaded.txt', 'dereferenced content');
StorageLinkHelper::ensurePublicStorageLink();
// 1) symlink 로 재생성
$this->assertTrue(is_link($this->fakeBase.'/public/storage'), 'symlink 로 재생성되어야 한다');
$this->assertSame($this->fakeBase.'/storage/app/public', readlink($this->fakeBase.'/public/storage'));
// 2) .broken.{timestamp} 백업 존재 + 콘텐츠 보존
$backups = array_values(array_filter(
scandir($this->fakeBase.'/public'),
fn ($e) => str_starts_with($e, 'storage.broken.'),
));
$this->assertCount(1, $backups, '.broken.{timestamp} 백업 1개 존재');
$this->assertFileExists($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt');
$this->assertSame('dereferenced content', File::get($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt'));
}
#[Test]
public function storage_app_public_source_부재_시_skip_합니다(): void
{
File::ensureDirectoryExists($this->fakeBase.'/public/storage');
File::put($this->fakeBase.'/public/storage/custom.txt', 'operator content');
// storage/app/public 의도적 미생성 — Laravel 표준 미사용 환경
StorageLinkHelper::ensurePublicStorageLink();
$this->assertFalse(is_link($this->fakeBase.'/public/storage'), 'source 부재 시 symlink 생성 안 함');
$this->assertTrue(is_dir($this->fakeBase.'/public/storage'), '일반 디렉토리 그대로 유지');
$this->assertFileExists($this->fakeBase.'/public/storage/custom.txt', '운영자 콘텐츠 보존');
// rename 백업도 없어야 함
$backups = array_values(array_filter(
scandir($this->fakeBase.'/public'),
fn ($e) => str_starts_with($e, 'storage.broken.'),
));
$this->assertEmpty($backups, 'source 부재 시 rename 백업도 생성되지 않아야 한다');
}
#[Test]
public function 로거_null_이면_upgrade_채널_폴백으로_예외없이_동작합니다(): void
{
$this->skipIfNoSymlink();
File::ensureDirectoryExists($this->fakeBase.'/public');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
// null 로거로 호출 — 폴백 채널로 예외 없이 완료되어야 함
StorageLinkHelper::ensurePublicStorageLink(null);
$this->assertTrue(is_link($this->fakeBase.'/public/storage'));
}
/**
* public/storage 부재 시 junction 폴백으로 재생성 (Windows — symlink 권한 불요).
*
* Windows 일반 사용자는 symlink 를 못 만들지만 junction(`mklink /J`)은 만들 수 있으므로,
* `createLink` 의 junction 폴백이 부재 케이스를 실제로 복구하는지 검증한다.
*/
#[Test]
public function public_storage_부재_시_junction_폴백으로_재생성합니다(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
$this->markTestSkipped('junction 은 Windows 전용 (mklink /J)');
}
File::ensureDirectoryExists($this->fakeBase.'/public');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
File::put($this->fakeBase.'/storage/app/public/uploaded.txt', 'user upload');
// public/storage 의도적 미생성
StorageLinkHelper::ensurePublicStorageLink();
$this->assertTrue(is_dir($this->fakeBase.'/public/storage'), '부재 시 junction 으로 재생성되어야 한다');
$this->assertFileExists($this->fakeBase.'/public/storage/uploaded.txt', 'junction 통과 시 업로드 파일 접근 가능');
}
/**
* 일반 디렉토리 손상 시 junction 폴백으로 재생성 + .broken 백업 (Windows).
*/
#[Test]
public function 일반_디렉토리_손상_시_junction_폴백으로_재생성합니다(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
$this->markTestSkipped('junction 은 Windows 전용 (mklink /J)');
}
File::ensureDirectoryExists($this->fakeBase.'/public/storage');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
File::put($this->fakeBase.'/public/storage/uploaded.txt', 'dereferenced content');
// 재생성 전 source 에 마커 파일을 넣어, 재생성된 링크가 source 를 통과하는지로 검증한다.
File::put($this->fakeBase.'/storage/app/public/marker.txt', 'from source');
StorageLinkHelper::ensurePublicStorageLink();
// junction 으로 재생성 → source(storage/app/public) 를 통과해 marker.txt 접근 가능
$this->assertTrue(is_dir($this->fakeBase.'/public/storage'), 'junction 으로 재생성되어야 한다');
$this->assertSame(
'from source',
File::get($this->fakeBase.'/public/storage/marker.txt'),
'재생성된 링크는 storage/app/public 을 가리켜야 한다 (source 마커 접근 가능)',
);
// .broken 백업 존재 + dereferenced 콘텐츠 보존
$backups = array_values(array_filter(
scandir($this->fakeBase.'/public'),
fn ($e) => str_starts_with($e, 'storage.broken.'),
));
$this->assertCount(1, $backups, '.broken 백업 1개 존재');
$this->assertFileExists($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt');
$this->assertSame('dereferenced content', File::get($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt'));
}
/**
* 이미 junction(정상 링크)이면 no-op — rename 백업하지 않고 보존 (Windows).
*/
#[Test]
public function 이미_junction_이면_no_op_으로_보존합니다(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
$this->markTestSkipped('junction 은 Windows 전용 (mklink /J)');
}
File::ensureDirectoryExists($this->fakeBase.'/public');
File::ensureDirectoryExists($this->fakeBase.'/storage/app/public');
File::put($this->fakeBase.'/storage/app/public/uploaded.txt', 'user upload');
exec('cmd /c mklink /J '.escapeshellarg($this->fakeBase.'/public/storage').' '.escapeshellarg($this->fakeBase.'/storage/app/public').' 2>&1', $out, $code);
if ($code !== 0 || ! is_dir($this->fakeBase.'/public/storage')) {
$this->markTestSkipped('junction 생성 실패: '.implode("\n", $out));
}
StorageLinkHelper::ensurePublicStorageLink();
// .broken 백업이 생기지 않아야 함 (no-op)
$backups = array_values(array_filter(
scandir($this->fakeBase.'/public'),
fn ($e) => str_starts_with($e, 'storage.broken.'),
));
$this->assertEmpty($backups, '이미 정상 junction 은 rename 백업 없이 보존되어야 한다');
$this->assertFileExists($this->fakeBase.'/public/storage/uploaded.txt');
}
}