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 쓰기 권한 불요를 명시)
This commit is contained in:
@@ -416,6 +416,25 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
|
||||
|
||||
### 제3자 라이브러리는 쓰기 경로를 지정받는다
|
||||
|
||||
제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 시스템 temp 에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 **매 요청이 같은 실패를 반복**한다 — 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다 (공개 #125).
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 제3자 라이브러리를 기본 설정 그대로 인스턴스화 | 캐시·임시파일 경로를 `ExtensionStoragePath::module($id, 'cache/…')` 로 명시 — 기본값은 **라이브러리 자기 설치 폴더**다 |
|
||||
| 쓰기 경로만 지정하고 디렉토리 생성은 라이브러리에 맡김 | `FilePermissionHelper::ensureWritableDirectory()` 로 **먼저 확보한다** — 라이브러리는 대개 하위 디렉토리만 만들고, base 가 없으면 경고만 내고 끝난다 |
|
||||
| 확보 절차(억제 생성·chmod·setgid·소유권·쓰기 판정)를 호출부가 자기 안에 복사 | 코어 프리미티브 한 곳에서 수행 — 사본은 서로 다른 하드닝을 갖고 갈라진다(실제로 억제 mkdir·setgid·`clearstatcache` 가 사본마다 한쪽씩 빠져 있었다) |
|
||||
| 확장 저장 경로를 `storage_path('app/modules/…')` 로 직접 조립 | `ExtensionStoragePath::{module,plugin}()` — 디스크 root 가 단일 출처이고 테스트 환경을 인지하므로, 확장이 `runningUnitTests()` 분기를 복사하지 않는다. 복사본은 한 곳만 빠뜨려도 그 확장의 테스트가 **운영 설정 파일을 덮어쓴다** |
|
||||
| 캐시 쓰기 실패를 그대로 500 으로 흘림 | 캐시는 성능 장치다 — 확보 실패 시 캐시만 끄고 본래 기능은 계속한다. **정화·검증 자체를 건너뛰는 폴백은 금지** |
|
||||
| 폴백 통지를 `Log::warning` 으로 남김 | `Log::error` — 출하 기본 로그 수준(`config/settings/defaults.json` 의 `log_level`)이 `error` 라 `warning` 은 기본 설치 상태에서 파일에 기록되지 않는다. 기능은 성공하므로 그 통지가 유일한 흔적이다 |
|
||||
|
||||
확보 프리미티브는 **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()` 는 `mkdir()` 을 억제 없이 부르므로 생성 실패가 `E_WARNING` → `ErrorException` 으로 승격되어, 막으려던 500 이 다른 줄에서 그대로 난다. 실패는 `bool` 과 사유(`occupied_by_file` / `ancestor_not_writable` / `create_failed` / `not_writable`)로 올라오고, 그 사유를 통지에 실어 운영자가 고칠 대상을 지목한다.
|
||||
|
||||
경로는 `ExtensionStoragePath` 가 해석한다. `getBasePath('cache')` 는 `Storage::disk()->path()` 위임이라 비로컬 디스크(S3 등)에서 파일시스템 경로가 아니게 되는데, 그러면 라이브러리가 상대경로를 CWD 기준으로 해석해 **조용히 엉뚱한 곳에 쓴다** — 지금 결함보다 나쁘다. 대부분의 정의 캐시는 `file_put_contents` 로 쓰는 로컬 전용 장치다.
|
||||
|
||||
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때"
|
||||
|
||||
### 확장·템플릿 구동 에셋은 자체 제공한다
|
||||
|
||||
브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 **예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다.** 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
|
||||
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
|
||||
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
||||
|
||||
+17
@@ -379,6 +379,23 @@ http://도메인/install
|
||||
|
||||
> 사용자 페이지는 사용자 템플릿이 설치되어 있어야 접근할 수 있습니다. 인스톨러에서 사용자 템플릿을 함께 설치하거나, 관리자 페이지에서 템플릿을 먼저 설치해 주세요.
|
||||
|
||||
### 파일 권한 (설치 후 확인)
|
||||
|
||||
POSIX 권한 모델 (Linux/macOS/BSD) 환경에서 웹 서버 실행 계정이 쓸 수 있어야 하는 위치는 다음과 같습니다. Windows 환경에서는 해당하지 않습니다.
|
||||
|
||||
| 위치 | 필요한 이유 |
|
||||
|------|------------|
|
||||
| `storage/` · `bootstrap/cache` | 애플리케이션 동작에 항상 필요 (로그·캐시·세션·업로드·런타임 캐시) |
|
||||
| `modules/` · `plugins/` · `templates/` · `public/build` | 관리자 화면에서 확장(모듈/플러그인/템플릿)을 설치·업데이트·삭제할 때 필요 |
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data storage bootstrap/cache modules plugins templates public/build
|
||||
```
|
||||
|
||||
**`vendor/` 에는 쓰기 권한이 필요하지 않습니다.** 확장의 `vendor/` 는 설치·업데이트 시점에만 기록되며, 애플리케이션이 동작하면서 만드는 런타임 캐시는 모두 `storage/` 아래에 기록됩니다. 명령줄로 설치·업데이트를 수행한다면 그 계정만 쓸 수 있으면 됩니다.
|
||||
|
||||
전체 권한 모델(그룹 공유 방식 A / 소유자 통일 방식 B / ACL 방식 C 와 umask 운영)은 [docs/requirements.md](docs/requirements.md) "파일 권한 및 umask 운영 방식" 절을 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 업그레이드
|
||||
|
||||
+1
-1
@@ -519,8 +519,8 @@ cp .env.example .env
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
|
||||
@@ -533,8 +533,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
|
||||
@@ -387,6 +387,122 @@ class FilePermissionHelper
|
||||
static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 존재하는 디렉토리의 퍼미션·소유권을 umask 와 무관하게 정합화합니다.
|
||||
*
|
||||
* `File::ensureDirectoryExists($dir, 0775)` 등 생성 API 의 mode 인자는 **umask 로 깎인다** —
|
||||
* umask 022 환경에서는 0775 요청이 0755 로 만들어져 그룹 공유(웹 계정)가 쓸 수 없다.
|
||||
* 명시 `chmod` 로 umask 를 무력화하고, POSIX 에서는 setgid 를 세워 그 아래에 만들어지는
|
||||
* 하위 디렉토리가 그룹을 상속하게 한다. setgid 가 없으면 CLI(스케줄러/큐)가 먼저 만든
|
||||
* 하위 디렉토리를 웹 프로세스가 쓰지 못한다. Windows 에는 setgid 개념이 없어 제외한다.
|
||||
*
|
||||
* 마지막으로 부모 소유권을 상속시켜 sudo/CLI 계정 고정을 막는다.
|
||||
*
|
||||
* @param string $path 대상 디렉토리 절대 경로
|
||||
* @param int $mode 적용할 퍼미션 (예: 0775)
|
||||
*/
|
||||
public static function hardenDirectory(string $path, int $mode = 0775): void
|
||||
{
|
||||
@chmod($path, $mode);
|
||||
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
@chmod($path, $mode | 02000);
|
||||
}
|
||||
|
||||
static::inheritOwnershipFromParent($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 가능한 디렉토리를 확보합니다 — 없으면 만들고, 권한을 정합화한 뒤 실제 쓰기 가능 여부를 판정합니다.
|
||||
*
|
||||
* 제3자 라이브러리에 넘길 캐시·임시 디렉토리처럼 "확보하지 못하면 그 기능만 끄면 되는"
|
||||
* 자리를 위한 프리미티브다. **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()`
|
||||
* 는 `mkdir()` 을 억제 없이 호출하므로 생성 실패가 `E_WARNING` 으로 나오고 Laravel
|
||||
* `HandleExceptions` 가 이를 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 쓰기 경로를
|
||||
* 지정하는 목적 자체가 그 500 을 막는 것이므로, 확보 실패가 다시 500 을 내면 무의미하다.
|
||||
*
|
||||
* 실패 정책은 호출부가 정한다 — 조용히 성능 저하로 이어갈지(정의 캐시), 시끄럽게 실패로
|
||||
* 처리할지(정적 게시)가 자리마다 다르기 때문이다. 사유는 `$failure` out 파라미터로 올린다.
|
||||
*
|
||||
* @param string $path 확보할 디렉토리 절대 경로
|
||||
* @param int $mode 생성 시 적용할 퍼미션
|
||||
* @param array{reason: string, path: string}|null $failure out — 실패 사유와 그 대상 경로.
|
||||
* reason 은 `occupied_by_file`(경로가 파일로 점유) /
|
||||
* `ancestor_not_writable`(실재하는 최근접 상위가 쓰기 불가) /
|
||||
* `create_failed`(생성 실패) / `not_writable`(존재하나 쓰기 불가)
|
||||
* @return bool 확보 성공 여부
|
||||
*/
|
||||
public static function ensureWritableDirectory(string $path, int $mode = 0775, ?array &$failure = null): bool
|
||||
{
|
||||
$failure = null;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
// 같은 이름의 파일이 자리를 차지하면 mkdir 이 경고를 낸다 — 먼저 걸러낸다.
|
||||
if (file_exists($path)) {
|
||||
$failure = ['reason' => 'occupied_by_file', 'path' => $path];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상위가 쓰기 불가라면 생성 자체가 불가능하다. 여기서 끊어야 호출부가 "무엇을
|
||||
// 고쳐야 하는지"(대상 디렉토리가 아니라 그 상위)를 운영자에게 지목할 수 있다.
|
||||
$ancestor = static::nearestExistingAncestor($path);
|
||||
|
||||
if ($ancestor === null || ! is_writable($ancestor)) {
|
||||
$failure = ['reason' => 'ancestor_not_writable', 'path' => $ancestor ?? dirname($path)];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// force 인자로 경고를 억제한다. 실패는 아래 판정이 흡수한다.
|
||||
File::makeDirectory($path, $mode, true, true);
|
||||
|
||||
if (is_dir($path)) {
|
||||
static::hardenDirectory($path, $mode);
|
||||
}
|
||||
}
|
||||
|
||||
// 방금 만든 경로의 stat 은 캐시돼 있을 수 있다 — 판정 전에 비운다.
|
||||
clearstatcache(true, $path);
|
||||
|
||||
if (! is_dir($path)) {
|
||||
$failure = ['reason' => 'create_failed', 'path' => $path];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_writable($path)) {
|
||||
$failure = ['reason' => 'not_writable', 'path' => $path];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
|
||||
*
|
||||
* @param string $path 기준 경로
|
||||
* @return string|null 실재하는 최근접 상위 (루트까지 없으면 null)
|
||||
*/
|
||||
public static function nearestExistingAncestor(string $path): ?string
|
||||
{
|
||||
$current = dirname($path);
|
||||
|
||||
while (! File::isDirectory($current)) {
|
||||
$parent = dirname($current);
|
||||
|
||||
if ($parent === $current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $parent;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -406,10 +407,10 @@ class SettingsMigrator
|
||||
private function getSettingsDir(): string
|
||||
{
|
||||
if ($this->type === 'module') {
|
||||
return storage_path('app/modules/'.$this->identifier.'/settings');
|
||||
return ExtensionStoragePath::module($this->identifier, 'settings');
|
||||
}
|
||||
|
||||
return storage_path('app/plugins/'.$this->identifier.'/settings');
|
||||
return ExtensionStoragePath::plugin($this->identifier, 'settings');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ use App\Models\Template;
|
||||
use App\Providers\CoreServiceProvider;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Support\AssetUrl;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -1791,7 +1792,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
}
|
||||
|
||||
$identifier = $module->getIdentifier();
|
||||
$settingsDir = storage_path('app/modules/'.$identifier.'/settings');
|
||||
$settingsDir = ExtensionStoragePath::module($identifier, 'settings');
|
||||
|
||||
// 이미 환경설정 디렉토리가 있고 파일이 있으면 스킵 (재설치 시 덮어쓰기 방지)
|
||||
if (File::isDirectory($settingsDir) && count(File::files($settingsDir)) > 0) {
|
||||
@@ -1915,7 +1916,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
*/
|
||||
protected function deleteModuleStorage(ModuleInterface $module): void
|
||||
{
|
||||
$moduleStoragePath = storage_path('app/modules/'.$module->getIdentifier());
|
||||
$moduleStoragePath = ExtensionStoragePath::module($module->getIdentifier());
|
||||
|
||||
if (! File::isDirectory($moduleStoragePath)) {
|
||||
Log::info('삭제할 모듈 스토리지 디렉토리가 없습니다.', [
|
||||
@@ -2000,7 +2001,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
||||
$storageInfo = $this->getStorageDirectoriesInfo(
|
||||
storage_path('app/modules/'.$identifier)
|
||||
ExtensionStoragePath::module($identifier)
|
||||
);
|
||||
|
||||
// 6. Composer vendor 디렉토리 정보 조회
|
||||
|
||||
@@ -45,6 +45,7 @@ use App\Providers\CoreServiceProvider;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Support\AssetUrl;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -2610,7 +2611,7 @@ class PluginManager implements PluginManagerInterface
|
||||
protected function initializePluginSettings(PluginInterface $plugin): void
|
||||
{
|
||||
$identifier = $plugin->getIdentifier();
|
||||
$settingsDir = storage_path("app/plugins/{$identifier}/settings");
|
||||
$settingsDir = ExtensionStoragePath::plugin($identifier, 'settings');
|
||||
$settingsPath = $settingsDir.'/setting.json';
|
||||
|
||||
// 이미 설정 파일이 존재하면 스킵 (재설치 시 기존 설정 유지)
|
||||
@@ -2691,7 +2692,7 @@ class PluginManager implements PluginManagerInterface
|
||||
protected function deletePluginSettingsDirectory(PluginInterface $plugin): void
|
||||
{
|
||||
$identifier = $plugin->getIdentifier();
|
||||
$pluginStorageDir = storage_path("app/plugins/{$identifier}");
|
||||
$pluginStorageDir = ExtensionStoragePath::plugin($identifier);
|
||||
|
||||
if (File::isDirectory($pluginStorageDir)) {
|
||||
File::deleteDirectory($pluginStorageDir);
|
||||
@@ -2761,7 +2762,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
||||
$storageInfo = $this->getStorageDirectoriesInfo(
|
||||
storage_path('app/plugins/'.$identifier)
|
||||
ExtensionStoragePath::plugin($identifier)
|
||||
);
|
||||
|
||||
// 6. Composer vendor 디렉토리 정보 조회
|
||||
|
||||
@@ -60,6 +60,22 @@ class ExtensionStaticCacheService
|
||||
/** 게시 트리 디렉토리 권한 — umask 무력화 대상 */
|
||||
private const PUBLISH_DIR_MODE = 0775;
|
||||
|
||||
/**
|
||||
* 게시 루트 확보 실패 사유 → 운영자가 읽는 요약.
|
||||
*
|
||||
* 키는 `FilePermissionHelper::ensureWritableDirectory()` 가 돌려주는 사유다. 사유마다
|
||||
* 고쳐야 할 대상이 다르므로(상위 디렉토리 / 그 자리의 파일 / 대상 자신) 한 문장으로
|
||||
* 뭉뚱그리지 않는다 — 뭉뚱그리면 실패 마커만 보고는 무엇을 고쳐야 할지 알 수 없다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const PREFLIGHT_FAILURE_SUMMARIES = [
|
||||
'ancestor_not_writable' => '게시 루트를 만들 상위 디렉토리에 쓸 수 없습니다',
|
||||
'occupied_by_file' => '게시 루트 자리를 같은 이름의 파일이 차지하고 있습니다',
|
||||
'create_failed' => '게시 루트를 만들지 못했습니다',
|
||||
'not_writable' => '게시 루트에 쓸 수 없습니다',
|
||||
];
|
||||
|
||||
/** 게시 트리 디렉토리 rename 시도 횟수 (일시 거부 흡수) */
|
||||
private const RENAME_ATTEMPTS = 3;
|
||||
|
||||
@@ -650,70 +666,17 @@ class ExtensionStaticCacheService
|
||||
*/
|
||||
private function ensurePublishRootWritable(int $version): bool
|
||||
{
|
||||
$base = $this->baseDir();
|
||||
|
||||
if (! File::isDirectory($base)) {
|
||||
// 게시 루트가 아직 없으면 만든다. 검사 대상은 `public/build` 고정이 아니라
|
||||
// **실재하는 최근접 조상**이다 — `public/build` 자체가 없는 환경(신규 설치,
|
||||
// 테스트 격리 public 경로)에서 부모 존재를 요구하면 정상 상황을 실패로 만든다.
|
||||
$ancestor = $this->nearestExistingAncestor($base);
|
||||
|
||||
if ($ancestor === null || ! is_writable($ancestor)) {
|
||||
$this->failPreflight(
|
||||
$version,
|
||||
$ancestor ?? dirname($base),
|
||||
'게시 루트를 만들 상위 디렉토리에 쓸 수 없습니다'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 조상이 쓰기 가능해도 mkdir 은 실패할 수 있다 — 경로 중간이 **파일**이거나
|
||||
// 경합으로 사라지는 경우다. `ensureDirectoryExists` 는 그 실패를 예외로
|
||||
// 던지는데, 이 프리플라이트는 `publishVersion` 의 try 블록 **밖**에서 돌므로
|
||||
// 잡지 않으면 예외가 호출자에게 그대로 새어 나간다 — 게시 실패는 사이트를
|
||||
// 멈추지 않는다는 계약이 그 지점에서 깨진다.
|
||||
try {
|
||||
$this->makeDirectory($base);
|
||||
} catch (\Throwable $e) {
|
||||
$this->failPreflight($version, $base, '게시 루트를 만들지 못했습니다: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
// 확보는 코어 공통 프리미티브가 맡는다 — 검사 대상은 `public/build` 고정이 아니라
|
||||
// **실재하는 최근접 상위**이고(`public/build` 자체가 없는 신규 설치·테스트 격리 public
|
||||
// 경로에서 부모 존재를 요구하면 정상 상황을 실패로 만든다), 생성 실패는 예외가 아니라
|
||||
// 사유로 올라온다. 실패 정책(프리플라이트 실패 마커 + API 폴백)은 이 자리가 정한다.
|
||||
if (FilePermissionHelper::ensureWritableDirectory($this->baseDir(), self::PUBLISH_DIR_MODE, $failure)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearstatcache(true, $base);
|
||||
$this->failPreflight($version, $failure['path'], self::PREFLIGHT_FAILURE_SUMMARIES[$failure['reason']]);
|
||||
|
||||
if (! File::isDirectory($base) || ! is_writable($base)) {
|
||||
$this->failPreflight($version, $base, '게시 루트에 쓸 수 없습니다');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
|
||||
*
|
||||
* @param string $path 기준 경로
|
||||
* @return string|null 실재하는 최근접 조상 (루트까지 없으면 null)
|
||||
*/
|
||||
private function nearestExistingAncestor(string $path): ?string
|
||||
{
|
||||
$current = dirname($path);
|
||||
|
||||
while (! File::isDirectory($current)) {
|
||||
$parent = dirname($current);
|
||||
|
||||
if ($parent === $current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $parent;
|
||||
}
|
||||
|
||||
return $current;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -771,10 +734,12 @@ class ExtensionStaticCacheService
|
||||
/**
|
||||
* 게시 트리 디렉토리를 만들고 umask 와 무관하게 권한·소유권을 정합화합니다.
|
||||
*
|
||||
* `File::ensureDirectoryExists($dir, 0775)` 의 mode 인자는 **umask 로 깎인다** —
|
||||
* umask 022 환경에서는 0755 가 되어 웹 계정(그룹 공유)이 쓸 수 없다. 명시 `chmod` 로
|
||||
* umask 를 무력화하고, 부모 소유권을 상속시켜 CLI 계정 고정을 막는다.
|
||||
* (선례: `CoreUpdateService::ensureWritableDirectories`)
|
||||
* 게시 파일마다 호출되는 뜨거운 경로라 쓰기 가능 여부를 매번 재판정하지 않는다 — 그 판정은
|
||||
* 게시 시작 전 `ensurePublishRootWritable()` 이 루트에서 한 번 수행한다. 여기서는 생성만
|
||||
* 하고, 실패는 **예외로 남긴다**: 호출부(`writeJson`/`copyFile`)가 게시 전체를 중단시키는
|
||||
* 계약이 그 예외에 걸려 있다.
|
||||
*
|
||||
* 권한 정합화는 코어 공통 프리미티브에 위임한다 (umask 무력화 + POSIX setgid + 소유권 상속).
|
||||
*
|
||||
* @param string $dir 생성할 디렉토리 절대 경로
|
||||
*/
|
||||
@@ -782,10 +747,7 @@ class ExtensionStaticCacheService
|
||||
{
|
||||
File::ensureDirectoryExists($dir, self::PUBLISH_DIR_MODE);
|
||||
|
||||
// ensureDirectoryExists 의 mode 는 umask 로 깎이므로 명시 chmod 로 확정한다.
|
||||
@chmod($dir, self::PUBLISH_DIR_MODE);
|
||||
|
||||
FilePermissionHelper::inheritOwnershipFromParent($dir);
|
||||
FilePermissionHelper::hardenDirectory($dir, self::PUBLISH_DIR_MODE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 확장(모듈/플러그인) 저장 경로의 **로컬 파일시스템 절대 경로** 해석기.
|
||||
*
|
||||
* `StorageInterface` 로 읽고 쓸 수 있는 자리에는 이 클래스가 필요 없다 — 그쪽을 쓴다.
|
||||
* 이 해석기는 파일시스템 경로 **문자열 자체가 필요한** 자리를 위한 것이다:
|
||||
*
|
||||
* - 제3자 라이브러리에 캐시·임시 디렉토리를 넘길 때 (HTMLPurifier `Cache.SerializerPath` 등)
|
||||
* - 설정 JSON 을 `file_put_contents` 계열로 직접 다루는 자리
|
||||
*
|
||||
* `AbstractModule::getStorageBasePath()` 를 쓰지 않는 이유는 그 반환값이
|
||||
* `Storage::disk()->path()` 위임이라, 확장이 카테고리 디스크를 비로컬(S3 등)로 오버라이드하면
|
||||
* 파일시스템 경로가 아니게 되기 때문이다. 그러면 라이브러리가 그 값을 상대경로로 보고 현재
|
||||
* 작업 디렉토리 기준으로 해석해 **조용히 엉뚱한 곳에 쓴다.**
|
||||
*
|
||||
* 경로 레이아웃은 `modules`/`plugins` 디스크의 root(`config/filesystems.php`)를 단일 출처로
|
||||
* 삼는다. 그 root 가 테스트 환경을 인지하므로, 각 확장이 `app()->runningUnitTests()` 분기를
|
||||
* 자기 안에 복사할 필요가 없다 — 복사본은 한 곳만 빠뜨려도 그 확장의 테스트가 조용히 운영
|
||||
* 설정 파일을 덮어쓴다.
|
||||
*/
|
||||
class ExtensionStoragePath
|
||||
{
|
||||
/**
|
||||
* 모듈 저장 경로의 절대 경로를 반환합니다.
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
* @param string $category 카테고리 (예: settings, cache/htmlpurifier). 빈 문자열이면 모듈 루트
|
||||
* @return string 절대 경로 (존재 여부와 무관한 순수 계산)
|
||||
*/
|
||||
public static function module(string $identifier, string $category = ''): string
|
||||
{
|
||||
return static::resolve('modules', $identifier, $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 저장 경로의 절대 경로를 반환합니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자 (예: sirsoft-pay_kginicis)
|
||||
* @param string $category 카테고리 (예: settings). 빈 문자열이면 플러그인 루트
|
||||
* @return string 절대 경로 (존재 여부와 무관한 순수 계산)
|
||||
*/
|
||||
public static function plugin(string $identifier, string $category = ''): string
|
||||
{
|
||||
return static::resolve('plugins', $identifier, $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크 root 를 기준으로 `{root}/{identifier}[/{category}]` 를 조립합니다.
|
||||
*
|
||||
* root 는 `config/filesystems.php` 가 단일 출처다. 설정이 비어 있는 비정상 상황에서만
|
||||
* 운영 기본 레이아웃으로 되돌아간다 — 여기서 예외를 던지면 설정 파일 하나 때문에
|
||||
* 확장 기능 전체가 멈춘다.
|
||||
*
|
||||
* @param string $disk 디스크 이름 (modules | plugins)
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param string $category 카테고리
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
protected static function resolve(string $disk, string $identifier, string $category): string
|
||||
{
|
||||
$root = config("filesystems.disks.{$disk}.root");
|
||||
|
||||
if (! is_string($root) || $root === '') {
|
||||
$root = storage_path('app/'.$disk);
|
||||
}
|
||||
|
||||
$path = rtrim($root, '/\\').'/'.trim($identifier, '/\\');
|
||||
|
||||
$category = trim($category, '/\\');
|
||||
|
||||
return $category === '' ? $path : $path.'/'.$category;
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -1,5 +1,20 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 확장 저장 루트 (modules / plugins 디스크)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 테스트에서는 운영 데이터와 격리된 경로를 쓴다. 격리가 없으면 테스트가 실제
|
||||
| `storage/app/modules/{id}/settings/*.json` 을 덮어써 운영 설정이 사라진다.
|
||||
|
|
||||
| 이 값이 확장 저장 위치의 단일 출처다. 각 확장이 `app()->runningUnitTests()` 로
|
||||
| 같은 분기를 자기 안에 복사해 두면 한 곳만 빠뜨려도 그 확장의 테스트가 조용히
|
||||
| 운영 파일을 건드린다 — 분기는 여기 한 곳에만 둔다.
|
||||
|
|
||||
*/
|
||||
$extensionStorageRoot = env('APP_ENV') === 'testing' ? 'framework/testing' : 'app';
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
@@ -44,7 +59,7 @@ return [
|
||||
|
||||
'modules' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/modules'),
|
||||
'root' => storage_path($extensionStorageRoot.'/modules'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
'report' => false,
|
||||
@@ -52,7 +67,7 @@ return [
|
||||
|
||||
'plugins' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/plugins'),
|
||||
'root' => storage_path($extensionStorageRoot.'/plugins'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
'report' => false,
|
||||
|
||||
@@ -501,6 +501,26 @@ public function updateUser(User $user, array $data): User
|
||||
|
||||
> 상세: [validation.md](validation.md) "계층 리소스"·"보안 게이트 대칭성"
|
||||
|
||||
### 서비스가 제3자 라이브러리를 붙일 때
|
||||
|
||||
그 라이브러리가 디스크에 무엇을 어디에 쓰는지는 **서비스가 책임진다.** 제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 자기 설치 폴더(vendor 안)나 현재 작업 디렉토리에 쓰는데, 배포본의 vendor 를 읽기 전용으로 두는 서버에서는 그 쓰기가 PHP 경고를 내고 Laravel 이 이를 `ErrorException` 으로 승격시켜 요청이 500 으로 끝난다.
|
||||
|
||||
```
|
||||
필수: 경로는 `ExtensionStoragePath::module($identifier, 'cache/…')` 로 얻는다
|
||||
(디스크 root 단일 출처 — 테스트 분기를 서비스가 들고 있지 않는다)
|
||||
필수: 확보는 `FilePermissionHelper::ensureWritableDirectory($path, $mode, $failure)` 에 맡긴다
|
||||
(억제된 생성 + umask 무력화 chmod + POSIX setgid + 소유권 상속 + 쓰기 판정을 한 번에)
|
||||
필수: 확보 실패 시 캐시만 끄고 본래 기능은 계속 수행 + `error` 수준 통지 1회 기록
|
||||
(출하 기본 로그 수준이 `error` 라 `warning` 은 기본 설치 상태에서 기록되지 않는다)
|
||||
필수: 통지에 `$failure['reason']` 을 함께 싣는다 (사유마다 운영자가 고칠 대상이 다르다)
|
||||
금지: 경로를 `storage_path('app/modules/…')` 로 직접 조립하는 것
|
||||
금지: 확보 절차(생성·chmod·setgid·판정)를 서비스가 자기 안에 복사하는 것
|
||||
금지: 캐시 확보 실패를 그대로 500 으로 흘리는 것
|
||||
금지: 폴백에서 정화·검증 자체를 건너뛰는 것 (캐시는 성능 장치, 정화는 보안 장치)
|
||||
```
|
||||
|
||||
> 상세: [storage-driver.md](../extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때"
|
||||
|
||||
---
|
||||
|
||||
## 트랜잭션 및 관계 삭제 패턴
|
||||
|
||||
@@ -968,6 +968,36 @@ $basePath = $this->storage->getBasePath('images');
|
||||
// → /path/to/g7/storage/app/modules/sirsoft-ecommerce/images
|
||||
```
|
||||
|
||||
#### 제3자 라이브러리에 절대 경로를 넘길 때
|
||||
|
||||
제3자 라이브러리(HTML 정화기, PDF 생성기, 이미지 처리기 등)는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 현재 작업 디렉토리에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 **요청이 500 으로 끝난다**. 설정 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 매 요청이 같은 실패를 반복한다. 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다.
|
||||
|
||||
`cache` 카테고리의 절대 경로를 명시적으로 넘긴다.
|
||||
|
||||
```php
|
||||
$cacheDir = ExtensionStoragePath::module('sirsoft-ecommerce', 'cache/htmlpurifier');
|
||||
|
||||
if (! FilePermissionHelper::ensureWritableDirectory($cacheDir, 0775, $failure)) {
|
||||
// 캐시만 끄고 정화는 그대로 수행한다 (아래 (c))
|
||||
}
|
||||
|
||||
$config = \HTMLPurifier_Config::createDefault();
|
||||
$config->set('Cache.SerializerPath', $cacheDir);
|
||||
$config->set('Cache.SerializerPermissions', 0775);
|
||||
```
|
||||
|
||||
세 가지 규율을 함께 지킨다.
|
||||
|
||||
**(a) 디렉토리를 먼저 만든다.** 경로 해석기는 경로를 계산할 뿐 만들지 않고, 라이브러리도 대개 지정한 base 아래의 **하위** 디렉토리만 만든다. base 가 없으면 경고 한 줄을 내고 끝나므로, 경로만 지정하면 실패 지점이 "vendor 쓰기 실패" 에서 "base 없음 실패" 로 옮겨갈 뿐이다.
|
||||
|
||||
확보는 코어 프리미티브 `FilePermissionHelper::ensureWritableDirectory($path, $mode, $failure)` 가 맡는다. 이 프리미티브는 **예외도 PHP 경고도 내지 않고** `bool` 을 돌려준다 — `File::ensureDirectoryExists()` 는 `mkdir()` 을 억제 없이 부르므로 생성 실패가 `E_WARNING` 으로 나오고 Laravel 이 `ErrorException` 으로 승격시켜, 막으려던 500 이 다른 줄에서 그대로 난다. 직접 조립하지 않는다.
|
||||
|
||||
**(b) 권한 정합화는 프리미티브가 함께 수행한다.** 생성 API 의 mode 인자는 umask 로 깎이므로 명시 `chmod` 를 재적용하고, POSIX 에서는 setgid 도 함께 세운다 — 새 디렉토리의 그룹은 생성 프로세스의 egid 이므로, setgid 가 없으면 스케줄러(CLI)가 먼저 만든 하위 디렉토리를 웹 프로세스가 쓰지 못한다. 부모 소유권 상속까지 한 곳에 있으므로 호출부가 이 셋을 각자 복사하지 않는다. 이미 존재하는 디렉토리만 정합화하려면 `FilePermissionHelper::hardenDirectory($path, $mode)` 를 쓴다.
|
||||
|
||||
**(c) 확보 실패는 기능 실패가 아니다.** 프리미티브가 `false` 와 함께 사유(`occupied_by_file` / `ancestor_not_writable` / `create_failed` / `not_writable`)를 돌려주므로 통지에 그 사유를 싣는다 — 사유마다 운영자가 고쳐야 할 대상이 다르다. 디렉토리가 이미 있는데 쓰기 불가라면 요청 경로에서 권한과 싸우지 않는다. 캐시만 끄고(`Cache.DefinitionImpl = null` 같은 라이브러리별 비활성 스위치) 본래 기능은 계속 수행하며, 통지는 프로세스당 1회만 남긴다. 그 통지는 `error` 수준으로 남긴다 — 출하 기본 로그 수준이 `error` 라 `warning` 으로 남기면 기본 설치 상태에서 파일에 기록되지 않고, 기능은 성공하므로 운영자에게 도달하는 흔적이 그 통지 하나뿐이다. **정화·검증 자체를 건너뛰는 폴백은 금지한다** — 캐시는 성능 장치이고 정화는 보안 장치라, 캐시 실패가 보안 장치를 건너뛰게 만들어서는 안 된다.
|
||||
|
||||
**디스크 주의**: 경로를 `getBasePath('cache')` 로 얻지 말고 `App\Support\ExtensionStoragePath::module($identifier, 'cache/…')` 로 얻는다. 이 해석기는 `modules`/`plugins` 디스크의 root(`config/filesystems.php`)를 단일 출처로 삼아 로컬 절대 경로를 조립하며, 그 root 가 테스트 환경을 인지하므로 확장이 `app()->runningUnitTests()` 분기를 자기 안에 복사할 필요가 없다. `getBasePath()` 는 `Storage::disk()->path()` 위임이라 카테고리 디스크가 비로컬(S3 등)로 오버라이드되면 파일시스템 경로가 아니게 되고, 그러면 라이브러리가 그 값을 상대경로로 보고 현재 작업 디렉토리 기준으로 해석해 **조용히 엉뚱한 곳에 쓴다**. 대부분의 정의 캐시는 `file_put_contents` 로 쓰는 로컬 전용 장치다. 두 경로는 기본 설정에서 바이트 단위로 동일하므로 `cache` 카테고리 규약은 레이아웃 차원에서 그대로 지켜진다.
|
||||
|
||||
---
|
||||
|
||||
### getDisk()
|
||||
|
||||
@@ -73,6 +73,31 @@ powershell -Command "npm run test:run"
|
||||
2. `_bundled` src/ → PSR-4 prepend 등록 (활성 디렉토리보다 우선 검색)
|
||||
3. `autoload-extensions.php` → 이미 로드된 _bundled 항목은 스킵
|
||||
4. Manager/RouteServiceProvider → `class_exists` 가드로 중복 선언 방지
|
||||
5. 확장 `vendor/` → 제3자 composer 패키지만 골라 별도 로더로 등록
|
||||
|
||||
### 확장의 제3자 composer 패키지
|
||||
|
||||
확장이 자기 `composer.json` 으로 들여온 제3자 패키지(예: HTML 정화 라이브러리)는 `tests/bootstrap.php`
|
||||
가 등록합니다. 확장별 테스트 베이스 클래스에서 같은 일을 다시 하지 않습니다 — 규칙이 두 곳으로
|
||||
갈라지면 한쪽만 고쳐져 조용히 어긋납니다.
|
||||
|
||||
```
|
||||
금지: 확장 vendor 의 autoload.php 를 그대로 require
|
||||
필수: 생성된 맵에서 제3자 항목만 골라 vendorDir 없는 로더로 등록
|
||||
```
|
||||
|
||||
그 오토로더를 그대로 쓰면 두 가지가 오류 없이 깨집니다.
|
||||
|
||||
1. 확장 **자신의** PSR-4 와 files 를 활성 디렉토리로 매핑하고 자신을 prepend 로 걸어, 위 2번의
|
||||
`_bundled` 등록을 이깁니다. 테스트가 `_bundled` 가 아니라 활성 디렉토리 사본을 검증하게 되어
|
||||
"`_bundled` 에서만 작업한다" 는 규율이 조용히 깨집니다.
|
||||
2. Composer 로더는 `vendorDir` 를 가지면 등록 로더 목록의 맨 앞에 자신을 넣는데, 테스트용 앱
|
||||
생성이 그 첫 항목에서 base path 를 유추합니다. 이후 테스트의 앱 부팅이 확장 디렉토리에서
|
||||
`bootstrap/app.php` 를 찾다 실패합니다. (운영 진입점은 base path 를 명시 전달하므로 영향이 없습니다.)
|
||||
|
||||
이 결손은 그 패키지를 쓰는 코드 경로를 아무도 테스트하지 않는 동안 드러나지 않습니다. 확장에
|
||||
제3자 패키지를 추가하면 그 패키지를 실제로 로드하는 테스트를 함께 두고, 로드 실패를 skip 이 아니라
|
||||
단언 실패로 드러냅니다.
|
||||
|
||||
### 주의사항
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
||||
|
||||
### Changed
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
"en": "Module for board management"
|
||||
},
|
||||
"g7_version": ">=7.0.9",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Services;
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -453,18 +454,15 @@ class BoardSettingsService implements ModuleSettingsInterface
|
||||
/**
|
||||
* 설정 저장 경로 반환
|
||||
*
|
||||
* testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
|
||||
* 격리된 임시 경로를 사용합니다. 설정 저장을 수행하는 테스트가 운영 basic_defaults.json
|
||||
* 등을 덮어쓰거나 지우는 것을 차단합니다(운영 설정 영구 보존).
|
||||
* 경로는 `modules` 디스크 root(`config/filesystems.php`)를 단일 출처로 삼는다. 그 root 가
|
||||
* 테스트 환경에서 운영 데이터와 격리된 경로를 가리키므로, 운영 설정(storage/app/modules/
|
||||
* .../settings)을 덮어쓰지 않기 위한 분기를 이 서비스가 따로 들고 있지 않는다 — 분기를
|
||||
* 확장마다 복사하면 한 곳만 빠뜨려도 그 확장의 테스트가 조용히 운영 파일을 건드린다.
|
||||
*
|
||||
* @return string 설정 파일 저장 디렉토리 경로
|
||||
*/
|
||||
private function getStoragePath(): string
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
}
|
||||
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
@@ -42,7 +43,7 @@ class BoardSettingsControllerTest extends ModuleTestCase
|
||||
// 일반 사용자 생성 (권한 없음)
|
||||
$this->normalUser = $this->createUser();
|
||||
|
||||
$this->settingsStoragePath = storage_path('app/modules/sirsoft-board/settings');
|
||||
$this->settingsStoragePath = ExtensionStoragePath::module('sirsoft-board', 'settings');
|
||||
|
||||
// 테스트 전 저장소 정리
|
||||
if (File::isDirectory($this->settingsStoragePath)) {
|
||||
|
||||
@@ -236,6 +236,8 @@ class BoardSetSettingPipelineTest extends ModuleTestCase
|
||||
*/
|
||||
public function test_storage_path_is_isolated_during_tests(): void
|
||||
{
|
||||
// audit:allow extension-storage-path-hand-assembled 운영 경로를 **의도적으로** 가리킨다 —
|
||||
// 이 단언의 대상은 "테스트가 운영 파일을 건드리지 않았는가" 이므로 해석기를 쓰면 검사가 성립하지 않는다.
|
||||
$productionPath = storage_path('app/modules/sirsoft-board/settings/basic_defaults.json');
|
||||
// 운영 파일의 존재 여부·내용을 그대로 스냅샷 (환경마다 상태가 다르므로 변화 없음만 단언)
|
||||
$before = File::exists($productionPath) ? File::get($productionPath) : null;
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.2.1] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
## [1.2.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
|
||||
},
|
||||
"g7_version": ">=7.0.9",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -610,19 +611,16 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
/**
|
||||
* 설정 저장 경로 반환
|
||||
*
|
||||
* testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
|
||||
* 격리된 임시 경로를 사용합니다. 설정 저장 API를 호출하는 Feature 테스트가
|
||||
* 운영 mileage.json 등을 덮어쓰는 것을 차단합니다(운영 설정 영구 보존).
|
||||
* 경로는 `modules` 디스크 root(`config/filesystems.php`)를 단일 출처로 삼는다. 그 root 가
|
||||
* 테스트 환경에서 운영 데이터와 격리된 경로를 가리키므로, 운영 설정(storage/app/modules/
|
||||
* .../settings)을 덮어쓰지 않기 위한 분기를 이 서비스가 따로 들고 있지 않는다 — 분기를
|
||||
* 확장마다 복사하면 한 곳만 빠뜨려도 그 확장의 테스트가 조용히 운영 파일을 건드린다.
|
||||
*
|
||||
* @return string 설정 파일 저장 디렉토리 경로
|
||||
*/
|
||||
private function getStoragePath(): string
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
}
|
||||
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\HookManager;
|
||||
use App\Search\SearchPagePolicy;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -11,6 +13,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\CursorPaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OptionHasOrderHistoryException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\ProductHasOrderHistoryException;
|
||||
@@ -54,6 +57,25 @@ class ProductService
|
||||
*/
|
||||
protected ?\HTMLPurifier $purifier = null;
|
||||
|
||||
/**
|
||||
* 이 모듈의 식별자
|
||||
*/
|
||||
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
|
||||
|
||||
/**
|
||||
* HTMLPurifier 정의 캐시 디렉토리 권한
|
||||
*
|
||||
* 0775 인 이유: CLI(스케줄러/큐)와 웹(php-fpm)이 같은 캐시를 공유해야 한다. 그룹 쓰기가
|
||||
* 없으면 먼저 만든 프로세스가 상대를 잠근다. `Cache.SerializerPermissions` 로 HTMLPurifier 가
|
||||
* 만드는 하위 디렉토리에도 전파되며, `.ser` 파일에는 `$chmod & 0666` 이 적용되어 0664 가 된다.
|
||||
*/
|
||||
private const PURIFIER_CACHE_DIR_MODE = 0775;
|
||||
|
||||
/**
|
||||
* 정의 캐시 비활성 통지를 프로세스당 1회만 남기기 위한 플래그
|
||||
*/
|
||||
private static bool $purifierCacheWarned = false;
|
||||
|
||||
/**
|
||||
* 시스템 기본통화 코드 조회
|
||||
*
|
||||
@@ -1330,12 +1352,7 @@ class ProductService
|
||||
}
|
||||
|
||||
if ($this->purifier === null) {
|
||||
$config = \HTMLPurifier_Config::createDefault();
|
||||
$config->set('HTML.Allowed', 'p,br,strong,em,b,i,u,s,ul,ol,li,a[href|target],img[src|alt|width|height],h1,h2,h3,h4,h5,h6,table,tr,td,th,thead,tbody,tfoot,caption,colgroup,col,blockquote,pre,code,div,span[style],hr');
|
||||
$config->set('CSS.AllowedProperties', 'color,background-color,font-size,font-weight,text-align,text-decoration,margin,padding,border,width,height');
|
||||
$config->set('Attr.AllowedFrameTargets', ['_blank']);
|
||||
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
|
||||
$this->purifier = new \HTMLPurifier($config);
|
||||
$this->purifier = $this->createPurifier();
|
||||
}
|
||||
|
||||
foreach ($data['description'] as $locale => $content) {
|
||||
@@ -1347,6 +1364,113 @@ class ProductService
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTMLPurifier 인스턴스를 생성합니다.
|
||||
*
|
||||
* 정의 캐시 경로를 `storage/` 아래로 강제합니다. 지정하지 않으면 HTMLPurifier 는 자기 설치
|
||||
* 폴더(`{module}/vendor/ezyang/htmlpurifier/library/.../DefinitionCache/Serializer/`)에 캐시를
|
||||
* 쓰는데, 배포본의 vendor 를 읽기 전용으로 두는 서버에서는 그 쓰기가 `E_USER_WARNING` 을 내고
|
||||
* Laravel 이 이를 `ErrorException` 으로 승격시켜 상품 등록/수정이 매번 500 이 됩니다 (공개 #125).
|
||||
* 캐시는 설정 해시당 1회만 기록되므로 쓰기 불가 환경에서는 캐시가 영영 생기지 않아 모든 요청이
|
||||
* 실패합니다.
|
||||
*
|
||||
* 캐시 디렉토리를 확보하지 못하면 정의 캐시만 끄고(`Cache.DefinitionImpl = null`) 정화는 그대로
|
||||
* 수행합니다 — 캐시는 성능 장치이고 정화는 보안 장치라, 캐시 실패가 보안 장치를 건너뛰게
|
||||
* 만들어서는 안 됩니다.
|
||||
*
|
||||
* @return \HTMLPurifier 설정이 적용된 인스턴스
|
||||
*/
|
||||
protected function createPurifier(): \HTMLPurifier
|
||||
{
|
||||
$cachePath = $this->resolvePurifierCachePath($failure);
|
||||
|
||||
if ($cachePath === null && ! self::$purifierCacheWarned) {
|
||||
self::$purifierCacheWarned = true;
|
||||
|
||||
// `error` 로 남기는 이유: G7 출하 기본값(config/settings/defaults.json)의 `log_level` 이
|
||||
// `error` 라, `warning` 으로 남기면 기본 설치 상태에서는 이 통지가 로그 파일에 아예
|
||||
// 기록되지 않는다. 저장은 성공하므로 사용자 피해는 없지만, 캐시를 못 쓰는 상태가
|
||||
// 흔적 없이 영구히 유지되어 운영자가 조치할 근거를 얻지 못한다.
|
||||
Log::error('HTMLPurifier 정의 캐시 디렉토리를 사용할 수 없어 캐시 없이 동작합니다', [
|
||||
'module' => self::MODULE_IDENTIFIER,
|
||||
'expected_path' => $this->purifierCacheDirectory(),
|
||||
// 사유마다 고쳐야 할 대상이 다르다 — 상위 디렉토리 권한 / 그 자리를 차지한 파일 /
|
||||
// 대상 자신의 권한. 사유 없이 경로만 남기면 운영자가 어디를 볼지 알 수 없다.
|
||||
'reason' => $failure['reason'] ?? 'unknown',
|
||||
'blocking_path' => $failure['path'] ?? $this->purifierCacheDirectory(),
|
||||
'impact' => '상품 설명(HTML) 정화가 요청마다 정의를 다시 계산합니다. 해당 디렉토리의 쓰기 권한을 확인하세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
return new \HTMLPurifier($this->buildPurifierConfig($cachePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* HTMLPurifier 설정을 조립합니다.
|
||||
*
|
||||
* @param string|null $cachePath 정의 캐시 디렉토리 절대 경로 (null 이면 캐시 비활성)
|
||||
* @return \HTMLPurifier_Config 조립된 설정
|
||||
*/
|
||||
protected function buildPurifierConfig(?string $cachePath): \HTMLPurifier_Config
|
||||
{
|
||||
$config = \HTMLPurifier_Config::createDefault();
|
||||
$config->set('HTML.Allowed', 'p,br,strong,em,b,i,u,s,ul,ol,li,a[href|target],img[src|alt|width|height],h1,h2,h3,h4,h5,h6,table,tr,td,th,thead,tbody,tfoot,caption,colgroup,col,blockquote,pre,code,div,span[style],hr');
|
||||
$config->set('CSS.AllowedProperties', 'color,background-color,font-size,font-weight,text-align,text-decoration,margin,padding,border,width,height');
|
||||
$config->set('Attr.AllowedFrameTargets', ['_blank']);
|
||||
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
|
||||
|
||||
if ($cachePath === null) {
|
||||
$config->set('Cache.DefinitionImpl', null);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
$config->set('Cache.SerializerPath', $cachePath);
|
||||
// HTMLPurifier 가 `{경로}/HTML` 하위 디렉토리와 `.ser` 파일을 만들 때 쓰는 권한.
|
||||
// 기본값 0755 는 CLI 가 먼저 만들면 웹 계정이 못 쓴다.
|
||||
$config->set('Cache.SerializerPermissions', self::PURIFIER_CACHE_DIR_MODE);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정의 캐시 디렉토리를 확보하고 절대 경로를 반환합니다.
|
||||
*
|
||||
* 디렉토리가 이미 있는데 쓰기 불가라면 요청 경로에서 권한을 고치려 들지 않고 null 을
|
||||
* 돌려줍니다 — 호출측이 캐시를 끄고 정화는 그대로 수행합니다.
|
||||
*
|
||||
* 확보 자체(경고를 내지 않는 생성 · umask 무력화 · POSIX setgid · 소유권 상속 · 쓰기 판정)는
|
||||
* 코어 공통 프리미티브가 맡습니다. 그 프리미티브는 예외도 PHP 경고도 내지 않으므로, 이 수정이
|
||||
* 막으려던 500 이 확보 실패 지점에서 다시 나는 일이 없습니다.
|
||||
*
|
||||
* @param array{reason: string, path: string}|null $failure out — 확보 실패 사유와 그 대상 경로
|
||||
* @return string|null 사용 가능한 절대 경로. 확보 실패 시 null
|
||||
*/
|
||||
protected function resolvePurifierCachePath(?array &$failure = null): ?string
|
||||
{
|
||||
$dir = $this->purifierCacheDirectory();
|
||||
|
||||
return FilePermissionHelper::ensureWritableDirectory($dir, self::PURIFIER_CACHE_DIR_MODE, $failure)
|
||||
? $dir
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정의 캐시 디렉토리의 절대 경로 (존재 여부와 무관한 순수 계산).
|
||||
*
|
||||
* 경로는 코어 해석기가 `modules` 디스크 root 를 단일 출처로 삼아 조립합니다.
|
||||
* `getStorageBasePath('cache')` 를 경유하지 않는 이유는 그 반환값이 `Storage::disk()->path()`
|
||||
* 위임이라, 확장이 카테고리 디스크를 비로컬(S3 등)로 오버라이드하면 파일시스템 경로가 아니게
|
||||
* 되기 때문입니다 — HTMLPurifier 는 `file_put_contents` 로 쓰므로 반드시 로컬 절대 경로여야
|
||||
* 합니다.
|
||||
*
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
protected function purifierCacheDirectory(): string
|
||||
{
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'cache/htmlpurifier');
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 동기화 플래그를 적용합니다 (서버 SSoT).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Concerns;
|
||||
|
||||
/**
|
||||
* HTMLPurifier 정의 캐시 위치 검사 헬퍼 (공개 #125 회귀)
|
||||
*
|
||||
* 정의 캐시가 모듈 설치 폴더(vendor) 대신 `storage/` 아래에 기록되는지를 확인하는
|
||||
* 테스트들이 공유합니다.
|
||||
*/
|
||||
trait InspectsHtmlPurifierCache
|
||||
{
|
||||
/**
|
||||
* 테스트 환경에서 정의 캐시가 기록되어야 하는 디렉토리 절대 경로.
|
||||
*
|
||||
* `ProductService::purifierCacheDirectory()` 의 테스트 분기와 동일해야 합니다.
|
||||
*
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
protected function purifierStorageBase(): string
|
||||
{
|
||||
return storage_path('framework/testing/modules/sirsoft-ecommerce/cache/htmlpurifier');
|
||||
}
|
||||
|
||||
/**
|
||||
* HTMLPurifier 가 자기 설치 폴더를 캐시 기본값으로 쓸 때의 경로.
|
||||
*
|
||||
* `HTMLPurifier_DefinitionCache_Serializer::generateBaseDirectoryPath()` 의 폴백과 동일식입니다.
|
||||
*
|
||||
* @return string|null 절대 경로. HTMLPurifier 미로드 시 null
|
||||
*/
|
||||
protected function vendorSerializerBase(): ?string
|
||||
{
|
||||
return defined('HTMLPURIFIER_PREFIX')
|
||||
? HTMLPURIFIER_PREFIX.'/HTMLPurifier/DefinitionCache/Serializer'
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리 하위 `.ser` 파일의 "경로 => mtime:size" 스냅샷을 만듭니다.
|
||||
*
|
||||
* @param string|null $base 검사할 디렉토리 절대 경로 (null 이거나 미존재면 빈 배열)
|
||||
* @return array<string, string> 경로를 키로 정렬된 스냅샷
|
||||
*/
|
||||
protected function serSnapshot(?string $base): array
|
||||
{
|
||||
if ($base === null || ! is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$snapshot = [];
|
||||
$it = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($it as $file) {
|
||||
if ($file->isFile() && str_ends_with($file->getFilename(), '.ser')) {
|
||||
$snapshot[$file->getPathname()] = $file->getMTime().':'.$file->getSize();
|
||||
}
|
||||
}
|
||||
|
||||
ksort($snapshot);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Category;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Sequence;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\Concerns\InspectsHtmlPurifierCache;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* HTML 상세설명 상품 등록의 정의 캐시 경로 회귀 테스트 (공개 #125)
|
||||
*
|
||||
* 실제 등록 엔드포인트를 태워, 정의 캐시가 모듈 설치 폴더(vendor)가 아니라 `storage/` 아래에
|
||||
* 기록되는지와 두 번째 저장이 기존 캐시를 재사용하는지를 확인합니다.
|
||||
*/
|
||||
class ProductStoreHtmlDescriptionCacheTest extends ModuleTestCase
|
||||
{
|
||||
use InspectsHtmlPurifierCache;
|
||||
|
||||
private $user;
|
||||
|
||||
private Category $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->assertTrue(
|
||||
class_exists(\HTMLPurifier::class),
|
||||
'HTMLPurifier 가 로드되지 않았습니다. 모듈 vendor 가 설치되어 있고 '
|
||||
.'tests/bootstrap.php 의 확장 vendor 오토로드 등록이 동작하는지 확인하세요.'
|
||||
);
|
||||
|
||||
$defaultConfig = SequenceType::PRODUCT->getDefaultConfig();
|
||||
Sequence::firstOrCreate(
|
||||
['type' => SequenceType::PRODUCT->value],
|
||||
[
|
||||
'algorithm' => $defaultConfig['algorithm']->value,
|
||||
'prefix' => $defaultConfig['prefix'],
|
||||
'current_value' => 0,
|
||||
'increment' => 1,
|
||||
'min_value' => 1,
|
||||
'max_value' => $defaultConfig['max_value'],
|
||||
'cycle' => false,
|
||||
'pad_length' => $defaultConfig['pad_length'],
|
||||
'max_history_count' => $defaultConfig['max_history_count'],
|
||||
]
|
||||
);
|
||||
|
||||
$this->user = $this->createAdminUser([
|
||||
'sirsoft-ecommerce.products.read',
|
||||
'sirsoft-ecommerce.products.create',
|
||||
'sirsoft-ecommerce.products.update',
|
||||
]);
|
||||
|
||||
$this->category = new Category([
|
||||
'name' => ['ko' => '테스트 카테고리', 'en' => 'Test Category'],
|
||||
'slug' => 'issue125-category',
|
||||
'is_active' => true,
|
||||
'depth' => 0,
|
||||
]);
|
||||
$this->category->path = 'temp';
|
||||
$this->category->save();
|
||||
$this->category->generatePath();
|
||||
$this->category->save();
|
||||
|
||||
File::deleteDirectory($this->purifierStorageBase());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory($this->purifierStorageBase());
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 상세설명 상품 등록 payload.
|
||||
*
|
||||
* @param string $productCode 상품코드
|
||||
* @return array 등록 요청 body
|
||||
*/
|
||||
private function htmlProductData(string $productCode): array
|
||||
{
|
||||
return [
|
||||
'name' => ['ko' => 'ISSUE125 상품', 'en' => 'ISSUE125 Product'],
|
||||
'product_code' => $productCode,
|
||||
'category_ids' => [$this->category->id],
|
||||
'list_price' => 10000,
|
||||
'selling_price' => 9000,
|
||||
'stock_quantity' => 10,
|
||||
'sales_status' => 'on_sale',
|
||||
'display_status' => 'visible',
|
||||
'tax_status' => 'taxable',
|
||||
'description_mode' => 'html',
|
||||
'description' => ['ko' => '<p>설명</p><script>alert(1)</script>'],
|
||||
'options' => [
|
||||
[
|
||||
'option_code' => $productCode.'-O1',
|
||||
'option_name' => ['ko' => '기본옵션', 'en' => 'Default Option'],
|
||||
'option_values' => [
|
||||
['key' => ['ko' => '색상'], 'value' => ['ko' => '빨강']],
|
||||
],
|
||||
'list_price' => 10000,
|
||||
'selling_price' => 9000,
|
||||
'stock_quantity' => 10,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 상세설명 상품을 등록하면 정의 캐시가 storage 아래에 기록됩니다.
|
||||
*
|
||||
* 거짓 green 경고: 개발 머신 vendor 에는 이미 같은 이름의 `.ser` 가 있어 "vendor 스냅샷
|
||||
* 불변" 단언은 수정 전에도 통과할 수 있다. 결함을 증명하는 것은 storage 스냅샷 단언이다.
|
||||
*
|
||||
* @scenario description_mode=html, cache_dir=writable
|
||||
*
|
||||
* @effects definition_cache_written_under_storage, vendor_install_directory_never_written
|
||||
*/
|
||||
public function test_store_with_html_description_writes_cache_under_storage(): void
|
||||
{
|
||||
$vendorBefore = $this->serSnapshot($this->vendorSerializerBase());
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/modules/sirsoft-ecommerce/admin/products', $this->htmlProductData('ISSUE125-001'));
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$this->assertNotEmpty(
|
||||
$this->serSnapshot($this->purifierStorageBase()),
|
||||
'storage 아래에 정의 캐시가 기록되지 않았습니다.'
|
||||
);
|
||||
$this->assertSame(
|
||||
$vendorBefore,
|
||||
$this->serSnapshot($this->vendorSerializerBase()),
|
||||
'HTMLPurifier 설치 폴더에 정의 캐시가 기록되었습니다.'
|
||||
);
|
||||
|
||||
$description = $response->json('data.description');
|
||||
$this->assertStringNotContainsString('<script', is_array($description) ? ($description['ko'] ?? '') : (string) $description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 번째 저장은 이미 기록된 정의 캐시를 재사용합니다 (재기록하지 않음).
|
||||
*
|
||||
* @scenario description_mode=html, cache_dir=already_populated
|
||||
*
|
||||
* @effects second_save_reuses_cached_definition
|
||||
*/
|
||||
public function test_second_store_reuses_existing_definition_cache(): void
|
||||
{
|
||||
$this->actingAs($this->user)
|
||||
->postJson('/api/modules/sirsoft-ecommerce/admin/products', $this->htmlProductData('ISSUE125-002'))
|
||||
->assertCreated();
|
||||
|
||||
$snapshot = $this->serSnapshot($this->purifierStorageBase());
|
||||
$this->assertNotEmpty($snapshot);
|
||||
|
||||
// mtime 해상도가 1초라 같은 초 안의 재기록이 감지되지 않는다 — 과거로 밀어 baseline 확보.
|
||||
foreach (array_keys($snapshot) as $path) {
|
||||
touch($path, time() - 3600);
|
||||
}
|
||||
$baseline = $this->serSnapshot($this->purifierStorageBase());
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson('/api/modules/sirsoft-ecommerce/admin/products', $this->htmlProductData('ISSUE125-003'))
|
||||
->assertCreated();
|
||||
|
||||
$this->assertSame(
|
||||
$baseline,
|
||||
$this->serSnapshot($this->purifierStorageBase()),
|
||||
'두 번째 저장이 정의 캐시를 다시 기록했습니다 (재사용되지 않음).'
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -117,6 +117,8 @@ class EcommerceSettingsOrderSettingsTest extends ModuleTestCase
|
||||
*/
|
||||
public function test_saving_settings_does_not_touch_production_path(): void
|
||||
{
|
||||
// audit:allow extension-storage-path-hand-assembled 운영 경로를 **의도적으로** 가리킨다 —
|
||||
// 이 단언의 대상은 "테스트가 운영 파일을 건드리지 않았는가" 이므로 해석기를 쓰면 검사가 성립하지 않는다.
|
||||
$productionFile = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$existedBefore = File::exists($productionFile);
|
||||
$contentBefore = $existedBefore ? File::get($productionFile) : null;
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\Concerns\InspectsHtmlPurifierCache;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Psr\Log\AbstractLogger;
|
||||
|
||||
/**
|
||||
* HTMLPurifier 정의 캐시 경로 회귀 테스트 (공개 #125)
|
||||
*
|
||||
* `sanitizeDescription()` 이 정의 캐시 경로를 지정하지 않으면 HTMLPurifier 는 자기 설치 폴더
|
||||
* (`{module}/vendor/ezyang/htmlpurifier/library/.../DefinitionCache/Serializer/`)에 캐시를 쓴다.
|
||||
* 배포본의 vendor 를 읽기 전용으로 두는 서버에서는 그 쓰기가 경고를 내고 Laravel 이 이를
|
||||
* `ErrorException` 으로 승격시켜 상품 등록/수정이 매번 500 이 된다.
|
||||
*/
|
||||
class ProductDescriptionPurifierCacheTest extends ModuleTestCase
|
||||
{
|
||||
use InspectsHtmlPurifierCache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 하네스 결손(모듈 vendor 미오토로드)을 조용한 skip 으로 감추지 않는다 —
|
||||
// 로드되지 않으면 이 테스트가 검증하려는 코드 경로 자체가 실행되지 않는다.
|
||||
$this->assertTrue(
|
||||
class_exists(\HTMLPurifier::class),
|
||||
'HTMLPurifier 가 로드되지 않았습니다. 모듈 vendor 가 설치되어 있고 '
|
||||
.'tests/bootstrap.php 의 확장 vendor 오토로드 등록이 동작하는지 확인하세요.'
|
||||
);
|
||||
|
||||
// 검증 대상이 이 테스트와 같은 트리(_bundled)의 사본인지 확인한다.
|
||||
// 모듈 vendor 의 composer 오토로더는 자기 PSR-4 를 **활성** 디렉토리로 매핑하고 자신을
|
||||
// prepend 로 등록하므로, 그것을 그대로 로드하면 테스트가 활성 사본을 검증하게 되어
|
||||
// `_bundled` 의 수정이 반영되지 않은 채로도 통과하거나 실패한다 (조용한 오판).
|
||||
$this->assertStringStartsWith(
|
||||
dirname(__DIR__, 3).DIRECTORY_SEPARATOR,
|
||||
(new \ReflectionClass(ProductService::class))->getFileName(),
|
||||
'ProductService 가 이 테스트와 다른 트리에서 로드되었습니다 — 모듈 vendor 오토로더가 '
|
||||
.'자기 PSR-4 를 등록해 활성 디렉토리 사본을 검증하고 있습니다.'
|
||||
);
|
||||
|
||||
$this->purgePurifierCacheBase();
|
||||
$this->resetPurifierCacheNoticeFlag();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->purgePurifierCacheBase();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 캐시 디렉토리를 정리합니다 (파일이 자리를 차지한 경우 포함).
|
||||
*/
|
||||
private function purgePurifierCacheBase(): void
|
||||
{
|
||||
$base = $this->purifierStorageBase();
|
||||
|
||||
foreach ([$base, dirname($base)] as $path) {
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log 파사드를 기록용 로거로 교체하고 그 기록기를 돌려줍니다.
|
||||
*
|
||||
* `Log::spy()` 는 쓰지 않는다 — `LogManager` 가 `__call` 로 임의 메서드를 받아 넘기므로
|
||||
* Mockery 스파이가 `shouldHaveReceived('error')` 를 `warning` 호출에도 통과시킨다(무효 단언).
|
||||
*
|
||||
* @return object `calls` 배열(level/message)을 보유한 기록기
|
||||
*/
|
||||
private function swapLogRecorder(): object
|
||||
{
|
||||
$recorder = new class extends AbstractLogger
|
||||
{
|
||||
/** @var array<int, array{level: string, message: string}> */
|
||||
public array $calls = [];
|
||||
|
||||
public function log($level, string|\Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->calls[] = ['level' => (string) $level, 'message' => (string) $message];
|
||||
}
|
||||
};
|
||||
|
||||
Log::swap($recorder);
|
||||
|
||||
return $recorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 비활성 통지의 "프로세스당 1회" 플래그를 되돌립니다.
|
||||
*
|
||||
* 정적 플래그라 같은 프로세스의 앞선 테스트가 이미 세워 두면 통지가 발화하지 않아,
|
||||
* 통지 단언이 테스트 순서에 따라 조용히 통과/실패한다.
|
||||
*/
|
||||
private function resetPurifierCacheNoticeFlag(): void
|
||||
{
|
||||
$property = new \ReflectionProperty(ProductService::class, 'purifierCacheWarned');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* `sanitizeDescription()` 을 호출하고 사용된 서비스 인스턴스를 돌려줍니다.
|
||||
*
|
||||
* @param array $data 상품 데이터
|
||||
* @return array{0: ProductService, 1: array} 서비스 인스턴스와 정화 결과
|
||||
*/
|
||||
private function sanitize(array $data): array
|
||||
{
|
||||
// 생성자 의존성이 7개라 수동 new 가 성립하지 않는다.
|
||||
$service = app(ProductService::class);
|
||||
|
||||
$method = new \ReflectionMethod($service, 'sanitizeDescription');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return [$service, $method->invoke($service, $data)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스가 보유한 HTMLPurifier 인스턴스를 꺼냅니다.
|
||||
*
|
||||
* @param ProductService $service 대상 서비스
|
||||
* @return \HTMLPurifier|null 인스턴스 (미생성이면 null)
|
||||
*/
|
||||
private function purifierOf(ProductService $service): ?\HTMLPurifier
|
||||
{
|
||||
$property = new \ReflectionProperty($service, 'purifier');
|
||||
$property->setAccessible(true);
|
||||
|
||||
return $property->getValue($service);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 모드 정화 시 정의 캐시가 vendor 가 아니라 storage 아래에 기록됩니다.
|
||||
*
|
||||
* 거짓 green 경고: 개발 머신 vendor 에는 이미 `.ser` 가 있고 설정 해시가 같아 파일명이
|
||||
* 동일하므로 "vendor 스냅샷 불변" 단언은 수정 전에도 통과할 수 있다. 결함을 증명하는 것은
|
||||
* storage 쪽 단언(`Cache.SerializerPath` / `generateFilePath` / `assertFileExists`)이다.
|
||||
*
|
||||
* @scenario description_mode=html, cache_dir=writable
|
||||
*
|
||||
* @effects definition_cache_written_under_storage, vendor_install_directory_never_written
|
||||
*/
|
||||
public function test_definition_cache_is_written_under_storage_not_vendor(): void
|
||||
{
|
||||
$storageBase = $this->purifierStorageBase();
|
||||
$vendorBefore = $this->serSnapshot($this->vendorSerializerBase());
|
||||
|
||||
[$service, $result] = $this->sanitize([
|
||||
'description_mode' => 'html',
|
||||
'description' => ['ko' => '<p>본문</p><script>alert(1)</script>'],
|
||||
]);
|
||||
|
||||
$this->assertStringContainsString('<p>본문</p>', $result['description']['ko']);
|
||||
$this->assertStringNotContainsString('<script', $result['description']['ko']);
|
||||
|
||||
$purifier = $this->purifierOf($service);
|
||||
$this->assertNotNull($purifier);
|
||||
|
||||
$config = $purifier->config;
|
||||
$this->assertSame($storageBase, $config->get('Cache.SerializerPath'));
|
||||
|
||||
$cacheFile = (new \HTMLPurifier_DefinitionCache_Serializer('HTML'))->generateFilePath($config);
|
||||
$this->assertStringStartsWith($storageBase, $cacheFile);
|
||||
$this->assertFileExists($cacheFile);
|
||||
|
||||
$this->assertSame(
|
||||
$vendorBefore,
|
||||
$this->serSnapshot($this->vendorSerializerBase()),
|
||||
'HTMLPurifier 설치 폴더에 정의 캐시가 기록되었습니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* text 모드는 HTMLPurifier 를 인스턴스화하지 않습니다.
|
||||
*
|
||||
* @scenario description_mode=text, cache_dir=writable
|
||||
*
|
||||
* @effects text_mode_skips_purifier_entirely
|
||||
*/
|
||||
public function test_text_mode_never_touches_purifier(): void
|
||||
{
|
||||
[$service, $result] = $this->sanitize([
|
||||
'description_mode' => 'text',
|
||||
'description' => ['ko' => '<p>본문</p><script>alert(1)</script>'],
|
||||
]);
|
||||
|
||||
$this->assertSame('<p>본문</p><script>alert(1)</script>', $result['description']['ko']);
|
||||
$this->assertNull($this->purifierOf($service));
|
||||
$this->assertDirectoryDoesNotExist($this->purifierStorageBase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 디렉토리를 확보하지 못하면 캐시만 끄고 정화는 그대로 수행합니다.
|
||||
*
|
||||
* 목적지에 같은 이름의 **파일**을 놓아 재현한다 — ACL/chmod 없이 Windows·POSIX 양쪽에서
|
||||
* 결정적이다.
|
||||
*
|
||||
* 거짓 green 경고: vendor 스냅샷 불변 단언은 수정 전에도 통과할 수 있다(위 참조).
|
||||
* 이 테스트의 판정은 `Cache.DefinitionImpl === null` 이다.
|
||||
*
|
||||
* 통지 수준을 함께 잠근다 — 이 폴백은 저장을 성공시키므로 운영자에게 도달하는 흔적이
|
||||
* 그 통지 하나뿐인데, G7 출하 기본 로그 수준(`config/settings/defaults.json` 의 `log_level`)이
|
||||
* `error` 라 `warning` 으로 남기면 기본 설치 상태에서 파일에 기록되지 않는다.
|
||||
*
|
||||
* @scenario description_mode=html, cache_dir=occupied_by_file
|
||||
*
|
||||
* @effects purify_still_strips_script_when_cache_disabled, vendor_install_directory_never_written, cache_disabled_notice_reaches_default_log_level
|
||||
*/
|
||||
public function test_falls_back_to_disabled_definition_cache_when_directory_unusable(): void
|
||||
{
|
||||
$base = $this->purifierStorageBase();
|
||||
File::ensureDirectoryExists(dirname($base));
|
||||
file_put_contents($base, 'occupied');
|
||||
|
||||
$vendorBefore = $this->serSnapshot($this->vendorSerializerBase());
|
||||
|
||||
$recorder = $this->swapLogRecorder();
|
||||
|
||||
[$service, $result] = $this->sanitize([
|
||||
'description_mode' => 'html',
|
||||
'description' => ['ko' => '<p>본문</p><script>alert(1)</script>'],
|
||||
]);
|
||||
|
||||
$notices = array_values(array_filter(
|
||||
$recorder->calls,
|
||||
fn (array $call): bool => str_contains($call['message'], 'HTMLPurifier 정의 캐시 디렉토리를 사용할 수 없어')
|
||||
));
|
||||
|
||||
$this->assertCount(1, $notices, '캐시 비활성 통지가 프로세스당 1회 발화해야 합니다.');
|
||||
$this->assertSame(
|
||||
'error',
|
||||
$notices[0]['level'],
|
||||
'캐시 비활성 통지는 error 수준이어야 합니다 — 출하 기본 로그 수준이 error 라 '
|
||||
.'warning 으로 남기면 기본 설치 상태에서 로그 파일에 기록되지 않고, 이 폴백은 저장을 '
|
||||
.'성공시키므로 운영자에게 도달하는 흔적이 이 통지 하나뿐입니다.'
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('<p>본문</p>', $result['description']['ko']);
|
||||
$this->assertStringNotContainsString('<script', $result['description']['ko']);
|
||||
|
||||
$purifier = $this->purifierOf($service);
|
||||
$this->assertNotNull($purifier);
|
||||
$this->assertNull($purifier->config->get('Cache.DefinitionImpl'));
|
||||
|
||||
$this->assertSame(
|
||||
$vendorBefore,
|
||||
$this->serSnapshot($this->vendorSerializerBase()),
|
||||
'HTMLPurifier 설치 폴더에 정의 캐시가 기록되었습니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 디렉토리 생성 실패가 예외로 승격되지 않습니다.
|
||||
*
|
||||
* `File::ensureDirectoryExists()` 는 `mkdir()` 을 억제 없이 호출하므로, 생성이 실패하면
|
||||
* `E_WARNING` 이 나오고 Laravel `HandleExceptions` 가 이를 `ErrorException` 으로 승격시켜
|
||||
* 요청이 500 이 된다 — 이 이슈가 막으려던 실패가 다른 줄에서 그대로 재현되는 형태다.
|
||||
* 부모 자리에 파일을 놓아 재현한다(ACL/chmod 없이 Windows·POSIX 양쪽에서 결정적).
|
||||
*
|
||||
* @scenario description_mode=html, cache_dir=parent_unusable
|
||||
*
|
||||
* @effects cache_dir_creation_failure_does_not_escalate, purify_still_strips_script_when_cache_disabled
|
||||
*/
|
||||
public function test_directory_creation_failure_does_not_escalate_to_exception(): void
|
||||
{
|
||||
$base = $this->purifierStorageBase();
|
||||
$parent = dirname($base);
|
||||
|
||||
File::deleteDirectory($parent);
|
||||
File::ensureDirectoryExists(dirname($parent));
|
||||
file_put_contents($parent, 'occupied');
|
||||
|
||||
$this->assertFalse(is_dir($base));
|
||||
$this->assertFalse(file_exists($base), '부모가 파일이면 자식 경로는 존재하지 않아야 한다 (전제 확인).');
|
||||
|
||||
[$service, $result] = $this->sanitize([
|
||||
'description_mode' => 'html',
|
||||
'description' => ['ko' => '<p>본문</p><script>alert(1)</script>'],
|
||||
]);
|
||||
|
||||
$this->assertStringContainsString('<p>본문</p>', $result['description']['ko']);
|
||||
$this->assertStringNotContainsString('<script', $result['description']['ko']);
|
||||
$this->assertNull($this->purifierOf($service)->config->get('Cache.DefinitionImpl'));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
feature: HTMLPurifier 정의 캐시 경로 재지정 (공개 #125)
|
||||
|
||||
description: |
|
||||
상품 상세설명을 편집기(HTML)로 작성해 저장하면 정화 구성요소(HTMLPurifier)가 정의 캐시를
|
||||
자기 설치 폴더(`{module}/vendor/ezyang/htmlpurifier/library/.../DefinitionCache/Serializer/`)에
|
||||
기록했다. 배포본의 vendor 를 읽기 전용으로 두는 표준 서버에서는 그 쓰기가 예외가 아니라
|
||||
PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시켜 상품 등록/수정이 500 으로
|
||||
끝났다. 캐시는 설정 해시당 1회만 기록되므로 쓰기 불가 환경에서는 캐시가 영영 생기지 않아
|
||||
매 요청이 같은 실패를 반복했다 (공개 이슈 #125, @lyg-kaban 제보).
|
||||
|
||||
정정은 세 갈래다 — ① 정의 캐시 경로를 `storage/app/modules/{identifier}/cache/htmlpurifier`
|
||||
로 명시 지정하고, ② 그 디렉토리를 우리가 먼저 만든다(HTMLPurifier 는 base 디렉토리가 없으면
|
||||
경고만 내고 만들지 않는다), ③ 그 경로마저 확보하지 못하면 정의 캐시만 끄고 정화는 그대로
|
||||
수행한다. 캐시는 성능 장치이고 정화는 보안 장치라, 캐시 실패가 보안 장치를 건너뛰게
|
||||
만들어서는 안 된다.
|
||||
|
||||
③ 의 폴백은 저장을 성공시키므로 운영자에게 도달하는 흔적이 로그 통지 하나뿐이다. 그 통지는
|
||||
`error` 수준으로 남긴다 — G7 출하 기본 로그 수준(`config/settings/defaults.json` 의
|
||||
`log_level`)이 `error` 라, `warning` 으로 남기면 기본 설치 상태에서 파일에 기록되지 않아
|
||||
캐시를 못 쓰는 상태가 흔적 없이 영구히 유지된다.
|
||||
|
||||
axes:
|
||||
description_mode: [text, html]
|
||||
cache_dir: [writable, occupied_by_file, parent_unusable, already_populated]
|
||||
|
||||
exclusions:
|
||||
- { description_mode: text, cache_dir: occupied_by_file, reason: "text 모드는 HTMLPurifier 를 인스턴스화하지 않아 캐시 상태와 직교" }
|
||||
- { description_mode: text, cache_dir: parent_unusable, reason: "동일 — text 모드는 purifier 경로에 진입하지 않는다" }
|
||||
- { description_mode: text, cache_dir: already_populated, reason: "동일 — text 모드는 purifier 경로에 진입하지 않는다" }
|
||||
|
||||
effects:
|
||||
- definition_cache_written_under_storage
|
||||
- vendor_install_directory_never_written
|
||||
- text_mode_skips_purifier_entirely
|
||||
- purify_still_strips_script_when_cache_disabled
|
||||
- cache_dir_creation_failure_does_not_escalate
|
||||
- cache_disabled_notice_reaches_default_log_level
|
||||
- second_save_reuses_cached_definition
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductDescriptionPurifierCacheTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductStoreHtmlDescriptionCacheTest.php
|
||||
|
||||
validation:
|
||||
note: |
|
||||
거짓 green 경고: 개발 머신의 vendor 에는 이미 `.ser` 가 있고 설정 해시가 같아 파일명이
|
||||
동일하므로, "vendor 설치 폴더가 쓰이지 않았다" 는 단언만으로는 수정 전에도 통과한다.
|
||||
결함을 실제로 증명하는 것은 storage 쪽 단언이다 — `Cache.SerializerPath` 값,
|
||||
`generateFilePath()` 가 가리키는 경로의 실재, 그리고 폴백 경로에서의
|
||||
`Cache.DefinitionImpl === null`.
|
||||
|
||||
이 테스트들은 모듈 전용 composer 패키지(`\HTMLPurifier`)를 실제로 로드해야 성립한다.
|
||||
PHPUnit 진입점(`autoload-extensions.php`)은 확장 vendor 를 오토로드하지 않으므로 코어
|
||||
`tests/bootstrap.php` 가 확장 vendor 의 생성된 맵에서 제3자 패키지만 골라 등록하며(확장
|
||||
자신의 PSR-4 는 제외 — 활성 디렉토리 사본을 검증하게 되는 것을 막는다),
|
||||
각 테스트는 로드 실패를 조용한 skip 이 아니라 setUp 단언 실패로 드러낸다.
|
||||
+2
-1
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,6 @@ class AbsorbLegacyDueDaysIntoAutoCancelDays implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings/order_settings.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/order_settings.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -81,10 +82,6 @@ class BackfillShippingPolicyCurrency implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
$base = app()->runningUnitTests()
|
||||
? 'framework/testing/modules/'
|
||||
: 'app/modules/';
|
||||
|
||||
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/language_currency.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/language_currency.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -153,10 +154,6 @@ class NormalizeCurrencyBaseUnit implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
$base = app()->runningUnitTests()
|
||||
? 'framework/testing/modules/'
|
||||
: 'app/modules/';
|
||||
|
||||
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/language_currency.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/language_currency.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -137,10 +138,6 @@ class NormalizeCurrencyLocales implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
$base = app()->runningUnitTests()
|
||||
? 'framework/testing/modules/'
|
||||
: 'app/modules/';
|
||||
|
||||
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/language_currency.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/language_currency.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -60,6 +61,6 @@ class RemoveLegacyDefaultLanguage implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings/language_currency.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/language_currency.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_2\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -130,10 +131,6 @@ class PruneEmptyShippingCountryNameLocales implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
$base = app()->runningUnitTests()
|
||||
? 'framework/testing/modules/'
|
||||
: 'app/modules/';
|
||||
|
||||
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/shipping.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/shipping.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -122,10 +123,6 @@ class BackfillMileageEarnRounding implements DataMigration
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
$base = app()->runningUnitTests()
|
||||
? 'framework/testing/modules/'
|
||||
: 'app/modules/';
|
||||
|
||||
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/mileage.json');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings').'/mileage.json';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_1_1\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -102,7 +103,7 @@ class SoftDeleteOrphanInquiryReplyPosts implements DataMigration
|
||||
$boardIds = [];
|
||||
|
||||
// ① 설정 파일의 board_slug → boards.id 해석
|
||||
$settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/inquiry.json');
|
||||
$settingsPath = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/inquiry.json';
|
||||
|
||||
if (File::exists($settingsPath) && Schema::hasTable(self::BOARDS_TABLE)) {
|
||||
$settings = json_decode(File::get($settingsPath), true);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-08-22T12:22:17+00:00",
|
||||
"generated_at": "2026-08-28T01:27:59+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "module:sirsoft-ecommerce",
|
||||
"composer_json_sha256": "0556835aa543c462efe9132123b57234fddb66e482898144e7930f6cbedb1a9d",
|
||||
"composer_json_sha256": "4f224863e21777f5ef474a7fcfb7d5f66132551b4c58220a660bd3acb573dfdb",
|
||||
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
|
||||
"zip_sha256": "3127bf97f4342c91cffc0d729eaa70f2a1f74a4a4015288081a082e665ccb9c0",
|
||||
"zip_size": 435548,
|
||||
"zip_sha256": "5f954f1597fc2a39bb601af441e3af3768e303b835c9047f0ade71cb56aec66c",
|
||||
"zip_size": 435549,
|
||||
"package_count": 1,
|
||||
"php_requirement": "^8.2",
|
||||
"g7_version": "7.0.9",
|
||||
"g7_version": "7.0.10",
|
||||
"packages": [
|
||||
{
|
||||
"name": "ezyang/htmlpurifier",
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,10 @@
|
||||
|
||||
- 페이지 공유 미리보기의 설명(og:description)이 항상 비어 있던 문제를 수정했습니다. 이제 페이지 SEO 설정의 설명이 사용됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
## [1.0.3] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
"en": "Static page (info/policy/guide) management module"
|
||||
},
|
||||
"g7_version": ">=7.0.9",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Page\Services;
|
||||
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -253,17 +254,15 @@ class PageSettingsService implements ModuleSettingsInterface
|
||||
/**
|
||||
* 설정 저장 경로 반환
|
||||
*
|
||||
* testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
|
||||
* 별도 경로를 사용합니다 — 테스트가 개발/운영 설정을 덮어쓰지 않게 합니다.
|
||||
* 경로는 `modules` 디스크 root(`config/filesystems.php`)를 단일 출처로 삼는다. 그 root 가
|
||||
* 테스트 환경에서 운영 데이터와 격리된 경로를 가리키므로, 운영 설정(storage/app/modules/
|
||||
* .../settings)을 덮어쓰지 않기 위한 분기를 이 서비스가 따로 들고 있지 않는다 — 분기를
|
||||
* 확장마다 복사하면 한 곳만 빠뜨려도 그 확장의 테스트가 조용히 운영 파일을 건드린다.
|
||||
*
|
||||
* @return string 설정 파일 저장 디렉토리 경로
|
||||
*/
|
||||
private function getStoragePath(): string
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
}
|
||||
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
return ExtensionStoragePath::module(self::MODULE_IDENTIFIER, 'settings');
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftCkeditor5\V1_0_2\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -30,11 +31,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class SeedUnusedImageCleanupSettings implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 플러그인 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-ckeditor5/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 백필할 기본값 (키 => 기본값).
|
||||
*
|
||||
@@ -62,7 +58,9 @@ final class SeedUnusedImageCleanupSettings implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 플러그인 설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::plugin('sirsoft-ckeditor5', 'settings').'/setting.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[sirsoft-ckeditor5] 설정 파일 없음 — 설치 시 defaults.json 이 시드하므로 skip');
|
||||
|
||||
@@ -108,7 +108,7 @@ abstract class PluginTestCase extends TestCase
|
||||
* 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다.
|
||||
*
|
||||
* 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크
|
||||
* (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면
|
||||
* (root = `plugins` 디스크, config/filesystems.php)에 setting.json 을 쓴다. 격리하지 않으면
|
||||
* 테스트가 실제 로컬 런타임 설정 파일을 덮어써 검수 모드/자격증명이 오염된다
|
||||
* (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를
|
||||
* 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
## [1.1.2] - 2026-08-22
|
||||
|
||||
### Changed
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.8 로 상향했습니다.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ko": "KG 이니시스 결제 게이트웨이 (표준결제창 연동, 일본결제 지원)",
|
||||
"en": "KG Inicis payment gateway (standard payment window, Japan payment support)"
|
||||
},
|
||||
"g7_version": ">=7.0.8",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
|
||||
+4
-3
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_1\Migrations\BackfillEasyPayPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.1/migrations/BackfillEasyPayPgProvider.php';
|
||||
|
||||
@@ -18,7 +19,7 @@ require_once dirname(__DIR__, 3).'/upgrades/data/1.0.1/migrations/BackfillEasyPa
|
||||
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
|
||||
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
|
||||
*/
|
||||
class BackfillEasyPayPgProviderTest extends TestCase
|
||||
class BackfillEasyPayPgProviderTest extends PluginTestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
@@ -30,7 +31,7 @@ class BackfillEasyPayPgProviderTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->settingsPath = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_0\Migrations\MigrateNaverpayBrandButtonKey;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
@@ -23,7 +24,7 @@ class MigrateNaverpayBrandButtonKeyTest extends PluginTestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/plugins/sirsoft-pay_kginicis/settings/setting.json');
|
||||
$this->settingsPath = ExtensionStoragePath::plugin('sirsoft-pay_kginicis', 'settings').'/setting.json';
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\PayKginicis\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -37,8 +38,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
class Upgrade_1_0_0_beta_4 implements UpgradeStepInterface
|
||||
{
|
||||
private const ECOMMERCE_SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
private const EASY_PAY_IDS = [
|
||||
'kginicis_samsung_pay',
|
||||
'kginicis_naverpay',
|
||||
@@ -48,7 +47,9 @@ class Upgrade_1_0_0_beta_4 implements UpgradeStepInterface
|
||||
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::ECOMMERCE_SETTINGS_PATH);
|
||||
// 이커머스 모듈의 주문설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로
|
||||
// 조립한다 — 확장마다 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 건드린다.
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[v1.0.0-beta.4] 이커머스 order_settings.json 없음 — 첫 진입 시 자동 생성되므로 skip');
|
||||
|
||||
+4
-3
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_0\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -20,8 +21,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class MigrateNaverpayBrandButtonKey implements DataMigration
|
||||
{
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_kginicis/settings/setting.json';
|
||||
|
||||
private const OLD_KEY = 'easy_pay_naverpay_brand_button';
|
||||
|
||||
private const NEW_KEY = 'easy_pay_show_brand_button';
|
||||
@@ -43,7 +42,9 @@ final class MigrateNaverpayBrandButtonKey implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 설정 파일 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::plugin('sirsoft-pay_kginicis', 'settings').'/setting.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[v1.0.0] KG 이니시스 설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_1\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -30,11 +31,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillEasyPayPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
|
||||
*/
|
||||
@@ -62,7 +58,9 @@ final class BackfillEasyPayPgProvider implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이커머스 모듈의 주문설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[kginicis] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_1_0\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -34,11 +35,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_kginicis/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
@@ -66,7 +62,9 @@ final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이 플러그인의 설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::plugin('sirsoft-pay_kginicis', 'settings').'/setting.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[kginicis] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
- 관리자 주문 조회·에스크로 배송등록 API가 세부 권한을 검사하도록 수정했습니다. 이전에는 관리자 계정이면 주문 권한이 없어도 주문번호·결제정보·수령인 연락처와 주소를 조회하고 배송등록까지 할 수 있었습니다. 이제 조회는 주문 조회 권한, 배송등록은 주문 수정 권한이 필요하며, 다른 결제대행사 연동과 같은 기준이 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1893)
|
||||
- 가상계좌 입금통보 주소 조회와 시스템 점검 API에도 설정 조회 권한 검사를 추가했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
## [1.0.2] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ko": "NHN KCP 결제 게이트웨이 (Standard Pay 연동)",
|
||||
"en": "NHN KCP payment gateway (Standard Pay integration)"
|
||||
},
|
||||
"g7_version": ">=7.0.5",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
|
||||
class RegisterPgProviderListener implements HookListenerInterface
|
||||
{
|
||||
@@ -139,7 +140,7 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
|
||||
private function isNhnKcpDefaultPg(): bool
|
||||
{
|
||||
$path = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! file_exists($path)) {
|
||||
return false;
|
||||
|
||||
+4
-3
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_0_beta_4\Migrations\BackfillEasyPayPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/BackfillEasyPayPgProvider.php';
|
||||
|
||||
@@ -18,7 +19,7 @@ require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/Backfil
|
||||
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
|
||||
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
|
||||
*/
|
||||
class BackfillEasyPayPgProviderTest extends TestCase
|
||||
class BackfillEasyPayPgProviderTest extends PluginTestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
@@ -30,7 +31,7 @@ class BackfillEasyPayPgProviderTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->settingsPath = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_0_beta_4\Migration
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -30,11 +31,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillEasyPayPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
|
||||
*/
|
||||
@@ -62,7 +58,9 @@ final class BackfillEasyPayPgProvider implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이커머스 모듈의 주문설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nhnkcp] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_1\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -34,11 +35,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_nhnkcp/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
@@ -66,7 +62,9 @@ final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이 플러그인의 설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::plugin('sirsoft-pay_nhnkcp', 'settings').'/setting.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nhnkcp] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- 가상계좌 환불·에스크로 배송등록·결제 서명 요청의 입력값에 형식·길이 검증을 추가했습니다. 잘못된 형식의 환불 계좌 정보(계좌번호·은행코드·예금주)는 PG 호출 전에 차단됩니다.
|
||||
|
||||
### Changed
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
- 결제 서명(SignData) 요청의 필수값 누락 응답이 표준 검증 응답(422)으로 통일되었습니다.
|
||||
- 주문·결제 정보 조회가 데이터베이스 표 이름을 직접 쓰지 않고 쇼핑몰 모듈의 정의를 따르도록 내부 정리했습니다. 표시되는 내용과 동작은 종전과 동일합니다.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ko": "나이스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
"en": "NicePayments gateway (integrated payment window)"
|
||||
},
|
||||
"g7_version": ">=7.0.5",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
|
||||
class RegisterPgProviderListener implements HookListenerInterface
|
||||
{
|
||||
@@ -109,7 +110,7 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
|
||||
private function isNicepayDefaultPg(): bool
|
||||
{
|
||||
$path = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! file_exists($path)) {
|
||||
return false;
|
||||
|
||||
+4
-3
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_0_beta_4\Migrations\BackfillEasyPayPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/BackfillEasyPayPgProvider.php';
|
||||
|
||||
@@ -18,7 +19,7 @@ require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/Backfil
|
||||
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
|
||||
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
|
||||
*/
|
||||
class BackfillEasyPayPgProviderTest extends TestCase
|
||||
class BackfillEasyPayPgProviderTest extends PluginTestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
@@ -30,7 +31,7 @@ class BackfillEasyPayPgProviderTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->settingsPath = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_0_beta_4\Mig
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -30,11 +31,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillEasyPayPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
|
||||
*/
|
||||
@@ -62,7 +58,9 @@ final class BackfillEasyPayPgProvider implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이커머스 모듈의 주문설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nicepayments] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_1\Migrations
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -34,11 +35,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-pay_nicepayments/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
@@ -66,7 +62,9 @@ final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이 플러그인의 설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::plugin('sirsoft-pay_nicepayments', 'settings').'/setting.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nicepayments] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
|
||||
- 플러그인 설정 저장이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.10 으로 상향
|
||||
|
||||
## [1.0.1] - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
"en": "TossPayments gateway (integrated payment window)"
|
||||
},
|
||||
"g7_version": ">=7.0.0",
|
||||
"g7_version": ">=7.0.10",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_1\Migrations\BackfillTossPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
@@ -37,7 +38,7 @@ class BackfillTossPgProviderTest extends PluginTestCase
|
||||
|
||||
require_once base_path('plugins/_bundled/sirsoft-tosspayments/upgrades/data/1.0.1/migrations/BackfillTossPgProvider.php');
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->settingsPath = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_0\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -34,11 +35,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이 플러그인의 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-tosspayments/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 예전 기본값 => 새 기본값. 저장값이 좌변과 정확히 같을 때만 우변으로 바꾼다.
|
||||
*
|
||||
@@ -66,7 +62,9 @@ final class BackfillShopBaseRedirectUrls implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이 플러그인의 설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::plugin('sirsoft-tosspayments', 'settings').'/setting.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[tosspayments] 설정 파일 없음 — 새 기본값으로 동작하므로 skip');
|
||||
|
||||
+4
-6
@@ -7,6 +7,7 @@ namespace App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_1\Migrations;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
@@ -29,11 +30,6 @@ use Illuminate\Support\Facades\File;
|
||||
*/
|
||||
final class BackfillTossPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 주문서형 결제수단의 ID 접두사.
|
||||
*/
|
||||
@@ -61,7 +57,9 @@ final class BackfillTossPgProvider implements DataMigration
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
// 이커머스 모듈의 주문설정 저장 경로. 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다
|
||||
// 경로를 직접 조립하면 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
|
||||
$path = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[tosspayments] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
@@ -110,7 +110,7 @@ abstract class PluginTestCase extends TestCase
|
||||
* 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다.
|
||||
*
|
||||
* 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크
|
||||
* (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면
|
||||
* (root = `plugins` 디스크, config/filesystems.php)에 setting.json 을 쓴다. 격리하지 않으면
|
||||
* 테스트가 실제 로컬 런타임 설정 파일을 덮어써 라이브 모드/자격증명이 오염된다
|
||||
* (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를
|
||||
* 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
|
||||
- 확장(템플릿·모듈·플러그인)을 제거할 때 `custom/` 에 넣어 둔 파일의 사본이 보관되며, 제거 창이 그 보관 경로를 보여 줍니다. 보관된 파일이 없으면 창은 종전대로 곧바로 닫힙니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 관리자 화면에서 게시물·상품 등의 HTML 본문을 표시할 때 쓰는 정화 라이브러리를 최신 버전으로 고정했습니다. 종전에는 이 라이브러리를 직접 지정하지 않아 다른 구성요소가 딸려 들여온 구버전이 쓰였고, 사용자 템플릿보다 낮은 버전으로 동작했습니다.
|
||||
|
||||
## [1.0.7] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
File diff suppressed because one or more lines are too long
+21
-4
@@ -16,6 +16,7 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"browser-image-compression": "2.0.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"flag-icons": "7.2.3",
|
||||
"monaco-editor": "0.54.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
@@ -3065,6 +3066,13 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
@@ -3826,10 +3834,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
||||
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.244",
|
||||
@@ -4788,6 +4799,12 @@
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor/node_modules/dompurify": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"browser-image-compression": "2.0.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"flag-icons": "7.2.3",
|
||||
"monaco-editor": "0.54.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Permission;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
@@ -130,7 +131,7 @@ class PluginSettingsControllerTest extends TestCase
|
||||
*/
|
||||
private function createTestSettingsFile(string $identifier, array $settings): void
|
||||
{
|
||||
$settingsDir = storage_path("app/plugins/{$identifier}/settings");
|
||||
$settingsDir = ExtensionStoragePath::plugin($identifier, 'settings');
|
||||
$settingsPath = $settingsDir.'/setting.json';
|
||||
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
@@ -145,7 +146,7 @@ class PluginSettingsControllerTest extends TestCase
|
||||
*/
|
||||
private function cleanupTestSettings(string $identifier): void
|
||||
{
|
||||
$pluginDir = storage_path("app/plugins/{$identifier}");
|
||||
$pluginDir = ExtensionStoragePath::plugin($identifier);
|
||||
if (File::isDirectory($pluginDir)) {
|
||||
File::deleteDirectory($pluginDir);
|
||||
}
|
||||
@@ -319,12 +320,14 @@ class PluginSettingsControllerTest extends TestCase
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
// 파일에서 값 확인
|
||||
$settingsPath = storage_path('app/plugins/sirsoft-daum_postcode/settings/setting.json');
|
||||
if (File::exists($settingsPath)) {
|
||||
$savedContent = json_decode(File::get($settingsPath), true);
|
||||
$this->assertSame('popup', $savedContent['display_mode'] ?? null);
|
||||
$this->assertSame(640, $savedContent['popup_width'] ?? null);
|
||||
}
|
||||
$settingsPath = ExtensionStoragePath::plugin('sirsoft-daum_postcode', 'settings').'/setting.json';
|
||||
// 저장이 200 으로 끝났으므로 파일은 반드시 있어야 한다 — `File::exists` 조건 안에
|
||||
// 단언을 두면 경로가 어긋났을 때 검사가 조용히 공허해진다.
|
||||
$this->assertFileExists($settingsPath);
|
||||
|
||||
$savedContent = json_decode(File::get($settingsPath), true);
|
||||
$this->assertSame('popup', $savedContent['display_mode'] ?? null);
|
||||
$this->assertSame(640, $savedContent['popup_width'] ?? null);
|
||||
|
||||
// 정리
|
||||
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
||||
@@ -357,12 +360,15 @@ class PluginSettingsControllerTest extends TestCase
|
||||
'another_unknown' => ['nested' => true],
|
||||
]);
|
||||
|
||||
$settingsPath = storage_path('app/plugins/sirsoft-daum_postcode/settings/setting.json');
|
||||
if (File::exists($settingsPath)) {
|
||||
$savedContent = json_decode(File::get($settingsPath), true);
|
||||
$this->assertArrayNotHasKey('injected_field', $savedContent);
|
||||
$this->assertArrayNotHasKey('another_unknown', $savedContent);
|
||||
}
|
||||
$settingsPath = ExtensionStoragePath::plugin('sirsoft-daum_postcode', 'settings').'/setting.json';
|
||||
// 스키마 밖 키만 담긴 요청은 저장 자체가 일어나지 않을 수 있다. 그 경우에도 단언은
|
||||
// 수행한다 — 조건 안에 두면 파일이 없을 때 검사가 0건이 되어 공허하게 통과한다.
|
||||
$savedContent = File::exists($settingsPath)
|
||||
? (json_decode(File::get($settingsPath), true) ?? [])
|
||||
: [];
|
||||
|
||||
$this->assertArrayNotHasKey('injected_field', $savedContent);
|
||||
$this->assertArrayNotHasKey('another_unknown', $savedContent);
|
||||
|
||||
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
||||
}
|
||||
|
||||
@@ -81,6 +81,38 @@ class ExtensionTestIsolationTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트는 운영 라우트 캐시로 부팅하지 않는다.
|
||||
*
|
||||
* 라우트 캐시가 있으면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 **라우트 파일이
|
||||
* 실행되지 않는다.** 그러면 allowlist 가 라우트 축에서 통째로 무력화되어, core-only 테스트가
|
||||
* 굽던 시점에 활성이던 모든 확장의 라우트를 가진 채 부팅한다.
|
||||
*
|
||||
* 위의 GDPR 라우트 단언만으로는 이 결함을 안정적으로 잡지 못한다 — GDPR 이 그 머신에
|
||||
* 설치돼 있어야만 red 가 되기 때문이다. 여기서는 **메커니즘 자체**를 단언한다.
|
||||
*
|
||||
* @effects tests_do_not_boot_with_production_route_cache
|
||||
*/
|
||||
public function test_tests_do_not_boot_with_the_production_route_cache(): void
|
||||
{
|
||||
$cachedRoutesPath = $this->app->getCachedRoutesPath();
|
||||
|
||||
// 경로 구분자를 슬래시로 통일해 판정한다 — Windows 는 역슬래시를 섞어 돌려준다.
|
||||
$normalized = strtr($cachedRoutesPath, '\\', '/');
|
||||
|
||||
$this->assertStringNotContainsString(
|
||||
'bootstrap/cache',
|
||||
$normalized,
|
||||
'테스트가 운영 라우트 캐시 경로를 본다 — tests/bootstrap.php 의 APP_ROUTES_CACHE '
|
||||
.'리다이렉트가 사라졌다. 라우트 파일이 실행되지 않아 확장 격리가 무력화된다.'
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
$this->app->routesAreCached(),
|
||||
'테스트가 캐시된 라우트로 부팅했다 — 라우트 파일이 실행되지 않아 allowlist 가 적용되지 않는다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects allowlist_inactive_when_never_configured
|
||||
*/
|
||||
@@ -99,7 +131,7 @@ class ExtensionTestIsolationTest extends TestCase
|
||||
/**
|
||||
* @effects selfExtension_returns_null_for_core_tests
|
||||
*/
|
||||
public function test_selfExtension_returns_null_for_core_tests(): void
|
||||
public function test_self_extension_returns_null_for_core_tests(): void
|
||||
{
|
||||
// 본 테스트 클래스는 tests/Feature/ 하위 (코어 테스트) →
|
||||
// selfExtension() 의 modules/plugins 경로 패턴에 매칭되지 않아 null 반환
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Models\Module;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\Template;
|
||||
@@ -1204,16 +1205,31 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
||||
);
|
||||
|
||||
// ① 디렉토리 생성은 umask 를 무력화하는 명시 chmod 를 거친다.
|
||||
//
|
||||
// 정합화 구현은 코어 공통 프리미티브(`FilePermissionHelper::hardenDirectory`)로 옮겼다.
|
||||
// 검사도 그 위임을 따라가야 한다 — 옮겨간 뒤에도 이 클래스의 소스 리터럴만 보면 실제로
|
||||
// 도는 코드가 아닌 **죽은 자리**를 지키게 되어 가드가 공허해진다.
|
||||
$makeDirectory = $this->methodBody($source, 'makeDirectory');
|
||||
$this->assertNotSame('', $makeDirectory, 'makeDirectory 를 소스에서 찾지 못했다 — 검사가 공허하다');
|
||||
$this->assertStringContainsString(
|
||||
'@chmod($dir, self::PUBLISH_DIR_MODE)',
|
||||
'FilePermissionHelper::hardenDirectory($dir, self::PUBLISH_DIR_MODE)',
|
||||
$makeDirectory,
|
||||
'게시 디렉토리 정합화가 공통 프리미티브를 거치지 않는다 — umask/소유권 규율이 이 클래스에서 갈라진다'
|
||||
);
|
||||
|
||||
$helperSource = (string) file_get_contents(
|
||||
(new \ReflectionClass(FilePermissionHelper::class))->getFileName()
|
||||
);
|
||||
$harden = $this->methodBody($helperSource, 'hardenDirectory');
|
||||
$this->assertNotSame('', $harden, 'hardenDirectory 를 소스에서 찾지 못했다 — 검사가 공허하다');
|
||||
$this->assertStringContainsString(
|
||||
'@chmod($path, $mode)',
|
||||
$harden,
|
||||
'ensureDirectoryExists 의 mode 는 umask 로 깎인다 — 명시 chmod 가 없으면 0755 로 굳는다'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'FilePermissionHelper::inheritOwnershipFromParent($dir)',
|
||||
$makeDirectory
|
||||
'static::inheritOwnershipFromParent($path)',
|
||||
$harden
|
||||
);
|
||||
|
||||
// ② 정합화는 root 갈래와 항상-실행 갈래를 모두 갖는다.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\PluginManager;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
@@ -26,7 +27,7 @@ class PluginManagerSettingsTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->testSettingsDir = storage_path('app/plugins/test-init-plugin/settings');
|
||||
$this->testSettingsDir = ExtensionStoragePath::plugin('test-init-plugin', 'settings');
|
||||
$this->testDefaultsDir = sys_get_temp_dir().'/g7_test_defaults_'.uniqid();
|
||||
|
||||
// 테스트용 디렉토리 정리
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\Helpers\SettingsMigrator;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -18,8 +19,8 @@ class SettingsMigratorTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->moduleSettingsDir = storage_path('app/modules/test-migrator-mod/settings');
|
||||
$this->pluginSettingsDir = storage_path('app/plugins/test-migrator-plug/settings');
|
||||
$this->moduleSettingsDir = ExtensionStoragePath::module('test-migrator-mod', 'settings');
|
||||
$this->pluginSettingsDir = ExtensionStoragePath::plugin('test-migrator-plug', 'settings');
|
||||
$this->moduleDir = base_path('modules/test-migrator-mod');
|
||||
|
||||
// 모듈 설정 디렉토리 및 파일 생성
|
||||
@@ -59,12 +60,12 @@ class SettingsMigratorTest extends TestCase
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory(storage_path('app/modules/test-migrator-mod'))) {
|
||||
File::deleteDirectory(storage_path('app/modules/test-migrator-mod'));
|
||||
if (File::isDirectory(ExtensionStoragePath::module('test-migrator-mod'))) {
|
||||
File::deleteDirectory(ExtensionStoragePath::module('test-migrator-mod'));
|
||||
}
|
||||
|
||||
if (File::isDirectory(storage_path('app/plugins/test-migrator-plug'))) {
|
||||
File::deleteDirectory(storage_path('app/plugins/test-migrator-plug'));
|
||||
if (File::isDirectory(ExtensionStoragePath::plugin('test-migrator-plug'))) {
|
||||
File::deleteDirectory(ExtensionStoragePath::plugin('test-migrator-plug'));
|
||||
}
|
||||
|
||||
if (File::isDirectory($this->moduleDir)) {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Contracts\Extension\ModuleInterface;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Services\LayoutService;
|
||||
use App\Services\ModuleSettingsService;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Mockery;
|
||||
@@ -48,7 +48,7 @@ class ModuleSettingsServiceTest extends TestCase
|
||||
);
|
||||
|
||||
// 테스트용 설정 디렉토리 경로
|
||||
$this->testSettingsDir = storage_path('app/modules');
|
||||
$this->testSettingsDir = dirname(ExtensionStoragePath::module('placeholder'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Services\LayoutService;
|
||||
use App\Services\PluginSettingsService;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Mockery;
|
||||
use Tests\Helpers\MocksExtensions;
|
||||
@@ -46,7 +47,7 @@ class PluginSettingsServiceTest extends TestCase
|
||||
);
|
||||
|
||||
// 테스트용 설정 디렉토리 경로
|
||||
$this->testSettingsDir = storage_path('app/plugins');
|
||||
$this->testSettingsDir = dirname(ExtensionStoragePath::plugin('placeholder'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
|
||||
/**
|
||||
* PHPUnit 테스트용 부트스트랩 파일
|
||||
*
|
||||
@@ -143,6 +145,35 @@ if (file_exists($configCacheFile)) {
|
||||
unlink($configCacheFile);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 라우트 캐시 격리 (테스트 환경 보장)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 라우트 캐시가 있으면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 **라우트 파일이
|
||||
| 아예 실행되지 않는다.** 그러면 테스트가 선언한 확장 allowlist(`requiredExtensions`)가
|
||||
| 라우트 축에서 통째로 무력화된다 — core-only 테스트인데도 굽던 시점에 활성이던 모든 확장의
|
||||
| 라우트가 등록된 채로 부팅한다. 실패는 그 사실을 단언하는 테스트에서만 드러나고, 나머지
|
||||
| 테스트는 격리가 깨진 줄 모르는 채 통과한다.
|
||||
|
|
||||
| 라우트 캐시는 확장 설치·활성화·업데이트마다 `RouteCacheHelper::rebuild()` 가 다시 굽기
|
||||
| 때문에, 개발 머신에서는 사실상 항상 존재한다.
|
||||
|
|
||||
| 삭제하지 않고 **경로를 돌린다.** 삭제하면 테스트를 한 번 돌릴 때마다 운영 중 사이트의
|
||||
| 라우트 캐시가 사라지고(재생성은 다음 확장 작업까지 일어나지 않는다), 그 사이 모든 요청이
|
||||
| 라우트 파일 스캔 경로로 떨어진다. 돌려놓은 경로의 파일은 만들지 않으므로
|
||||
| `routesAreCached()` 가 false 가 되어 라우트 파일이 정상 실행되고, 테스트 안에서
|
||||
| `route:cache` 를 굽더라도 그 산출물이 운영 캐시를 덮지 않는다.
|
||||
|
|
||||
| config 캐시(위)와 달리 삭제가 아니라 리다이렉트인 이유가 이것이다 — config 캐시는
|
||||
| `config:cache` 로 즉시 복구되지만 라우트 캐시는 복구 시점이 확장 작업에 묶여 있다.
|
||||
|
|
||||
*/
|
||||
$testingRoutesCache = 'storage/framework/testing/routes-v7.php';
|
||||
putenv('APP_ROUTES_CACHE='.$testingRoutesCache);
|
||||
$_ENV['APP_ROUTES_CACHE'] = $testingRoutesCache;
|
||||
$_SERVER['APP_ROUTES_CACHE'] = $testingRoutesCache;
|
||||
|
||||
// Composer 오토로더 로드
|
||||
$loader = require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
@@ -338,6 +369,100 @@ if (file_exists($extensionAutoloadFile)) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 확장 vendor(제3자 composer 패키지) 오토로드 등록
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `autoload-extensions.php` 의 `vendor_autoloads` 를 소비하는 진입점은 `public/index.php`
|
||||
| 와 `artisan` 뿐이라, PHPUnit 프로세스에서는 확장 전용 composer 패키지(`\HTMLPurifier` 등)가
|
||||
| 오토로드되지 않았다. 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였고, 그 결손은 아무도
|
||||
| 그 경로를 테스트하지 않는 동안 드러나지 않는다 (공개 #125).
|
||||
|
|
||||
| 확장 vendor 의 `autoload.php` 를 그대로 require 하지 않고 생성된 맵만 읽어 자체 로더를 만든다.
|
||||
| 그 오토로더를 그대로 쓰면 두 가지가 깨진다.
|
||||
|
|
||||
| 1. 확장 자신의 PSR-4 와 files 를 **활성 디렉토리**로 매핑하고 자신을 prepend 로 걸어, 위에서
|
||||
| _bundled 를 prepend 한 등록을 이긴다 — 테스트가 `_bundled` 가 아니라 활성 디렉토리 사본을
|
||||
| 검증하게 되어 "_bundled 에서만 작업한다" 는 규율이 조용히 깨진다.
|
||||
| 2. Composer 로더는 `vendorDir` 를 갖고 자신을 `ClassLoader::getRegisteredLoaders()` 맨 앞에
|
||||
| 넣는데, `Illuminate\Foundation\Testing\TestCase::createApplication()` 이
|
||||
| `Application::inferBasePath()` 로 그 첫 항목에서 base path 를 유추한다. 이후 테스트의 앱
|
||||
| 부팅이 `modules/{id}/bootstrap/app.php` 를 찾다 실패한다. (운영 진입점은 basePath 를 명시
|
||||
| 전달하므로 영향이 없다.)
|
||||
|
|
||||
| 그래서 `Modules\` / `Plugins\` 로 시작하는 항목은 전부 제외하고 — 확장 자기 코드는 위에서
|
||||
| 이미 등록했다 — `vendorDir` 없는 단일 로더로 제3자 패키지만 등록한다. 루트 오토로더가 먼저
|
||||
| 등록되어 있으므로 append 로 걸어 루트 vendor 가 우선하게 둔다(같은 패키지 중복 선언 방지).
|
||||
|
|
||||
*/
|
||||
$extensionVendorLoader = new ClassLoader;
|
||||
$extensionVendorClassMap = [];
|
||||
$extensionVendorFiles = [];
|
||||
$isExtensionOwnSymbol = static fn (string $symbol): bool => str_starts_with($symbol, 'Modules\\')
|
||||
|| str_starts_with($symbol, 'Plugins\\');
|
||||
|
||||
foreach (['modules', 'plugins'] as $extensionType) {
|
||||
foreach ([__DIR__.'/../'.$extensionType, __DIR__.'/../'.$extensionType.'/_bundled'] as $searchDir) {
|
||||
if (! is_dir($searchDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (scandir($searchDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || $entry === '_bundled' || $entry === '_pending') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vendorDir = $searchDir.'/'.$entry.'/vendor';
|
||||
$classmapFile = $vendorDir.'/composer/autoload_classmap.php';
|
||||
|
||||
if (! is_dir($vendorDir) || ! file_exists($classmapFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 먼저 발견한 것(활성 디렉토리)이 이긴다 — 같은 클래스를 뒤에서 덮어쓰지 않는다.
|
||||
foreach (require $classmapFile as $fqcn => $path) {
|
||||
if (! $isExtensionOwnSymbol($fqcn) && ! isset($extensionVendorClassMap[$fqcn])) {
|
||||
$extensionVendorClassMap[$fqcn] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$psr4File = $vendorDir.'/composer/autoload_psr4.php';
|
||||
if (file_exists($psr4File)) {
|
||||
foreach (require $psr4File as $prefix => $paths) {
|
||||
if (! $isExtensionOwnSymbol($prefix)) {
|
||||
$extensionVendorLoader->addPsr4($prefix, $paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 패키지가 요구하는 부트스트랩 파일(HTMLPurifier 의 HTMLPURIFIER_PREFIX 정의 등).
|
||||
// vendor 디렉토리 바깥의 항목(확장 자신의 helpers)은 제외한다.
|
||||
$filesFile = $vendorDir.'/composer/autoload_files.php';
|
||||
if (file_exists($filesFile)) {
|
||||
$vendorReal = realpath($vendorDir) ?: $vendorDir;
|
||||
|
||||
foreach (require $filesFile as $file) {
|
||||
if (str_starts_with(realpath($file) ?: $file, $vendorReal)) {
|
||||
$extensionVendorFiles[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($extensionVendorClassMap !== []) {
|
||||
$extensionVendorLoader->addClassMap($extensionVendorClassMap);
|
||||
}
|
||||
|
||||
// vendorDir 를 주지 않았으므로 register() 가 registeredLoaders 를 건드리지 않는다.
|
||||
$extensionVendorLoader->register();
|
||||
|
||||
foreach ($extensionVendorFiles as $extensionVendorFile) {
|
||||
require_once $extensionVendorFile;
|
||||
}
|
||||
|
||||
// 모듈/플러그인 테스트 네임스페이스 등록 (tests/ 디렉토리)
|
||||
$modulesDir = __DIR__.'/../modules';
|
||||
if (is_dir($modulesDir)) {
|
||||
|
||||
@@ -67,6 +67,7 @@ effects:
|
||||
- allowlisted_plugin_middleware_is_registered_in_web_group
|
||||
- selfExtension_returns_null_for_core_tests
|
||||
- production_env_keeps_full_extension_loading
|
||||
- tests_do_not_boot_with_production_route_cache
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Extension/ExtensionTestAllowlistTest.php
|
||||
|
||||
Reference in New Issue
Block a user