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 쓰기 권한 불요를 명시)
178 lines
7.4 KiB
PHP
178 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Extension;
|
|
|
|
use Composer\Autoload\ClassLoader;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 확장 vendor(제3자 composer 패키지)가 PHPUnit 프로세스에서도 오토로드되는지에 대한 회귀 테스트.
|
|
*
|
|
* `autoload-extensions.php` 의 `vendor_autoloads` 를 소비하는 진입점은 `public/index.php` 와
|
|
* `artisan` 뿐이었다. 그래서 테스트 프로세스에서는 확장 전용 패키지가 오토로드되지 않았고,
|
|
* 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였다 — 아무도 그 경로를 테스트하지 않는 동안
|
|
* 이 결손은 드러나지 않는다 (공개 #125 에서 처음 드러났다).
|
|
*
|
|
* `tests/bootstrap.php` 가 그 등록을 하되, 확장 vendor 의 `autoload.php` 를 그대로 require
|
|
* 하지 않고 제3자 항목만 골라 `vendorDir` 없는 로더로 등록한다. 그렇게 하지 않으면 두 가지가
|
|
* 조용히 깨지며, 그 둘이 이 테스트의 나머지 두 축이다.
|
|
*/
|
|
class ExtensionVendorAutoloadInTestsTest extends TestCase
|
|
{
|
|
/**
|
|
* 확장 vendor 의 제3자 클래스가 테스트 프로세스에서 오토로드된다.
|
|
*
|
|
* 모집단 주의: 확장 활성 디렉토리와 그 `vendor/` 는 gitignore 대상이라, 확장 composer
|
|
* 설치를 하지 않은 체크아웃에서는 모집단이 0 이 되어 이 단언이 공허하게 통과한다.
|
|
* 그 상태에서도 아래 두 축(자기 네임스페이스 비하이재킹 / base path 무결성)은 유효하다.
|
|
*/
|
|
#[Test]
|
|
public function extension_third_party_packages_are_autoloadable(): void
|
|
{
|
|
$unloadable = [];
|
|
$sampled = [];
|
|
|
|
foreach ($this->discoverExtensionVendorClassmaps() as $classmapFile) {
|
|
foreach (require $classmapFile as $fqcn => $path) {
|
|
// 확장 자기 심볼은 애초에 등록 대상이 아니고, Composer 자신의 클래스는 루트 오토로더가
|
|
// 이미 들고 있어 우리 등록이 없어도 통과한다 — 표본으로 쓰면 공허한 단언이 된다.
|
|
if ($this->isExtensionOwnSymbol($fqcn) || str_starts_with($fqcn, 'Composer\\')) {
|
|
continue;
|
|
}
|
|
|
|
$sampled[] = $fqcn;
|
|
|
|
if (! class_exists($fqcn) && ! interface_exists($fqcn) && ! trait_exists($fqcn)) {
|
|
$unloadable[] = $fqcn;
|
|
}
|
|
|
|
// 확장마다 대표 1건만 본다 — 맵 전체를 로드하면 스위트가 느려지고,
|
|
// 오토로더가 걸렸는지 여부는 대표 1건으로 판정된다.
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$unloadable,
|
|
'확장 vendor 의 제3자 클래스가 테스트 프로세스에서 오토로드되지 않았습니다 — '
|
|
.'tests/bootstrap.php 의 확장 vendor 등록을 확인하세요. 표본: '
|
|
.implode(', ', $sampled)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 확장 자기 네임스페이스가 vendor 오토로더에 하이재킹되지 않는다.
|
|
*
|
|
* 확장 vendor 의 Composer 오토로더는 확장 자신의 PSR-4 를 **활성** 디렉토리로 매핑하고
|
|
* 자신을 prepend 로 등록한다. 그것을 그대로 쓰면 `tests/bootstrap.php` 가 앞서 prepend 한
|
|
* `_bundled` 등록을 이겨서, 테스트가 `_bundled` 가 아니라 활성 디렉토리 사본을 검증하게
|
|
* 된다 — `_bundled` 에서만 작업한다는 규율이 오류 없이 깨지는 형태다.
|
|
*/
|
|
#[Test]
|
|
public function bundled_extension_classes_are_not_hijacked_to_the_active_directory(): void
|
|
{
|
|
$hijacked = [];
|
|
|
|
foreach (glob(base_path('modules/_bundled/*/module.php')) ?: [] as $manifest) {
|
|
$identifier = basename(dirname($manifest));
|
|
$class = $this->extensionEntryClass('Modules', $identifier);
|
|
|
|
if ($class === null || ! class_exists($class)) {
|
|
continue;
|
|
}
|
|
|
|
$file = (new \ReflectionClass($class))->getFileName();
|
|
|
|
if ($file !== false && ! str_contains(str_replace('\\', '/', $file), '/modules/_bundled/')) {
|
|
$hijacked[$identifier] = $file;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$hijacked,
|
|
'_bundled 확장의 클래스가 활성 디렉토리에서 로드되었습니다 — 확장 vendor 오토로더가 '
|
|
.'자기 PSR-4 를 등록하고 있습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 등록된 Composer 로더 목록의 첫 항목이 루트 vendor 로 남는다.
|
|
*
|
|
* Composer 로더는 `vendorDir` 를 가지면 `ClassLoader::getRegisteredLoaders()` 맨 앞에
|
|
* 자신을 넣는데, `Illuminate\Foundation\Testing\TestCase::createApplication()` 이
|
|
* `Application::inferBasePath()` 로 그 첫 항목에서 base path 를 유추한다. 확장 vendor 의
|
|
* 로더가 그 자리를 차지하면 이후 테스트의 앱 부팅이 `modules/{id}/bootstrap/app.php` 를
|
|
* 찾다 실패한다. (운영 진입점은 basePath 를 명시 전달하므로 영향이 없다.)
|
|
*/
|
|
#[Test]
|
|
public function registered_loader_list_still_points_at_the_project_root(): void
|
|
{
|
|
$registered = array_keys(ClassLoader::getRegisteredLoaders());
|
|
|
|
$this->assertNotEmpty($registered, '등록된 Composer 로더가 없습니다.');
|
|
|
|
$this->assertSame(
|
|
realpath(base_path('vendor')),
|
|
realpath($registered[0]),
|
|
'Composer 로더 목록의 첫 항목이 루트 vendor 가 아닙니다 — 확장 vendor 로더가 '
|
|
.'vendorDir 를 갖고 등록되어 Application::inferBasePath() 를 오염시킵니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 확장(모듈/플러그인)의 vendor classmap 파일 경로를 모읍니다.
|
|
*
|
|
* @return array<int, string> classmap 파일 절대 경로 목록
|
|
*/
|
|
private function discoverExtensionVendorClassmaps(): array
|
|
{
|
|
$found = [];
|
|
|
|
foreach (['modules', 'plugins'] as $type) {
|
|
foreach ([$type, $type.'/_bundled'] as $dir) {
|
|
foreach (glob(base_path($dir.'/*/vendor/composer/autoload_classmap.php')) ?: [] as $file) {
|
|
$found[] = $file;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* 확장 자신의 네임스페이스에 속하는 심볼인지 판정합니다.
|
|
*
|
|
* @param string $symbol FQCN 또는 PSR-4 접두사
|
|
* @return bool 확장 자기 심볼이면 true
|
|
*/
|
|
private function isExtensionOwnSymbol(string $symbol): bool
|
|
{
|
|
return str_starts_with($symbol, 'Modules\\') || str_starts_with($symbol, 'Plugins\\');
|
|
}
|
|
|
|
/**
|
|
* 확장 식별자로부터 진입 클래스 FQCN 을 조립합니다.
|
|
*
|
|
* @param string $root 네임스페이스 루트 (Modules|Plugins)
|
|
* @param string $identifier 확장 식별자 (vendor-name)
|
|
* @return string|null FQCN. 식별자 형식이 아니면 null
|
|
*/
|
|
private function extensionEntryClass(string $root, string $identifier): ?string
|
|
{
|
|
$parts = explode('-', $identifier);
|
|
|
|
if (count($parts) < 2) {
|
|
return null;
|
|
}
|
|
|
|
$vendor = ucfirst($parts[0]);
|
|
$name = str_replace('_', '', ucwords($parts[1], '_'));
|
|
$entry = $root === 'Modules' ? 'Module' : 'Plugin';
|
|
|
|
return $root.'\\'.$vendor.'\\'.$name.'\\'.$entry;
|
|
}
|
|
}
|