Merge pull request from gnuboard:HeuJung/issue603

fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
This commit is contained in:
정정홍
2026-08-21 17:48:10 +09:00
committed by GitHub
35 changed files with 1362 additions and 239 deletions
+8 -1
View File
@@ -584,10 +584,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 | | typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 | | 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 | | 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) |
| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 |
| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 |
| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 |
| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 |
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다. `message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다.
### Listener 데이터 접근 ### Listener 데이터 접근
@@ -1051,6 +1056,8 @@ BaseApiController (최상위)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행 필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지 필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시") 필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다
필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용 필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행 필수: 모든 확장 작업은 Artisan 커맨드로 수행
``` ```
+10
View File
@@ -12,6 +12,16 @@
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다. - 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다. - 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
### Changed
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
### Fixed
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
## [7.0.7] - 2026-08-19 ## [7.0.7] - 2026-08-19
### Security ### Security
+3
View File
@@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface; use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\ModuleCacheDriver; use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver; use App\Extension\Storage\ModuleStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use ReflectionClass; use ReflectionClass;
@@ -21,6 +22,8 @@ use ReflectionClass;
*/ */
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
{ {
use ReportsLifecycleFailure;
/** /**
* 모듈 디렉토리 경로 (캐시) * 모듈 디렉토리 경로 (캐시)
*/ */
+3
View File
@@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface; use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\PluginCacheDriver; use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Storage\PluginStorageDriver; use App\Extension\Storage\PluginStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use ReflectionClass; use ReflectionClass;
@@ -24,6 +25,8 @@ use ReflectionClass;
*/ */
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
{ {
use ReportsLifecycleFailure;
/** /**
* 플러그인 디렉토리 경로 (캐시) * 플러그인 디렉토리 경로 (캐시)
*/ */
+5 -1
View File
@@ -74,8 +74,12 @@ class ExtensionManager
* *
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다. * 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다. * 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
*
* 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
* Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
* 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
*/ */
protected function regenerateHookCache(): void public function regenerateHookCache(): void
{ {
try { try {
$moduleManager = app(ModuleManager::class); $moduleManager = app(ModuleManager::class);
+89 -16
View File
@@ -311,6 +311,7 @@ class ModuleManager implements ModuleManagerInterface
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message) * @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드 * @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부 * @param bool $force 강제 설치 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 설치 성공 여부 * @return bool 설치 성공 여부
* *
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시 * @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
@@ -320,7 +321,10 @@ class ModuleManager implements ModuleManagerInterface
?\Closure $onProgress = null, ?\Closure $onProgress = null,
VendorMode $vendorMode = VendorMode::Auto, VendorMode $vendorMode = VendorMode::Auto,
bool $force = false, bool $force = false,
?string &$failureReason = null,
): bool { ): bool {
$failureReason = null;
// identifier 형식 검증 (내부 호출 방어) // identifier 형식 검증 (내부 호출 방어)
ExtensionManager::validateIdentifierFormat($moduleName); ExtensionManager::validateIdentifierFormat($moduleName);
@@ -404,9 +408,12 @@ class ModuleManager implements ModuleManagerInterface
$this->validateSeoVariables($module, 'module'); $this->validateSeoVariables($module, 'module');
// 모듈 설치 실행 // 모듈 설치 실행
$module->clearLifecycleFailureReason();
$result = $module->install(); $result = $module->install();
if (! $result) { if (! $result) {
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
return false; return false;
} }
@@ -540,9 +547,15 @@ class ModuleManager implements ModuleManagerInterface
{ {
$module = $this->getModule($moduleName); $module = $this->getModule($moduleName);
if (! $module) { if (! $module) {
return ['success' => false, 'layouts_registered' => 0]; return [
'success' => false,
'layouts_registered' => 0,
'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
];
} }
$module->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크 // 상태 가드: 진행 중 상태 체크
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier()); $record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
if ($record) { if ($record) {
@@ -628,6 +641,13 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
// 새 애플리케이션을 부팅해 "캐시된" 활성 모듈 목록을 읽는다. 여기서 비우지 않으면
// 방금 활성으로 바뀐 이 모듈이 목록에서 빠진 채 라우트가 박제되고,
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
self::invalidateModuleStatusCache();
// soft deleted된 모듈 레이아웃 복원 (재활성화 시) // soft deleted된 모듈 레이아웃 복원 (재활성화 시)
$this->restoreModuleLayouts($module->getIdentifier()); $this->restoreModuleLayouts($module->getIdentifier());
@@ -650,9 +670,6 @@ class ModuleManager implements ModuleManagerInterface
$this->incrementExtensionCacheVersion(); $this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild(); RouteCacheHelper::rebuild();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이 // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache(); IdentityPolicy::flushRouteScopeCache();
@@ -667,7 +684,18 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.activated', $moduleName); HookManager::doAction('core.modules.activated', $moduleName);
} }
return ['success' => $result, 'layouts_registered' => $layoutsRegistered]; if (! $result) {
// 모듈이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
return [
'success' => false,
'layouts_registered' => $layoutsRegistered,
'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
];
}
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
} }
/** /**
@@ -714,9 +742,15 @@ class ModuleManager implements ModuleManagerInterface
): array { ): array {
$module = $this->getModule($moduleName); $module = $this->getModule($moduleName);
if (! $module) { if (! $module) {
return ['success' => false, 'layouts_deleted' => 0]; return [
'success' => false,
'layouts_deleted' => 0,
'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
];
} }
$module->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크 // 상태 가드: 진행 중 상태 체크
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier()); $record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
if ($record) { if ($record) {
@@ -777,6 +811,12 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 모듈 목록을 읽으므로,
// 여기서 비우지 않으면 방금 비활성으로 바꾼 모듈의 라우트가 그대로 박제되어
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
self::invalidateModuleStatusCache();
// 모듈 레이아웃 soft delete // 모듈 레이아웃 soft delete
$layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier()); $layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier());
@@ -796,9 +836,6 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 자체 캐시 전체 정리 // 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module); $this->flushModuleCache($module);
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서 // 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서
// 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존) // 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
@@ -811,7 +848,15 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier()); HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
} }
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted]; if (! $result) {
return [
'success' => false,
'layouts_deleted' => $layoutsDeleted,
'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
];
}
return ['success' => true, 'layouts_deleted' => $layoutsDeleted];
} }
/** /**
@@ -859,12 +904,19 @@ class ModuleManager implements ModuleManagerInterface
* @param string $moduleName 제거할 모듈명 * @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부 * @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message) * @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부 * @return bool 제거 성공 여부
* *
* @throws \Exception 모듈을 찾을 수 없을 때 * @throws \Exception 모듈을 찾을 수 없을 때
*/ */
public function uninstallModule(string $moduleName, bool $deleteData = false, ?\Closure $onProgress = null): bool public function uninstallModule(
{ string $moduleName,
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
): bool {
$failureReason = null;
// 상태 가드: 진행 중 상태 체크 // 상태 가드: 진행 중 상태 체크
$existingRecord = $this->moduleRepository->findByIdentifier($moduleName); $existingRecord = $this->moduleRepository->findByIdentifier($moduleName);
if ($existingRecord) { if ($existingRecord) {
@@ -897,8 +949,13 @@ class ModuleManager implements ModuleManagerInterface
DB::beginTransaction(); DB::beginTransaction();
// 모듈 제거 실행 // 모듈 제거 실행
$module->clearLifecycleFailureReason();
$result = $module->uninstall(); $result = $module->uninstall();
if (! $result) {
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
}
if ($result) { if ($result) {
// 권한·메뉴·역할은 $deleteData=true 시에만 삭제. // 권한·메뉴·역할은 $deleteData=true 시에만 삭제.
// false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다. // false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다.
@@ -960,6 +1017,12 @@ class ModuleManager implements ModuleManagerInterface
// 오토로드 병합 실행 (트랜잭션 외부에서 실행) // 오토로드 병합 실행 (트랜잭션 외부에서 실행)
if ($result) { if ($result) {
// 모듈 상태 캐시 무효화 — DB 에서 모듈 행을 지운 직후(커밋 직후)에 둔다.
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
// 캐시된 활성 모듈 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된 모듈이
// 목록에 남은 채로 라우트·훅이 박제된다.
self::invalidateModuleStatusCache();
$onProgress?->__invoke('autoload', '오토로드 갱신 중...'); $onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload(); $this->extensionManager->updateComposerAutoload();
@@ -977,9 +1040,6 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 자체 캐시 전체 정리 // 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module); $this->flushModuleCache($module);
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외. // 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외.
ExtensionMiddlewareRegistry::flush(); ExtensionMiddlewareRegistry::flush();
@@ -4495,6 +4555,13 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// 모듈 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
// updateComposerAutoload() 가 DB 를 재조회해 이 모듈을 비활성으로 판정하고
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
self::invalidateModuleStatusCache();
// 9. 레이아웃 갱신 (이전 상태가 active였으면) // 9. 레이아웃 갱신 (이전 상태가 active였으면)
// refreshModuleLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함 // refreshModuleLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
$onProgress?->__invoke('layout', '레이아웃 갱신 중...'); $onProgress?->__invoke('layout', '레이아웃 갱신 중...');
@@ -4518,7 +4585,13 @@ class ModuleManager implements ModuleManagerInterface
$this->clearAllTemplateRoutesCaches(); $this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion(); $this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild(); RouteCacheHelper::rebuild();
self::invalidateModuleStatusCache();
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
// 그 시점 이 모듈이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
$this->extensionManager->regenerateHookCache();
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거) // 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
HookManager::doAction('core.modules.updated', $identifier); HookManager::doAction('core.modules.updated', $identifier);
+86 -15
View File
@@ -296,6 +296,7 @@ class PluginManager implements PluginManagerInterface
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message) * @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드 * @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부 * @param bool $force 강제 설치 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 설치 성공 여부 * @return bool 설치 성공 여부
* *
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시 * @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
@@ -305,7 +306,10 @@ class PluginManager implements PluginManagerInterface
?\Closure $onProgress = null, ?\Closure $onProgress = null,
VendorMode $vendorMode = VendorMode::Auto, VendorMode $vendorMode = VendorMode::Auto,
bool $force = false, bool $force = false,
?string &$failureReason = null,
): bool { ): bool {
$failureReason = null;
// identifier 형식 검증 (내부 호출 방어) // identifier 형식 검증 (내부 호출 방어)
ExtensionManager::validateIdentifierFormat($pluginName); ExtensionManager::validateIdentifierFormat($pluginName);
@@ -386,8 +390,13 @@ class PluginManager implements PluginManagerInterface
// 플러그인 설치 실행 // 플러그인 설치 실행
$onProgress?->__invoke('validate', '검증 중...'); $onProgress?->__invoke('validate', '검증 중...');
$plugin->clearLifecycleFailureReason();
$result = $plugin->install(); $result = $plugin->install();
if (! $result) {
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
if (! $result) { if (! $result) {
return false; return false;
} }
@@ -522,9 +531,15 @@ class PluginManager implements PluginManagerInterface
{ {
$plugin = $this->getPlugin($pluginName); $plugin = $this->getPlugin($pluginName);
if (! $plugin) { if (! $plugin) {
return ['success' => false, 'layouts_registered' => 0]; return [
'success' => false,
'layouts_registered' => 0,
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
];
} }
$plugin->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크 // 상태 가드: 진행 중 상태 체크
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier()); $record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
if ($record) { if ($record) {
@@ -613,6 +628,13 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
// 새 애플리케이션을 부팅해 "캐시된" 활성 플러그인 목록을 읽는다. 여기서 비우지 않으면
// 방금 활성으로 바뀐 이 플러그인이 목록에서 빠진 채 라우트가 박제되고,
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
self::invalidatePluginStatusCache();
// soft deleted된 플러그인 레이아웃 복원 (재활성화 시) // soft deleted된 플러그인 레이아웃 복원 (재활성화 시)
$this->restorePluginLayouts($plugin->getIdentifier()); $this->restorePluginLayouts($plugin->getIdentifier());
@@ -635,9 +657,6 @@ class PluginManager implements PluginManagerInterface
$this->incrementExtensionCacheVersion(); $this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild(); RouteCacheHelper::rebuild();
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이 // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache(); IdentityPolicy::flushRouteScopeCache();
@@ -652,7 +671,18 @@ class PluginManager implements PluginManagerInterface
HookManager::doAction('core.plugins.activated', $pluginName); HookManager::doAction('core.plugins.activated', $pluginName);
} }
return ['success' => $result, 'layouts_registered' => $layoutsRegistered]; if (! $result) {
// 플러그인이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
return [
'success' => false,
'layouts_registered' => $layoutsRegistered,
'reason' => $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'),
];
}
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
} }
/** /**
@@ -699,9 +729,15 @@ class PluginManager implements PluginManagerInterface
): array { ): array {
$plugin = $this->getPlugin($pluginName); $plugin = $this->getPlugin($pluginName);
if (! $plugin) { if (! $plugin) {
return ['success' => false, 'layouts_deleted' => 0]; return [
'success' => false,
'layouts_deleted' => 0,
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
];
} }
$plugin->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크 // 상태 가드: 진행 중 상태 체크
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier()); $record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
if ($record) { if ($record) {
@@ -765,6 +801,12 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 플러그인 목록을 읽으므로,
// 여기서 비우지 않으면 방금 비활성으로 바꾼 플러그인의 라우트가 그대로 박제되어
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
self::invalidatePluginStatusCache();
// 플러그인 레이아웃 soft delete // 플러그인 레이아웃 soft delete
$layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier()); $layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier());
@@ -784,9 +826,6 @@ class PluginManager implements PluginManagerInterface
// 플러그인 자체 캐시 전체 정리 // 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin); $this->flushPluginCache($plugin);
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce // 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce
// 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존) // 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
@@ -801,6 +840,10 @@ class PluginManager implements PluginManagerInterface
$response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted]; $response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
if (! $result) {
$response['reason'] = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
if (! empty($driverWarnings)) { if (! empty($driverWarnings)) {
$response['driver_warnings'] = $driverWarnings; $response['driver_warnings'] = $driverWarnings;
} }
@@ -888,12 +931,19 @@ class PluginManager implements PluginManagerInterface
* @param string $pluginName 제거할 플러그인명 * @param string $pluginName 제거할 플러그인명
* @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부 * @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message) * @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부 * @return bool 제거 성공 여부
* *
* @throws \Exception 플러그인을 찾을 수 없을 때 * @throws \Exception 플러그인을 찾을 수 없을 때
*/ */
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?\Closure $onProgress = null): bool public function uninstallPlugin(
{ string $pluginName,
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
): bool {
$failureReason = null;
// 상태 가드: 진행 중 상태 체크 // 상태 가드: 진행 중 상태 체크
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName); $existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
if ($existingRecord) { if ($existingRecord) {
@@ -922,8 +972,13 @@ class PluginManager implements PluginManagerInterface
DB::beginTransaction(); DB::beginTransaction();
// 플러그인 제거 실행 // 플러그인 제거 실행
$plugin->clearLifecycleFailureReason();
$result = $plugin->uninstall(); $result = $plugin->uninstall();
if (! $result) {
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
if ($result) { if ($result) {
// 권한/역할은 $deleteData=true 시에만 삭제. // 권한/역할은 $deleteData=true 시에만 삭제.
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제" // 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
@@ -984,6 +1039,12 @@ class PluginManager implements PluginManagerInterface
// 트랜잭션 외부에서 실행 // 트랜잭션 외부에서 실행
if ($result) { if ($result) {
// 플러그인 상태 캐시 무효화 — DB 에서 플러그인 행을 지운 직후(커밋 직후)에 둔다.
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
// 캐시된 활성 플러그인 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된
// 플러그인이 목록에 남은 채로 라우트·훅이 박제된다.
self::invalidatePluginStatusCache();
// 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우) // 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우)
if ($deleteData) { if ($deleteData) {
$this->deletePluginSettingsDirectory($plugin); $this->deletePluginSettingsDirectory($plugin);
@@ -1009,9 +1070,6 @@ class PluginManager implements PluginManagerInterface
// 플러그인 자체 캐시 전체 정리 // 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin); $this->flushPluginCache($plugin);
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외. // 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
ExtensionMiddlewareRegistry::flush(); ExtensionMiddlewareRegistry::flush();
@@ -4725,6 +4783,13 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// 플러그인 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
// updateComposerAutoload() 가 DB 를 재조회해 이 플러그인을 비활성으로 판정하고
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
self::invalidatePluginStatusCache();
// 9. 레이아웃 갱신 (이전 상태가 active였으면) // 9. 레이아웃 갱신 (이전 상태가 active였으면)
// refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함 // refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
$onProgress?->__invoke('layout', '레이아웃 갱신 중...'); $onProgress?->__invoke('layout', '레이아웃 갱신 중...');
@@ -4748,7 +4813,13 @@ class PluginManager implements PluginManagerInterface
$this->clearAllTemplateRoutesCaches(); $this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion(); $this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild(); RouteCacheHelper::rebuild();
self::invalidatePluginStatusCache();
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
// 그 시점 이 플러그인이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
$this->extensionManager->regenerateHookCache();
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거) // 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
HookManager::doAction('core.plugins.updated', $identifier); HookManager::doAction('core.plugins.updated', $identifier);
+6
View File
@@ -672,15 +672,21 @@ class TemplateManager implements TemplateManagerInterface
* @param string $templateName 비활성화할 템플릿명 (identifier) * @param string $templateName 비활성화할 템플릿명 (identifier)
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core) * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약 * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 비활성화 성공 여부 * @return bool 비활성화 성공 여부
*/ */
public function deactivateTemplate( public function deactivateTemplate(
string $templateName, string $templateName,
string $reason = DeactivationReason::Manual->value, string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null, ?string $incompatibleRequiredVersion = null,
?string &$failureReason = null,
): bool { ): bool {
$failureReason = null;
$template = $this->getTemplate($templateName); $template = $this->getTemplate($templateName);
if (! $template) { if (! $template) {
$failureReason = __('templates.errors.not_found', ['template' => $templateName]);
return false; return false;
} }
@@ -0,0 +1,58 @@
<?php
namespace App\Extension\Traits;
/**
* 확장이 수명주기 훅에서 실패 사유를 알리는 통로.
*
* `install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로,
* 확장이 "왜" 거부했는지가 호출자에게 전달되지 않는다. 그 결과 관리자 화면에는
* 원인 자리가 빈 실패 문구만 남는다.
*
* 확장은 `failWith()` 로 사유를 남기며 false 를 돌려주고, 코어(Manager)는
* `getLifecycleFailureReason()` 으로 그 사유를 읽어 응답에 싣는다.
* 사유를 남기지 않은 확장은 null 이며, 이 경우 코어가 일반 문구로 대체한다.
*
* 사유는 이미 번역된 문장이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
*/
trait ReportsLifecycleFailure
{
/** @var string|null 마지막 수명주기 실패 사유 (번역된 문장) */
protected ?string $lifecycleFailureReason = null;
/**
* 마지막 수명주기 훅이 남긴 실패 사유를 반환합니다.
*
* @return string|null 실패 사유. 남기지 않았으면 null
*/
public function getLifecycleFailureReason(): ?string
{
return $this->lifecycleFailureReason;
}
/**
* 실패 사유를 남기고 false 를 반환합니다.
*
* 수명주기 훅에서 `return $this->failWith(__('...'));` 형태로 사용합니다.
*
* @param string $reason 운영자에게 보일 실패 사유 (번역된 문장)
* @return false 언제나 false
*/
protected function failWith(string $reason): bool
{
$this->lifecycleFailureReason = $reason;
return false;
}
/**
* 직전 실패 사유를 지웁니다.
*
* 같은 확장 인스턴스로 수명주기 훅을 다시 부르기 전에 코어가 호출합니다.
* 지우지 않으면 이전 실패의 사유가 다음 성공/실패에 그대로 따라붙는다.
*/
public function clearLifecycleFailureReason(): void
{
$this->lifecycleFailureReason = null;
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin; namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope; use App\Enums\LanguagePackScope;
use App\Exceptions\ModuleOperationException;
use App\Extension\Vendor\VendorMode; use App\Extension\Vendor\VendorMode;
use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks; use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
@@ -199,7 +200,7 @@ class ModuleController extends AdminBaseController
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort) // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []); $this->installSelectedDependencies($validated['dependencies'] ?? []);
$module = $this->moduleService->installModule($moduleName, $vendorMode); $module = $this->moduleService->installModule($moduleName, $vendorMode, false, $installFailureReason);
if ($module) { if ($module) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치 // cascade 2단계: 동반 번들 언어팩 best-effort 설치
@@ -210,7 +211,9 @@ class ModuleController extends AdminBaseController
return $this->success('module.install_success', $payload, 201); return $this->success('module.install_success', $payload, 201);
} else { } else {
return $this->error('module.install_failed'); return $this->error('module.install_failed', 400, null, [
'error' => $installFailureReason ?? __('modules.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로 // Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -271,10 +274,12 @@ class ModuleController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks, 'pending_language_packs' => $pendingLanguagePacks,
])); ]));
} else { } else {
return $this->error('module.activate_failed'); return $this->error('module.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('module.activate_failed', 422, $e->errors()); return $this->error('module.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -320,10 +325,12 @@ class ModuleController extends AdminBaseController
return $this->success('module.deactivate_success', $result); return $this->success('module.deactivate_success', $result);
} else { } else {
return $this->error('module.deactivate_failed'); return $this->error('module.deactivate_failed', 400, null, [
'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('module.deactivate_failed', 422, $e->errors()); return $this->error('module.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -383,15 +390,17 @@ class ModuleController extends AdminBaseController
$moduleName = $validated['module_name']; $moduleName = $validated['module_name'];
$deleteData = $validated['delete_data'] ?? false; $deleteData = $validated['delete_data'] ?? false;
$result = $this->moduleService->uninstallModule($moduleName, $deleteData); $result = $this->moduleService->uninstallModule($moduleName, $deleteData, $uninstallFailureReason);
if ($result) { if ($result) {
return $this->success('module.uninstall_success'); return $this->success('module.uninstall_success');
} else { } else {
return $this->error('module.uninstall_failed'); return $this->error('module.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('module.uninstall_failed', 422, $e->errors()); return $this->error('module.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -433,8 +442,10 @@ class ModuleController extends AdminBaseController
new ModuleResource($module), new ModuleResource($module),
201 201
); );
} catch (\RuntimeException $e) { } catch (ModuleOperationException $e) {
return $this->error($e->getMessage(), 422); // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]); return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
} }
@@ -457,8 +468,10 @@ class ModuleController extends AdminBaseController
new ModuleResource($module), new ModuleResource($module),
201 201
); );
} catch (\RuntimeException $e) { } catch (ModuleOperationException $e) {
return $this->error($e->getMessage(), 422); // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]); return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
} }
@@ -476,7 +489,7 @@ class ModuleController extends AdminBaseController
return $this->success('modules.check_updates_success', $result); return $this->success('modules.check_updates_success', $result);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('modules.check_updates_failed', 422, $e->errors()); return $this->error('modules.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -505,7 +518,7 @@ class ModuleController extends AdminBaseController
return $this->success('modules.check_modified_layouts_success', $result); return $this->success('modules.check_modified_layouts_success', $result);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors()); return $this->error('modules.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -596,10 +609,12 @@ class ModuleController extends AdminBaseController
new ModuleResource($module) new ModuleResource($module)
); );
} else { } else {
return $this->error('module.refresh_layouts_failed'); return $this->error('module.refresh_layouts_failed', 400, null, [
'error' => __('modules.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('module.refresh_layouts_failed', 422, $e->errors()); return $this->error('module.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin; namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope; use App\Enums\LanguagePackScope;
use App\Exceptions\PluginOperationException;
use App\Extension\Vendor\VendorMode; use App\Extension\Vendor\VendorMode;
use App\Helpers\PermissionHelper; use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -188,7 +189,7 @@ class PluginController extends AdminBaseController
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort) // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []); $this->installSelectedDependencies($validated['dependencies'] ?? []);
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode); $pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode, false, $installFailureReason);
if ($pluginInfo) { if ($pluginInfo) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치 // cascade 2단계: 동반 번들 언어팩 best-effort 설치
@@ -199,7 +200,9 @@ class PluginController extends AdminBaseController
return $this->success('plugins.install_success', $payload); return $this->success('plugins.install_success', $payload);
} else { } else {
return $this->error('plugins.install_failed'); return $this->error('plugins.install_failed', 400, null, [
'error' => $installFailureReason ?? __('plugins.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로 // Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -260,13 +263,16 @@ class PluginController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks, 'pending_language_packs' => $pendingLanguagePacks,
])); ]));
} else { } else {
return $this->error('plugins.activate_failed'); return $this->error('plugins.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error( return $this->error(
'plugins.activate_validation_failed', 'plugins.activate_validation_failed',
422, 422,
$e->errors() $e->errors(),
['error' => $e->getMessage()]
); );
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error( return $this->error(
@@ -317,13 +323,16 @@ class PluginController extends AdminBaseController
return $this->success('plugins.deactivate_success', $result); return $this->success('plugins.deactivate_success', $result);
} else { } else {
return $this->error('plugins.deactivate_failed'); return $this->error('plugins.deactivate_failed', 400, null, [
'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error( return $this->error(
'plugins.deactivate_validation_failed', 'plugins.deactivate_validation_failed',
422, 422,
$e->errors() $e->errors(),
['error' => $e->getMessage()]
); );
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error( return $this->error(
@@ -388,18 +397,21 @@ class PluginController extends AdminBaseController
$pluginName = $validated['plugin_name']; $pluginName = $validated['plugin_name'];
$deleteData = $validated['delete_data'] ?? false; $deleteData = $validated['delete_data'] ?? false;
$result = $this->pluginService->uninstallPlugin($pluginName, $deleteData); $result = $this->pluginService->uninstallPlugin($pluginName, $deleteData, $uninstallFailureReason);
if ($result) { if ($result) {
return $this->success('plugins.uninstall_success'); return $this->success('plugins.uninstall_success');
} else { } else {
return $this->error('plugins.uninstall_failed'); return $this->error('plugins.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error( return $this->error(
'plugins.uninstall_validation_failed', 'plugins.uninstall_validation_failed',
422, 422,
$e->errors() $e->errors(),
['error' => $e->getMessage()]
); );
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error( return $this->error(
@@ -444,8 +456,10 @@ class PluginController extends AdminBaseController
new PluginResource($plugin), new PluginResource($plugin),
201 201
); );
} catch (\RuntimeException $e) { } catch (PluginOperationException $e) {
return $this->error($e->getMessage(), 422); // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]); return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
} }
@@ -468,8 +482,10 @@ class PluginController extends AdminBaseController
new PluginResource($plugin), new PluginResource($plugin),
201 201
); );
} catch (\RuntimeException $e) { } catch (PluginOperationException $e) {
return $this->error($e->getMessage(), 422); // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]); return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
} }
@@ -487,7 +503,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.check_updates_success', $result); return $this->success('plugins.check_updates_success', $result);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('plugins.check_updates_failed', 422, $e->errors()); return $this->error('plugins.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -512,7 +528,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.check_modified_layouts_success', $result); return $this->success('plugins.check_modified_layouts_success', $result);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors()); return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -608,13 +624,16 @@ class PluginController extends AdminBaseController
'unchanged' => $result['unchanged'], 'unchanged' => $result['unchanged'],
]); ]);
} else { } else {
return $this->error('plugins.refresh_layouts_failed'); return $this->error('plugins.refresh_layouts_failed', 400, null, [
'error' => __('plugins.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error( return $this->error(
'plugins.refresh_layouts_validation_failed', 'plugins.refresh_layouts_validation_failed',
422, 422,
$e->errors() $e->errors(),
['error' => $e->getMessage()]
); );
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error( return $this->error(
@@ -113,10 +113,12 @@ class PluginSettingsController extends AdminBaseController
// 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment). // 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment).
$settings = $request->validated(); $settings = $request->validated();
$result = $this->pluginSettingsService->save($identifier, $settings); $result = $this->pluginSettingsService->save($identifier, $settings, $failureReason);
if (! $result) { if (! $result) {
return $this->error('plugins.settings.update_failed', 500); return $this->error('plugins.settings.update_failed', 500, null, [
'error' => $failureReason ?? __('plugins.errors.unknown_error'),
]);
} }
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로 // 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin; namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope; use App\Enums\LanguagePackScope;
use App\Exceptions\TemplateOperationException;
use App\Helpers\PermissionHelper; use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks; use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
@@ -169,7 +170,9 @@ class TemplateController extends AdminBaseController
return $this->success('templates.install_success', $payload, 201); return $this->success('templates.install_success', $payload, 201);
} else { } else {
return $this->error('templates.install_failed'); return $this->error('templates.install_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로 // Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -230,10 +233,12 @@ class TemplateController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks, 'pending_language_packs' => $pendingLanguagePacks,
])); ]));
} else { } else {
return $this->error('templates.activate_failed'); return $this->error('templates.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('templates.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('templates.activate_failed', 422, $e->errors()); return $this->error('templates.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -249,7 +254,7 @@ class TemplateController extends AdminBaseController
{ {
try { try {
$templateName = $request->validated()['template_name']; $templateName = $request->validated()['template_name'];
$template = $this->templateService->deactivateTemplate($templateName); $template = $this->templateService->deactivateTemplate($templateName, $deactivateFailureReason);
if ($template) { if ($template) {
return $this->successWithResource( return $this->successWithResource(
@@ -257,10 +262,12 @@ class TemplateController extends AdminBaseController
new TemplateResource($template) new TemplateResource($template)
); );
} else { } else {
return $this->error('templates.deactivate_failed'); return $this->error('templates.deactivate_failed', 400, null, [
'error' => $deactivateFailureReason ?? __('templates.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('templates.deactivate_failed', 422, $e->errors()); return $this->error('templates.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -284,10 +291,12 @@ class TemplateController extends AdminBaseController
if ($result) { if ($result) {
return $this->success('templates.uninstall_success'); return $this->success('templates.uninstall_success');
} else { } else {
return $this->error('templates.uninstall_failed'); return $this->error('templates.uninstall_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('templates.uninstall_failed', 422, $e->errors()); return $this->error('templates.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -348,8 +357,10 @@ class TemplateController extends AdminBaseController
new TemplateResource($template), new TemplateResource($template),
201 201
); );
} catch (\RuntimeException $e) { } catch (TemplateOperationException $e) {
return $this->error($e->getMessage(), 422); // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]); return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
} }
@@ -372,8 +383,10 @@ class TemplateController extends AdminBaseController
new TemplateResource($template), new TemplateResource($template),
201 201
); );
} catch (\RuntimeException $e) { } catch (TemplateOperationException $e) {
return $this->error($e->getMessage(), 422); // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]); return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
} }
@@ -397,10 +410,12 @@ class TemplateController extends AdminBaseController
new TemplateResource($template) new TemplateResource($template)
); );
} else { } else {
return $this->error('templates.refresh_layouts_failed'); return $this->error('templates.refresh_layouts_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
} }
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('templates.refresh_layouts_failed', 422, $e->errors()); return $this->error('templates.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -418,7 +433,7 @@ class TemplateController extends AdminBaseController
return $this->success('templates.check_updates_success', $result); return $this->success('templates.check_updates_success', $result);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('templates.check_updates_failed', 422, $e->errors()); return $this->error('templates.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -446,7 +461,7 @@ class TemplateController extends AdminBaseController
return $this->success('templates.check_modified_layouts_success', $result); return $this->success('templates.check_modified_layouts_success', $result);
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors()); return $this->error('templates.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
} }
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Extension\ExtensionManager; use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist; use App\Extension\Testing\ExtensionTestAllowlist;
use App\Extension\Traits\CachesPluginStatus;
use App\Support\InstallerContext; use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
class PluginRouteServiceProvider extends ServiceProvider class PluginRouteServiceProvider extends ServiceProvider
{ {
use CachesPluginStatus;
/** /**
* The path to the "home" route for your application. * The path to the "home" route for your application.
* *
@@ -33,6 +36,8 @@ class PluginRouteServiceProvider extends ServiceProvider
/** /**
* 플러그인의 라우트 파일들을 로드합니다. * 플러그인의 라우트 파일들을 로드합니다.
*
* 활성화된 플러그인만 라우트를 등록합니다.
*/ */
protected function loadPluginRoutes(): void protected function loadPluginRoutes(): void
{ {
@@ -65,6 +70,11 @@ class PluginRouteServiceProvider extends ServiceProvider
} }
} }
// 활성화된 플러그인 identifier 목록 가져오기.
// 같은 목록을 PluginManager·PluginServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로
// 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다.
$activePluginIdentifiers = self::getActivePluginIdentifiers();
$plugins = File::directories($pluginsPath); $plugins = File::directories($pluginsPath);
$allowlistActive = ExtensionTestAllowlist::isActive(); $allowlistActive = ExtensionTestAllowlist::isActive();
@@ -77,6 +87,13 @@ class PluginRouteServiceProvider extends ServiceProvider
continue; continue;
} }
// 활성화된 플러그인만 라우트 로드 (모듈과 동일 기준).
// 이 게이트가 없으면 비활성 플러그인의 API 가 계속 호출 가능해, 화면·메뉴만
// 사라지고 기능은 살아 있는 상태가 된다.
if (! in_array($pluginName, $activePluginIdentifiers)) {
continue;
}
// 플러그인 파일이 존재하는지 확인 // 플러그인 파일이 존재하는지 확인
if (! File::exists($pluginFile)) { if (! File::exists($pluginFile)) {
continue; continue;
+6 -1
View File
@@ -763,7 +763,12 @@ class LanguagePackService
$response = Http::timeout(120)->get($url); $response = Http::timeout(120)->get($url);
if (! $response->successful()) { if (! $response->successful()) {
throw new LanguagePackOperationException('language_packs.errors.download_failed', ['url' => $url]); // 응답 상태를 사유로 싣는다 — 비우면 치환 자리가 남아 관리자 화면에
// 리터럴 ':error' 가 그대로 노출된다.
throw new LanguagePackOperationException('language_packs.errors.download_failed', [
'url' => $url,
'error' => 'HTTP '.$response->status(),
]);
} }
File::put($zipPath, $response->body()); File::put($zipPath, $response->body());
+19 -6
View File
@@ -242,6 +242,7 @@ class ModuleService
* @param string $moduleName 설치할 모듈명 * @param string $moduleName 설치할 모듈명
* @param VendorMode $vendorMode Vendor 설치 모드 * @param VendorMode $vendorMode Vendor 설치 모드
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부 * @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return array|null 설치된 모듈 정보 또는 null * @return array|null 설치된 모듈 정보 또는 null
* *
* @throws ValidationException 모듈 설치 실패 시 * @throws ValidationException 모듈 설치 실패 시
@@ -250,12 +251,15 @@ class ModuleService
string $moduleName, string $moduleName,
VendorMode $vendorMode = VendorMode::Auto, VendorMode $vendorMode = VendorMode::Auto,
bool $force = false, bool $force = false,
?string &$failureReason = null,
): ?array { ): ?array {
$failureReason = null;
HookManager::doAction('core.modules.before_install', $moduleName); HookManager::doAction('core.modules.before_install', $moduleName);
try { try {
$this->moduleManager->loadModules(); $this->moduleManager->loadModules();
$result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force); $result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force, $failureReason);
if ($result) { if ($result) {
// 설치 후 모듈 정보 반환 // 설치 후 모듈 정보 반환
@@ -307,7 +311,9 @@ class ModuleService
]; ];
} }
return ['success' => false]; // 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
// 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
return $result;
} catch (\Exception $e) { } catch (\Exception $e) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'module_name' => [__('modules.activation_failed', ['error' => $e->getMessage()])], 'module_name' => [__('modules.activation_failed', ['error' => $e->getMessage()])],
@@ -359,12 +365,15 @@ class ModuleService
* *
* @param string $moduleName 제거할 모듈명 * @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부 * @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부 * @return bool 제거 성공 여부
* *
* @throws ValidationException 모듈 제거 실패 시 * @throws ValidationException 모듈 제거 실패 시
*/ */
public function uninstallModule(string $moduleName, bool $deleteData = false): bool public function uninstallModule(string $moduleName, bool $deleteData = false, ?string &$failureReason = null): bool
{ {
$failureReason = null;
HookManager::doAction('core.modules.before_uninstall', $moduleName, $deleteData); HookManager::doAction('core.modules.before_uninstall', $moduleName, $deleteData);
try { try {
@@ -372,7 +381,7 @@ class ModuleService
$this->moduleManager->loadModules(); $this->moduleManager->loadModules();
$moduleInfo = $this->moduleManager->getModuleInfo($moduleName); $moduleInfo = $this->moduleManager->getModuleInfo($moduleName);
$result = $this->moduleManager->uninstallModule($moduleName, $deleteData); $result = $this->moduleManager->uninstallModule($moduleName, $deleteData, null, $failureReason);
if ($result) { if ($result) {
$module = $this->moduleRepository->findByName($moduleName); $module = $this->moduleRepository->findByName($moduleName);
@@ -867,10 +876,14 @@ class ModuleService
private function executeModuleInstall(string $identifier): array private function executeModuleInstall(string $identifier): array
{ {
$this->moduleManager->loadModules(); $this->moduleManager->loadModules();
$result = $this->moduleManager->installModule($identifier); $result = $this->moduleManager->installModule($identifier, null, VendorMode::Auto, false, $failureReason);
if (! $result) { if (! $result) {
throw new ModuleOperationException('modules.errors.install_failed'); // 사유를 실어 올리지 않으면 'modules.errors.install_failed' 의 치환 자리가
// 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
throw new ModuleOperationException('modules.errors.install_failed', [
'error' => $failureReason ?? __('modules.errors.unknown_error'),
]);
} }
return $this->moduleManager->getModuleInfo($identifier); return $this->moduleManager->getModuleInfo($identifier);
+19 -6
View File
@@ -115,6 +115,7 @@ class PluginService
* @param string $pluginName 플러그인 식별자 * @param string $pluginName 플러그인 식별자
* @param VendorMode $vendorMode vendor 설치 모드 (Auto/Composer/Bundled) * @param VendorMode $vendorMode vendor 설치 모드 (Auto/Composer/Bundled)
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부 * @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return array|null 설치된 플러그인 정보 또는 설치 실패 시 null * @return array|null 설치된 플러그인 정보 또는 설치 실패 시 null
* *
* @throws ValidationException 플러그인 설치 실패 시 * @throws ValidationException 플러그인 설치 실패 시
@@ -123,12 +124,15 @@ class PluginService
string $pluginName, string $pluginName,
VendorMode $vendorMode = VendorMode::Auto, VendorMode $vendorMode = VendorMode::Auto,
bool $force = false, bool $force = false,
?string &$failureReason = null,
): ?array { ): ?array {
$failureReason = null;
HookManager::doAction('core.plugins.before_install', $pluginName); HookManager::doAction('core.plugins.before_install', $pluginName);
try { try {
$this->pluginManager->loadPlugins(); $this->pluginManager->loadPlugins();
$result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force); $result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force, $failureReason);
if ($result) { if ($result) {
// 설치 후 플러그인 정보 반환 // 설치 후 플러그인 정보 반환
@@ -182,7 +186,9 @@ class PluginService
]; ];
} }
return ['success' => false]; // 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
// 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
return $result;
} catch (\Exception $e) { } catch (\Exception $e) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'plugin_name' => [__('plugins.activation_failed', ['error' => $e->getMessage()])], 'plugin_name' => [__('plugins.activation_failed', ['error' => $e->getMessage()])],
@@ -236,18 +242,21 @@ class PluginService
* *
* @param string $pluginName 플러그인 식별자 * @param string $pluginName 플러그인 식별자
* @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부 * @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부 * @return bool 제거 성공 여부
* *
* @throws ValidationException 제거 실패 시 * @throws ValidationException 제거 실패 시
*/ */
public function uninstallPlugin(string $pluginName, bool $deleteData = false): bool public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?string &$failureReason = null): bool
{ {
$failureReason = null;
HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData); HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData);
try { try {
$this->pluginManager->loadPlugins(); $this->pluginManager->loadPlugins();
$result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData); $result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData, null, $failureReason);
HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result); HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result);
@@ -983,10 +992,14 @@ class PluginService
private function executePluginInstall(string $identifier): array private function executePluginInstall(string $identifier): array
{ {
$this->pluginManager->loadPlugins(); $this->pluginManager->loadPlugins();
$result = $this->pluginManager->installPlugin($identifier); $result = $this->pluginManager->installPlugin($identifier, null, VendorMode::Auto, false, $failureReason);
if (! $result) { if (! $result) {
throw new PluginOperationException('plugins.errors.install_failed'); // 사유를 실어 올리지 않으면 'plugins.errors.install_failed' 의 치환 자리가
// 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
throw new PluginOperationException('plugins.errors.install_failed', [
'error' => $failureReason ?? __('plugins.errors.unknown_error'),
]);
} }
return $this->pluginManager->getPluginInfo($identifier); return $this->pluginManager->getPluginInfo($identifier);
+11 -1
View File
@@ -151,10 +151,13 @@ class PluginSettingsService
* *
* @param string $identifier 플러그인 식별자 * @param string $identifier 플러그인 식별자
* @param array<string, mixed> $settings 저장할 설정 (검증 통과분) * @param array<string, mixed> $settings 저장할 설정 (검증 통과분)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 저장 성공 여부 * @return bool 저장 성공 여부
*/ */
public function save(string $identifier, array $settings): bool public function save(string $identifier, array $settings, ?string &$failureReason = null): bool
{ {
$failureReason = null;
// Before 훅 // Before 훅
HookManager::doAction('core.plugin_settings.before_save', $identifier, $settings); HookManager::doAction('core.plugin_settings.before_save', $identifier, $settings);
@@ -164,6 +167,8 @@ class PluginSettingsService
// 플러그인 인스턴스 확인 // 플러그인 인스턴스 확인
$pluginInstance = $this->pluginManager->getPlugin($identifier); $pluginInstance = $this->pluginManager->getPlugin($identifier);
if (! $pluginInstance) { if (! $pluginInstance) {
$failureReason = __('plugins.errors.not_found', ['plugin' => $identifier]);
return false; return false;
} }
@@ -187,6 +192,11 @@ class PluginSettingsService
// 파일에 저장 // 파일에 저장
$result = $this->saveSettingsToFile($identifier, $mergedSettings); $result = $this->saveSettingsToFile($identifier, $mergedSettings);
if (! $result && $failureReason === null) {
// 파일 쓰기 실패 — 스토리지 드라이버가 사유를 돌려주지 않으므로 일반 문구로 대체한다.
$failureReason = __('plugins.errors.unknown_error');
}
// 캐시 초기화 // 캐시 초기화
if ($result) { if ($result) {
unset($this->settingsCache[$identifier]); unset($this->settingsCache[$identifier]);
+18 -3
View File
@@ -7,6 +7,7 @@ use App\Contracts\Extension\PluginManagerInterface;
use App\Contracts\Extension\TemplateManagerInterface; use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface; use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface; use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus; use App\Enums\ExtensionStatus;
use App\Exceptions\TemplateNotFoundException; use App\Exceptions\TemplateNotFoundException;
use App\Exceptions\TemplateOperationException; use App\Exceptions\TemplateOperationException;
@@ -476,12 +477,15 @@ class TemplateService
* 템플릿을 비활성화합니다. * 템플릿을 비활성화합니다.
* *
* @param int|string $idOrIdentifier 템플릿 ID 또는 식별자 * @param int|string $idOrIdentifier 템플릿 ID 또는 식별자
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return array|null 비활성화된 템플릿 정보 또는 null * @return array|null 비활성화된 템플릿 정보 또는 null
* *
* @throws ValidationException 비활성화 실패 시 * @throws ValidationException 비활성화 실패 시
*/ */
public function deactivateTemplate(int|string $idOrIdentifier): ?array public function deactivateTemplate(int|string $idOrIdentifier, ?string &$failureReason = null): ?array
{ {
$failureReason = null;
// ID 또는 identifier로 템플릿 조회 // ID 또는 identifier로 템플릿 조회
$template = is_int($idOrIdentifier) $template = is_int($idOrIdentifier)
? $this->templateRepository->findById($idOrIdentifier) ? $this->templateRepository->findById($idOrIdentifier)
@@ -496,7 +500,14 @@ class TemplateService
HookManager::doAction('core.templates.before_deactivate', $template->identifier); HookManager::doAction('core.templates.before_deactivate', $template->identifier);
try { try {
$result = $this->templateManager->deactivateTemplate($template->identifier); // 위치 인자로 넘긴다 — 이 의존성은 인터페이스 타입이고 테스트가 그 인터페이스를
// mock 하므로, 이름 붙인 인자는 mock 의 __call 에 닿아 "Unknown named parameter" 가 된다.
$result = $this->templateManager->deactivateTemplate(
$template->identifier,
DeactivationReason::Manual->value,
null,
$failureReason
);
if ($result) { if ($result) {
// 템플릿 매니저에서 업데이트된 정보 조회 // 템플릿 매니저에서 업데이트된 정보 조회
@@ -2021,7 +2032,11 @@ class TemplateService
$result = $this->templateManager->installTemplate($identifier); $result = $this->templateManager->installTemplate($identifier);
if (! $result) { if (! $result) {
throw new TemplateOperationException('templates.errors.install_failed'); // installTemplate 은 사유 out 파라미터를 갖지 않으므로 일반 문구로 채운다.
// 비워 두면 치환 자리가 남아 관리자 화면에 리터럴 ':error' 가 노출된다.
throw new TemplateOperationException('templates.errors.install_failed', [
'error' => __('templates.errors.unknown_error'),
]);
} }
return $this->templateManager->getTemplateInfo($identifier); return $this->templateManager->getTemplateInfo($identifier);
+4
View File
@@ -120,6 +120,10 @@ Authorization: Bearer {YOUR_TOKEN}
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) |
| 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 | | 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 |
확장(모듈·플러그인)이 제공하는 엔드포인트(`/api/modules/{id}/…`, `/api/plugins/{id}/…`)는 그 확장이
**활성 상태일 때만** 존재합니다. 비활성화·제거된 확장의 엔드포인트는 404 를 반환하며, 이는 권한
문제가 아니라 라우트가 등록되지 않은 상태입니다. 확장을 업데이트하는 동안에도 잠시 같은 상태가 됩니다.
428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다. 428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다.
클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다. 클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다.
+3 -3
View File
@@ -371,7 +371,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 | | 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다: :error` — `language_packs.check_updates_failed`) | | 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다.` — `language_packs.check_updates_failed`) |
<!-- @generated:end --> <!-- @generated:end -->
@@ -633,7 +633,7 @@ HTTP/1.1 201
} }
``` ```
> manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다: :error`) 으로 응답합니다. > manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다.`) 으로 응답합니다.
**에러 응답** **에러 응답**
@@ -1664,7 +1664,7 @@ HTTP/1.1 200
} }
``` ```
> 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다: :error`) 으로 응답합니다. > 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다.`) 으로 응답합니다.
**에러 응답** **에러 응답**
+21
View File
@@ -164,6 +164,27 @@ class MaxDepthExceededException extends Exception
} }
``` ```
### 치환 자리는 비워 둘 수 없다
`:error` 같은 치환 자리를 가진 키를 파라미터 없이 부르면, 번역기는 그 자리를 **그대로 둔 문장**을
돌려준다. 그래서 운영자 화면에 `모듈 활성화에 실패했습니다: :error` 처럼 내부 자리표시자가 노출된다.
예외도 로그도 남지 않고 실패했을 때만 드러나므로, 정상 흐름만 보는 테스트로는 잡히지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `error('x.activate_failed')` — 치환 자리를 가진 키를 파라미터 없이 | `error('x.activate_failed', 400, null, ['error' => $reason])` |
| 사유를 모른다고 자리를 비워 두기 | 사유를 알 수 없으면 일반 문구로 채운다 (`errors.unknown_error`) |
| 원인을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 치환 자리 자체를 없앤다 |
| 하위 계층이 `false` 만 돌려주고 사유를 버리기 | 사유를 반환 경로에 실어 올린다 (배열 키 또는 선택적 out 파라미터) |
셋째 인자 `errors` 페이로드와 넷째 인자 `messageParams` 는 **다른 통로**다. `errors` 에 예외를 넘기는
것은 진단 정보이고(노출 폭은 `ResponseHelper` 가 `app.debug` 로 정한다), 문구의 치환 자리를 채우는
것은 `messageParams` 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다.
응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 **파라미터를 채우는 대신 키에서
치환 자리를 없앤다.** 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼
의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다.
--- ---
## 예외 → 응답 매핑 ## 예외 → 응답 매핑
+50 -2
View File
@@ -343,8 +343,9 @@ Route::prefix('products')->group(function () {
| `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 | | `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 |
| `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 | | `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 |
`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 새 애플리케이션을 부팅해 라우트를 `rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 **새 애플리케이션을 부팅해** 라우트를
수집하므로 방금 설치·활성화한 확장의 라우트도 함께 잡힌다. 재생성이 실패하면(직렬화 수집한다. 방금 설치·활성화한 확장의 라우트가 함께 잡히려면 그 새 부팅이 바뀐 상태를 읽어야
하므로, 굽기 전에 상태 캐시를 비워야 한다(아래 절). 재생성이 실패하면(직렬화
불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는 불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는
방금 설치한 확장을 통째로 없는 것으로 만든다. 방금 설치한 확장을 통째로 없는 것으로 만든다.
@@ -362,6 +363,53 @@ Route::prefix('products')->group(function () {
서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는 서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는
`api/modules/{identifier}` 로 식별자에 고정). `api/modules/{identifier}` 로 식별자에 고정).
### 확장 라우트는 활성 상태로 게이트된다
모듈·플러그인 라우트는 **활성 상태인 확장의 것만** 등록한다. 비활성화하면 화면·메뉴·프론트엔드
에셋은 사라지지만, 라우트 등록을 게이트하지 않으면 그 확장의 API 는 계속 호출 가능한 상태로 남는다.
컨트롤러 파일이 그대로 있으므로 요청은 정상 처리되고 오류도 로그도 남지 않아, 화면만 보고는
꺼진 기능이 여전히 동작한다는 사실을 알 수 없다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 라우트 프로바이더가 디렉토리에 있는 확장 전부를 등록 | 활성 식별자 목록으로 걸러 등록 |
| 한쪽(모듈)만 게이트하고 다른 쪽(플러그인)은 무게이트 | 두 경로가 같은 기준을 공유 |
| 게이트를 개별 컨트롤러·미들웨어에 흩어 놓기 | 라우트 등록 지점 한 곳에서 판정 |
활성 목록은 상태 캐시를 공유하므로, 상태를 바꾼 쪽이 그 캐시를 비워야 다음 부팅이 새 상태를
읽는다(아래 절).
### 굽기는 상태 캐시 무효화 뒤에 온다
`route:cache` 가 부팅하는 새 애플리케이션에서 확장 라우트 프로바이더는 DB 가 아니라
**캐시된 활성 확장 목록**을 읽는다(TTL 기본 1일). 그래서 확장의 상태를 DB 에 쓴 뒤
그 상태 캐시를 비우기 **전에** 구우면, 새 부팅이 낡은 목록을 읽어 방금 바뀐 상태가
반영되지 않은 라우트가 박제된다.
```text
DB 상태 쓰기 → 상태 캐시 무효화 → 굽기(rebuild / 훅 캐시 재생성)
```
순서가 뒤집혔을 때의 결과는 방향마다 다르고, 어느 쪽도 스스로 회복되지 않는다.
| 수명주기 | 낡은 목록의 내용 | 결과 |
|---------|----------------|------|
| 활성화 | 그 확장이 없음 | 방금 켠 확장의 API 전량 404 |
| 비활성화 | 그 확장이 남아 있음 | 끈 확장의 API 가 계속 호출 가능 |
| 업데이트 | `Updating`(=비활성)으로 판정 | 업데이트 후 API 404 + 훅 리스너 누락 |
상태 캐시 무효화는 **DB 상태 쓰기 직후**에 둔다. 굽기 직전으로 옮기는 것으로는 부족하다 —
같은 목록을 읽는 굽기가 라우트 캐시 말고도 있기 때문이다(훅 매핑 캐시가 오토로드 갱신
안에서 구워진다).
업데이트 경로만 예외가 하나 있다. `Updating` 전이 직후에는 비우지 않는다 — 비우면 그 창
안에서 도는 오토로드 갱신이 DB 를 재조회해 그 확장을 비활성으로 판정하고 훅 캐시에서
리스너를 떨군다. 대신 **상태 복원 직후**에 비우고, 그 뒤 훅 캐시를 다시 굽는다. 훅 캐시
폴백은 파일 부재·손상에만 작동하므로 내용이 낡은 경우는 조용히 통과하기 때문이다.
이 순서는 정적 검사로 강제된다 — `rebuild()` 를 호출하는 수명주기 메서드를 리플렉션으로
도출해(개별 열거 금지) 각각에서 무효화가 앞서는지 확인한다.
### 캐시 안전한 라우트 작성 ### 캐시 안전한 라우트 작성
캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가 캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
+22
View File
@@ -143,6 +143,28 @@
| `getMetadata()` | `[]` | 메타데이터 | | `getMetadata()` | `[]` | 메타데이터 |
| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) |
| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) | | `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) |
#### 수명주기 훅이 실패를 알리는 방법
`install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로, 그냥 `false` 를
반환하면 **왜 거부했는지가 코어에 전달되지 않는다.** 그 결과 운영자는 원인이 빠진 실패 문구만 본다.
사유를 남기려면 `failWith()` 로 반환한다. 코어가 그 사유를 응답 문구의 원인 자리에 싣는다.
```php
public function activate(): bool
{
if (! extension_loaded('gd')) {
return $this->failWith(__('my-module::messages.gd_required'));
}
return true;
}
```
- 사유는 **이미 번역된 문장**이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
- 사유를 남기지 않고 `false` 만 돌려주면 코어가 일반 문구로 대체한다(동작은 그대로).
- 같은 규칙이 플러그인(`AbstractPlugin`)에도 동일하게 적용된다.
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
#### 동적 권한/역할/메뉴 보존 규칙 #### 동적 권한/역할/메뉴 보존 규칙
@@ -11,6 +11,10 @@
- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`). - 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`).
- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`). - 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`).
### Changed
- 언어팩 관리(조회·설치·활성화·비활성화·제거·업데이트 확인·업데이트·캐시 갱신·manifest 미리보기) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 갱신했습니다 (`language_packs.*_failed`).
## [1.0.6] - 2026-08-19 ## [1.0.6] - 2026-08-19
### Changed ### Changed
@@ -2,25 +2,25 @@
return [ return [
'fetch_success' => '言語パックリストを取得しました。', 'fetch_success' => '言語パックリストを取得しました。',
'fetch_failed' => '言語パックリストを読み込めませんでした: :error', 'fetch_failed' => '言語パックリストを読み込めませんでした。',
'not_found' => '言語パックが見つかりません。', 'not_found' => '言語パックが見つかりません。',
'install_success' => '言語パックをインストールしました。', 'install_success' => '言語パックをインストールしました。',
'install_failed' => '言語パックのインストールに失敗しました: :error', 'install_failed' => '言語パックのインストールに失敗しました。',
'activate_success' => '言語パックを有効化しました。', 'activate_success' => '言語パックを有効化しました。',
'activate_failed' => '言語パックの有効化に失敗しました: :error', 'activate_failed' => '言語パックの有効化に失敗しました。',
'deactivate_success' => '言語パックを無効化しました。', 'deactivate_success' => '言語パックを無効化しました。',
'deactivate_failed' => '言語パックの無効化に失敗しました: :error', 'deactivate_failed' => '言語パックの無効化に失敗しました。',
'uninstall_success' => '言語パックを削除しました。', 'uninstall_success' => '言語パックを削除しました。',
'uninstall_failed' => '言語パックの削除に失敗しました: :error', 'uninstall_failed' => '言語パックの削除に失敗しました。',
'manifest_invalid' => 'language-pack.json の検証に失敗しました。', 'manifest_invalid' => 'language-pack.json の検証に失敗しました。',
'check_updates_success' => 'アップデート確認が完了しました。', 'check_updates_success' => 'アップデート確認が完了しました。',
'check_updates_failed' => 'アップデート確認に失敗しました: :error', 'check_updates_failed' => 'アップデート確認に失敗しました。',
'update_success' => '言語パックをアップデートしました。', 'update_success' => '言語パックをアップデートしました。',
'update_failed' => '言語パックのアップデートに失敗しました: :error', 'update_failed' => '言語パックのアップデートに失敗しました。',
'refresh_cache_success' => '言語パックキャッシュを更新しました。', 'refresh_cache_success' => '言語パックキャッシュを更新しました。',
'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました: :error', 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました。',
'preview_success' => 'manifest プレビューが完了しました。', 'preview_success' => 'manifest プレビューが完了しました。',
'preview_failed' => 'manifest プレビューに失敗しました: :error', 'preview_failed' => 'manifest プレビューに失敗しました。',
'errors' => [ 'errors' => [
'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。', 'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。',
'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。', 'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。',
+10 -10
View File
@@ -2,25 +2,25 @@
return [ return [
'fetch_success' => 'Language pack list retrieved.', 'fetch_success' => 'Language pack list retrieved.',
'fetch_failed' => 'Failed to load language pack list: :error', 'fetch_failed' => 'Failed to load language pack list.',
'not_found' => 'Language pack not found.', 'not_found' => 'Language pack not found.',
'install_success' => 'Language pack installed.', 'install_success' => 'Language pack installed.',
'install_failed' => 'Failed to install language pack: :error', 'install_failed' => 'Failed to install language pack.',
'activate_success' => 'Language pack activated.', 'activate_success' => 'Language pack activated.',
'activate_failed' => 'Failed to activate language pack: :error', 'activate_failed' => 'Failed to activate language pack.',
'deactivate_success' => 'Language pack deactivated.', 'deactivate_success' => 'Language pack deactivated.',
'deactivate_failed' => 'Failed to deactivate language pack: :error', 'deactivate_failed' => 'Failed to deactivate language pack.',
'uninstall_success' => 'Language pack removed.', 'uninstall_success' => 'Language pack removed.',
'uninstall_failed' => 'Failed to remove language pack: :error', 'uninstall_failed' => 'Failed to remove language pack.',
'manifest_invalid' => 'language-pack.json validation failed.', 'manifest_invalid' => 'language-pack.json validation failed.',
'check_updates_success' => 'Update check completed.', 'check_updates_success' => 'Update check completed.',
'check_updates_failed' => 'Failed to check updates: :error', 'check_updates_failed' => 'Failed to check updates.',
'update_success' => 'Language pack updated.', 'update_success' => 'Language pack updated.',
'update_failed' => 'Failed to update language pack: :error', 'update_failed' => 'Failed to update language pack.',
'refresh_cache_success' => 'Language pack cache refreshed.', 'refresh_cache_success' => 'Language pack cache refreshed.',
'refresh_cache_failed' => 'Failed to refresh language pack cache: :error', 'refresh_cache_failed' => 'Failed to refresh language pack cache.',
'preview_success' => 'Manifest preview completed.', 'preview_success' => 'Manifest preview completed.',
'preview_failed' => 'Failed to preview manifest: :error', 'preview_failed' => 'Failed to preview manifest.',
'errors' => [ 'errors' => [
'manifest_not_found' => 'language-pack.json file not found in archive.', 'manifest_not_found' => 'language-pack.json file not found in archive.',
@@ -36,7 +36,7 @@ return [
'target_version_too_old' => 'Target :scope (":target") version does not satisfy the required constraint (:constraint).', 'target_version_too_old' => 'Target :scope (":target") version does not satisfy the required constraint (:constraint).',
'downgrade_blocked' => 'Downgrade blocked (:from → :to).', 'downgrade_blocked' => 'Downgrade blocked (:from → :to).',
'protected_pack' => 'Protected language packs cannot be deactivated or removed.', 'protected_pack' => 'Protected language packs cannot be deactivated or removed.',
'download_failed' => 'Failed to download from URL: :url', 'download_failed' => 'Failed to download the language pack from URL (:url): :error',
'download_url_not_public' => 'Language packs cannot be downloaded from internal network addresses (private IPs, localhost, etc.). Use a publicly reachable https address.', 'download_url_not_public' => 'Language packs cannot be downloaded from internal network addresses (private IPs, localhost, etc.). Use a publicly reachable https address.',
'checksum_mismatch' => 'Checksum mismatch.', 'checksum_mismatch' => 'Checksum mismatch.',
'update_no_source' => 'No update source available (only GitHub-sourced packs can be updated).', 'update_no_source' => 'No update source available (only GitHub-sourced packs can be updated).',
+9 -9
View File
@@ -2,25 +2,25 @@
return [ return [
'fetch_success' => '언어팩 목록을 조회했습니다.', 'fetch_success' => '언어팩 목록을 조회했습니다.',
'fetch_failed' => '언어팩 목록을 불러오지 못했습니다: :error', 'fetch_failed' => '언어팩 목록을 불러오지 못했습니다.',
'not_found' => '언어팩을 찾을 수 없습니다.', 'not_found' => '언어팩을 찾을 수 없습니다.',
'install_success' => '언어팩을 설치했습니다.', 'install_success' => '언어팩을 설치했습니다.',
'install_failed' => '언어팩 설치에 실패했습니다: :error', 'install_failed' => '언어팩 설치에 실패했습니다.',
'activate_success' => '언어팩을 활성화했습니다.', 'activate_success' => '언어팩을 활성화했습니다.',
'activate_failed' => '언어팩 활성화에 실패했습니다: :error', 'activate_failed' => '언어팩 활성화에 실패했습니다.',
'deactivate_success' => '언어팩을 비활성화했습니다.', 'deactivate_success' => '언어팩을 비활성화했습니다.',
'deactivate_failed' => '언어팩 비활성화에 실패했습니다: :error', 'deactivate_failed' => '언어팩 비활성화에 실패했습니다.',
'uninstall_success' => '언어팩을 제거했습니다.', 'uninstall_success' => '언어팩을 제거했습니다.',
'uninstall_failed' => '언어팩 제거에 실패했습니다: :error', 'uninstall_failed' => '언어팩 제거에 실패했습니다.',
'manifest_invalid' => 'language-pack.json 검증에 실패했습니다.', 'manifest_invalid' => 'language-pack.json 검증에 실패했습니다.',
'check_updates_success' => '업데이트 확인을 완료했습니다.', 'check_updates_success' => '업데이트 확인을 완료했습니다.',
'check_updates_failed' => '업데이트 확인에 실패했습니다: :error', 'check_updates_failed' => '업데이트 확인에 실패했습니다.',
'update_success' => '언어팩을 업데이트했습니다.', 'update_success' => '언어팩을 업데이트했습니다.',
'update_failed' => '언어팩 업데이트에 실패했습니다: :error', 'update_failed' => '언어팩 업데이트에 실패했습니다.',
'refresh_cache_success' => '언어팩 캐시를 갱신했습니다.', 'refresh_cache_success' => '언어팩 캐시를 갱신했습니다.',
'refresh_cache_failed' => '언어팩 캐시 갱신에 실패했습니다: :error', 'refresh_cache_failed' => '언어팩 캐시 갱신에 실패했습니다.',
'preview_success' => 'manifest 미리보기를 완료했습니다.', 'preview_success' => 'manifest 미리보기를 완료했습니다.',
'preview_failed' => 'manifest 미리보기에 실패했습니다: :error', 'preview_failed' => 'manifest 미리보기에 실패했습니다.',
'errors' => [ 'errors' => [
'manifest_not_found' => 'ZIP 안에서 language-pack.json 파일을 찾을 수 없습니다.', 'manifest_not_found' => 'ZIP 안에서 language-pack.json 파일을 찾을 수 없습니다.',
@@ -3,11 +3,15 @@
namespace Tests\Feature\Api\Admin; namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType; use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Exceptions\ModuleOperationException;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\ModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\TestCase;
@@ -54,7 +58,7 @@ class ModuleControllerTest extends TestCase
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']), 'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
'extension_type' => ExtensionOwnerType::Core, 'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core', 'extension_identifier' => 'core',
'type' => \App\Enums\PermissionType::Admin, 'type' => PermissionType::Admin,
] ]
); );
$permissionIds[] = $permission->id; $permissionIds[] = $permission->id;
@@ -498,7 +502,7 @@ class ModuleControllerTest extends TestCase
*/ */
public function test_install_from_file_succeeds_with_valid_zip(): void public function test_install_from_file_succeeds_with_valid_zip(): void
{ {
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile') $moduleServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andReturn([ ->andReturn([
@@ -506,7 +510,7 @@ class ModuleControllerTest extends TestCase
'name' => 'Test Module', 'name' => 'Test Module',
'version' => '1.0.0', 'version' => '1.0.0',
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
@@ -520,15 +524,20 @@ class ModuleControllerTest extends TestCase
} }
/** /**
* ModuleService에서 RuntimeException 발생 시 422 반환 * ModuleService에서 도메인 예외 발생 시 422 반환 + 키가 해석된 문구
*
* 종전에는 raw `\RuntimeException` 이 이미 번역된 문장을 들고 오는 것을 컨트롤러가
* 메시지 **키** 자리로 넘겨 그대로 내보냈다. 키 해석에 실패한 원문이 나가는 형태라
* 계약 테스트가 이 지점을 위반으로 등재해 두고 있었다. 이제 도메인 실패는 키와
* 파라미터를 들고 다니는 예외로 오고, 컨트롤러가 그 키를 해석해 내보낸다.
*/ */
public function test_install_from_file_returns_422_on_runtime_exception(): void public function test_install_from_file_returns_422_on_domain_exception(): void
{ {
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile') $moduleServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andThrow(new \RuntimeException('module.json을 찾을 수 없습니다.')); ->andThrow(new ModuleOperationException('modules.errors.module_json_not_found'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
@@ -537,7 +546,27 @@ class ModuleControllerTest extends TestCase
]); ]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonPath('message', 'module.json을 찾을 수 없습니다.'); $response->assertJsonPath('message', __('modules.errors.module_json_not_found'));
}
/**
* 도메인 예외가 아닌 raw RuntimeException 은 500 (인프라 장애를 입력 오류로 위장하지 않는다)
*/
public function test_install_from_file_returns_500_on_raw_runtime_exception(): void
{
$moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \RuntimeException('disk full'));
$this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
'file' => $file,
]);
$response->assertStatus(500);
} }
/** /**
@@ -545,11 +574,11 @@ class ModuleControllerTest extends TestCase
*/ */
public function test_install_from_file_returns_500_on_general_exception(): void public function test_install_from_file_returns_500_on_general_exception(): void
{ {
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile') $moduleServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andThrow(new \Exception('예상치 못한 오류')); ->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
@@ -569,7 +598,7 @@ class ModuleControllerTest extends TestCase
*/ */
public function test_install_from_github_calls_service_with_valid_url(): void public function test_install_from_github_calls_service_with_valid_url(): void
{ {
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub') $moduleServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->with('https://github.com/sirsoft/sample-module') ->with('https://github.com/sirsoft/sample-module')
@@ -578,7 +607,7 @@ class ModuleControllerTest extends TestCase
'name' => 'Sample Module', 'name' => 'Sample Module',
'version' => '1.0.0', 'version' => '1.0.0',
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module', 'github_url' => 'https://github.com/sirsoft/sample-module',
@@ -592,20 +621,20 @@ class ModuleControllerTest extends TestCase
/** /**
* ModuleService에서 RuntimeException 발생 시 422 반환 (GitHub) * ModuleService에서 RuntimeException 발생 시 422 반환 (GitHub)
*/ */
public function test_install_from_github_returns_422_on_runtime_exception(): void public function test_install_from_github_returns_422_on_domain_exception(): void
{ {
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub') $moduleServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.')); ->andThrow(new ModuleOperationException('modules.errors.github_repo_not_found'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module', 'github_url' => 'https://github.com/sirsoft/sample-module',
]); ]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.'); $response->assertJsonPath('message', __('modules.errors.github_repo_not_found'));
} }
/** /**
@@ -613,11 +642,11 @@ class ModuleControllerTest extends TestCase
*/ */
public function test_install_from_github_returns_500_on_general_exception(): void public function test_install_from_github_returns_500_on_general_exception(): void
{ {
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub') $moduleServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->andThrow(new \Exception('예상치 못한 오류')); ->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module', 'github_url' => 'https://github.com/sirsoft/sample-module',
@@ -682,7 +711,7 @@ class ModuleControllerTest extends TestCase
public function test_activate_returns_409_when_dependencies_not_met(): void public function test_activate_returns_409_when_dependencies_not_met(): void
{ {
// Arrange: ModuleService를 Mock // Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule') $moduleServiceMock->shouldReceive('activateModule')
->with('test-module', false) ->with('test-module', false)
->andReturn([ ->andReturn([
@@ -696,7 +725,7 @@ class ModuleControllerTest extends TestCase
], ],
'message' => '이 모듈을 활성화하려면 필요한 의존성이 충족되어야 합니다.', 'message' => '이 모듈을 활성화하려면 필요한 의존성이 충족되어야 합니다.',
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [ $response = $this->authRequest()->postJson('/api/admin/modules/activate', [
@@ -731,7 +760,7 @@ class ModuleControllerTest extends TestCase
public function test_activate_with_force_bypasses_dependency_check(): void public function test_activate_with_force_bypasses_dependency_check(): void
{ {
// Arrange: ModuleService를 Mock // Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule') $moduleServiceMock->shouldReceive('activateModule')
->with('test-module', true) ->with('test-module', true)
->andReturn([ ->andReturn([
@@ -742,7 +771,7 @@ class ModuleControllerTest extends TestCase
'status' => 'active', 'status' => 'active',
], ],
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [ $response = $this->authRequest()->postJson('/api/admin/modules/activate', [
@@ -761,7 +790,7 @@ class ModuleControllerTest extends TestCase
public function test_deactivate_returns_409_when_dependents_exist(): void public function test_deactivate_returns_409_when_dependents_exist(): void
{ {
// Arrange: ModuleService를 Mock // Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule') $moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', false) ->with('test-module', false)
->andReturn([ ->andReturn([
@@ -774,7 +803,7 @@ class ModuleControllerTest extends TestCase
'dependent_plugins' => [], 'dependent_plugins' => [],
'message' => '이 모듈에 의존하는 활성화된 확장이 있습니다.', 'message' => '이 모듈에 의존하는 활성화된 확장이 있습니다.',
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [ $response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
@@ -801,7 +830,7 @@ class ModuleControllerTest extends TestCase
public function test_deactivate_with_force_bypasses_dependent_check(): void public function test_deactivate_with_force_bypasses_dependent_check(): void
{ {
// Arrange: ModuleService를 Mock // Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule') $moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', true) ->with('test-module', true)
->andReturn([ ->andReturn([
@@ -812,7 +841,7 @@ class ModuleControllerTest extends TestCase
'status' => 'inactive', 'status' => 'inactive',
], ],
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [ $response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
@@ -835,7 +864,7 @@ class ModuleControllerTest extends TestCase
public function test_activate_response_includes_assets_when_module_has_assets(): void public function test_activate_response_includes_assets_when_module_has_assets(): void
{ {
// Arrange: ModuleService를 Mock // Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule') $moduleServiceMock->shouldReceive('activateModule')
->with('test-module', false) ->with('test-module', false)
->andReturn([ ->andReturn([
@@ -851,7 +880,7 @@ class ModuleControllerTest extends TestCase
], ],
], ],
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [ $response = $this->authRequest()->postJson('/api/admin/modules/activate', [
@@ -872,7 +901,7 @@ class ModuleControllerTest extends TestCase
public function test_deactivate_response_includes_assets_when_module_has_assets(): void public function test_deactivate_response_includes_assets_when_module_has_assets(): void
{ {
// Arrange: ModuleService를 Mock // Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule') $moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', false) ->with('test-module', false)
->andReturn([ ->andReturn([
@@ -888,7 +917,7 @@ class ModuleControllerTest extends TestCase
], ],
], ],
]); ]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [ $response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
@@ -913,8 +942,8 @@ class ModuleControllerTest extends TestCase
{ {
// Arrange: 테스트용 CHANGELOG.md 생성 // Arrange: 테스트용 CHANGELOG.md 생성
$modulePath = base_path('modules/test-changelog-mod'); $modulePath = base_path('modules/test-changelog-mod');
\Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath); File::ensureDirectoryExists($modulePath);
\Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n"); File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
try { try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-mod/changelog'); $response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-mod/changelog');
@@ -924,7 +953,7 @@ class ModuleControllerTest extends TestCase
->assertJsonPath('data.changelog.0.categories.0.name', 'Added') ->assertJsonPath('data.changelog.0.categories.0.name', 'Added')
->assertJsonPath('data.changelog.1.version', '0.1.0'); ->assertJsonPath('data.changelog.1.version', '0.1.0');
} finally { } finally {
\Illuminate\Support\Facades\File::deleteDirectory($modulePath); File::deleteDirectory($modulePath);
} }
} }
@@ -945,8 +974,8 @@ class ModuleControllerTest extends TestCase
public function test_changelog_with_version_range(): void public function test_changelog_with_version_range(): void
{ {
$modulePath = base_path('modules/test-changelog-range'); $modulePath = base_path('modules/test-changelog-range');
\Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath); File::ensureDirectoryExists($modulePath);
\Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n"); File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
try { try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-range/changelog?from_version=0.1.0&to_version=0.1.2'); $response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-range/changelog?from_version=0.1.0&to_version=0.1.2');
@@ -957,7 +986,7 @@ class ModuleControllerTest extends TestCase
$this->assertSame('0.1.2', $changelog[0]['version']); $this->assertSame('0.1.2', $changelog[0]['version']);
$this->assertSame('0.1.1', $changelog[1]['version']); $this->assertSame('0.1.1', $changelog[1]['version']);
} finally { } finally {
\Illuminate\Support\Facades\File::deleteDirectory($modulePath); File::deleteDirectory($modulePath);
} }
} }
@@ -980,8 +1009,8 @@ class ModuleControllerTest extends TestCase
public function test_license_returns_content(): void public function test_license_returns_content(): void
{ {
$modulePath = base_path('modules/test-license-mod'); $modulePath = base_path('modules/test-license-mod');
\Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath); File::ensureDirectoryExists($modulePath);
\Illuminate\Support\Facades\File::put($modulePath.'/LICENSE', 'MIT License - Test'); File::put($modulePath.'/LICENSE', 'MIT License - Test');
try { try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-license-mod/license'); $response = $this->authRequest()->getJson('/api/admin/modules/test-license-mod/license');
@@ -989,7 +1018,7 @@ class ModuleControllerTest extends TestCase
$response->assertStatus(200) $response->assertStatus(200)
->assertJsonPath('data.content', 'MIT License - Test'); ->assertJsonPath('data.content', 'MIT License - Test');
} finally { } finally {
\Illuminate\Support\Facades\File::deleteDirectory($modulePath); File::deleteDirectory($modulePath);
} }
} }
@@ -3,11 +3,15 @@
namespace Tests\Feature\Api\Admin; namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType; use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Exceptions\PluginOperationException;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\PluginService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\TestCase;
@@ -51,7 +55,7 @@ class PluginControllerTest extends TestCase
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']), 'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
'extension_type' => ExtensionOwnerType::Core, 'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core', 'extension_identifier' => 'core',
'type' => \App\Enums\PermissionType::Admin, 'type' => PermissionType::Admin,
] ]
); );
$permissionIds[] = $permission->id; $permissionIds[] = $permission->id;
@@ -423,7 +427,7 @@ class PluginControllerTest extends TestCase
public function test_activate_returns_409_when_dependencies_not_met(): void public function test_activate_returns_409_when_dependencies_not_met(): void
{ {
// Arrange: PluginService를 Mock // Arrange: PluginService를 Mock
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('activatePlugin') $pluginServiceMock->shouldReceive('activatePlugin')
->with('test-plugin', false) ->with('test-plugin', false)
->andReturn([ ->andReturn([
@@ -437,7 +441,7 @@ class PluginControllerTest extends TestCase
], ],
'message' => '플러그인 활성화를 위해 필요한 의존성이 충족되지 않았습니다.', 'message' => '플러그인 활성화를 위해 필요한 의존성이 충족되지 않았습니다.',
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/plugins/activate', [ $response = $this->authRequest()->postJson('/api/admin/plugins/activate', [
@@ -472,7 +476,7 @@ class PluginControllerTest extends TestCase
public function test_activate_with_force_bypasses_dependency_check(): void public function test_activate_with_force_bypasses_dependency_check(): void
{ {
// Arrange: PluginService를 Mock // Arrange: PluginService를 Mock
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('activatePlugin') $pluginServiceMock->shouldReceive('activatePlugin')
->with('test-plugin', true) ->with('test-plugin', true)
->andReturn([ ->andReturn([
@@ -483,7 +487,7 @@ class PluginControllerTest extends TestCase
'status' => 'active', 'status' => 'active',
], ],
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/plugins/activate', [ $response = $this->authRequest()->postJson('/api/admin/plugins/activate', [
@@ -502,7 +506,7 @@ class PluginControllerTest extends TestCase
public function test_deactivate_returns_409_when_dependents_exist(): void public function test_deactivate_returns_409_when_dependents_exist(): void
{ {
// Arrange: PluginService를 Mock // Arrange: PluginService를 Mock
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('deactivatePlugin') $pluginServiceMock->shouldReceive('deactivatePlugin')
->with('test-plugin', false) ->with('test-plugin', false)
->andReturn([ ->andReturn([
@@ -517,7 +521,7 @@ class PluginControllerTest extends TestCase
], ],
'message' => '이 플러그인에 의존하는 활성화된 확장이 있습니다.', 'message' => '이 플러그인에 의존하는 활성화된 확장이 있습니다.',
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [ $response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [
@@ -545,7 +549,7 @@ class PluginControllerTest extends TestCase
public function test_deactivate_with_force_bypasses_dependent_check(): void public function test_deactivate_with_force_bypasses_dependent_check(): void
{ {
// Arrange: PluginService를 Mock // Arrange: PluginService를 Mock
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('deactivatePlugin') $pluginServiceMock->shouldReceive('deactivatePlugin')
->with('test-plugin', true) ->with('test-plugin', true)
->andReturn([ ->andReturn([
@@ -556,7 +560,7 @@ class PluginControllerTest extends TestCase
'status' => 'inactive', 'status' => 'inactive',
], ],
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [ $response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [
@@ -579,7 +583,7 @@ class PluginControllerTest extends TestCase
public function test_activate_response_includes_assets_when_plugin_has_assets(): void public function test_activate_response_includes_assets_when_plugin_has_assets(): void
{ {
// Arrange: PluginService를 Mock // Arrange: PluginService를 Mock
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('activatePlugin') $pluginServiceMock->shouldReceive('activatePlugin')
->with('test-plugin', false) ->with('test-plugin', false)
->andReturn([ ->andReturn([
@@ -595,7 +599,7 @@ class PluginControllerTest extends TestCase
], ],
], ],
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/plugins/activate', [ $response = $this->authRequest()->postJson('/api/admin/plugins/activate', [
@@ -616,7 +620,7 @@ class PluginControllerTest extends TestCase
public function test_deactivate_response_includes_assets_when_plugin_has_assets(): void public function test_deactivate_response_includes_assets_when_plugin_has_assets(): void
{ {
// Arrange: PluginService를 Mock // Arrange: PluginService를 Mock
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('deactivatePlugin') $pluginServiceMock->shouldReceive('deactivatePlugin')
->with('test-plugin', false) ->with('test-plugin', false)
->andReturn([ ->andReturn([
@@ -632,7 +636,7 @@ class PluginControllerTest extends TestCase
], ],
], ],
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
// Act // Act
$response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [ $response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [
@@ -656,8 +660,8 @@ class PluginControllerTest extends TestCase
public function test_changelog_returns_parsed_entries(): void public function test_changelog_returns_parsed_entries(): void
{ {
$pluginPath = base_path('plugins/test-changelog-plg'); $pluginPath = base_path('plugins/test-changelog-plg');
\Illuminate\Support\Facades\File::ensureDirectoryExists($pluginPath); File::ensureDirectoryExists($pluginPath);
\Illuminate\Support\Facades\File::put($pluginPath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Fixed\n- 버그 수정\n"); File::put($pluginPath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Fixed\n- 버그 수정\n");
try { try {
$response = $this->authRequest()->getJson('/api/admin/plugins/test-changelog-plg/changelog'); $response = $this->authRequest()->getJson('/api/admin/plugins/test-changelog-plg/changelog');
@@ -666,7 +670,7 @@ class PluginControllerTest extends TestCase
->assertJsonPath('data.changelog.0.version', '0.1.1') ->assertJsonPath('data.changelog.0.version', '0.1.1')
->assertJsonPath('data.changelog.0.categories.0.name', 'Fixed'); ->assertJsonPath('data.changelog.0.categories.0.name', 'Fixed');
} finally { } finally {
\Illuminate\Support\Facades\File::deleteDirectory($pluginPath); File::deleteDirectory($pluginPath);
} }
} }
@@ -817,7 +821,7 @@ class PluginControllerTest extends TestCase
*/ */
public function test_install_from_file_calls_service_with_valid_zip(): void public function test_install_from_file_calls_service_with_valid_zip(): void
{ {
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromZipFile') $pluginServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andReturn([ ->andReturn([
@@ -825,7 +829,7 @@ class PluginControllerTest extends TestCase
'name' => 'Test Plugin', 'name' => 'Test Plugin',
'version' => '1.0.0', 'version' => '1.0.0',
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
$file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip');
@@ -841,13 +845,13 @@ class PluginControllerTest extends TestCase
/** /**
* PluginService에서 RuntimeException 발생 시 422 반환 * PluginService에서 RuntimeException 발생 시 422 반환
*/ */
public function test_install_from_file_returns_422_on_runtime_exception(): void public function test_install_from_file_returns_422_on_domain_exception(): void
{ {
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromZipFile') $pluginServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andThrow(new \RuntimeException('plugin.json을 찾을 수 없습니다.')); ->andThrow(new PluginOperationException('plugins.errors.plugin_json_not_found'));
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
$file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip');
@@ -856,7 +860,7 @@ class PluginControllerTest extends TestCase
]); ]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonPath('message', 'plugin.json을 찾을 수 없습니다.'); $response->assertJsonPath('message', __('plugins.errors.plugin_json_not_found'));
} }
/** /**
@@ -864,11 +868,11 @@ class PluginControllerTest extends TestCase
*/ */
public function test_install_from_file_returns_500_on_general_exception(): void public function test_install_from_file_returns_500_on_general_exception(): void
{ {
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromZipFile') $pluginServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andThrow(new \Exception('예상치 못한 오류')); ->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
$file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip');
@@ -970,7 +974,7 @@ class PluginControllerTest extends TestCase
*/ */
public function test_install_from_github_calls_service_with_valid_url(): void public function test_install_from_github_calls_service_with_valid_url(): void
{ {
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromGithub') $pluginServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->with('https://github.com/sirsoft/sample-plugin') ->with('https://github.com/sirsoft/sample-plugin')
@@ -979,7 +983,7 @@ class PluginControllerTest extends TestCase
'name' => 'Sample Plugin', 'name' => 'Sample Plugin',
'version' => '1.0.0', 'version' => '1.0.0',
]); ]);
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
$response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-plugin', 'github_url' => 'https://github.com/sirsoft/sample-plugin',
@@ -993,20 +997,20 @@ class PluginControllerTest extends TestCase
/** /**
* PluginService에서 RuntimeException 발생 시 422 반환 * PluginService에서 RuntimeException 발생 시 422 반환
*/ */
public function test_install_from_github_returns_422_on_runtime_exception(): void public function test_install_from_github_returns_422_on_domain_exception(): void
{ {
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromGithub') $pluginServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->andThrow(new \RuntimeException('GitHub 저장소를 다운로드할 수 없습니다.')); ->andThrow(new PluginOperationException('plugins.errors.github_repo_not_found'));
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
$response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-plugin', 'github_url' => 'https://github.com/sirsoft/sample-plugin',
]); ]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonPath('message', 'GitHub 저장소를 다운로드할 수 없습니다.'); $response->assertJsonPath('message', __('plugins.errors.github_repo_not_found'));
} }
/** /**
@@ -1014,11 +1018,11 @@ class PluginControllerTest extends TestCase
*/ */
public function test_install_from_github_returns_500_on_general_exception(): void public function test_install_from_github_returns_500_on_general_exception(): void
{ {
$pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromGithub') $pluginServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->andThrow(new \Exception('예상치 못한 오류')); ->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); $this->app->instance(PluginService::class, $pluginServiceMock);
$response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-plugin', 'github_url' => 'https://github.com/sirsoft/sample-plugin',
@@ -4,6 +4,7 @@ namespace Tests\Feature\Api\Admin;
use App\Contracts\Extension\TemplateManagerInterface; use App\Contracts\Extension\TemplateManagerInterface;
use App\Enums\ExtensionOwnerType; use App\Enums\ExtensionOwnerType;
use App\Exceptions\TemplateOperationException;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\Template; use App\Models\Template;
@@ -1024,12 +1025,12 @@ class TemplateControllerTest extends TestCase
/** /**
* TemplateService에서 RuntimeException 발생 시 422 반환 * TemplateService에서 RuntimeException 발생 시 422 반환
*/ */
public function test_install_from_file_returns_422_on_runtime_exception(): void public function test_install_from_file_returns_422_on_domain_exception(): void
{ {
$templateServiceMock = Mockery::mock(TemplateService::class); $templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromZipFile') $templateServiceMock->shouldReceive('installFromZipFile')
->once() ->once()
->andThrow(new \RuntimeException('template.json을 찾을 수 없습니다.')); ->andThrow(new TemplateOperationException('templates.errors.template_json_not_found'));
$this->app->instance(TemplateService::class, $templateServiceMock); $this->app->instance(TemplateService::class, $templateServiceMock);
$file = UploadedFile::fake()->create('template.zip', 100, 'application/zip'); $file = UploadedFile::fake()->create('template.zip', 100, 'application/zip');
@@ -1039,7 +1040,7 @@ class TemplateControllerTest extends TestCase
]); ]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonPath('message', 'template.json을 찾을 수 없습니다.'); $response->assertJsonPath('message', __('templates.errors.template_json_not_found'));
} }
/** /**
@@ -1094,12 +1095,12 @@ class TemplateControllerTest extends TestCase
/** /**
* TemplateService에서 RuntimeException 발생 시 422 반환 (GitHub) * TemplateService에서 RuntimeException 발생 시 422 반환 (GitHub)
*/ */
public function test_install_from_github_returns_422_on_runtime_exception(): void public function test_install_from_github_returns_422_on_domain_exception(): void
{ {
$templateServiceMock = Mockery::mock(TemplateService::class); $templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromGithub') $templateServiceMock->shouldReceive('installFromGithub')
->once() ->once()
->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.')); ->andThrow(new TemplateOperationException('templates.errors.github_repo_not_found'));
$this->app->instance(TemplateService::class, $templateServiceMock); $this->app->instance(TemplateService::class, $templateServiceMock);
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [ $response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
@@ -1107,7 +1108,7 @@ class TemplateControllerTest extends TestCase
]); ]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.'); $response->assertJsonPath('message', __('templates.errors.github_repo_not_found'));
} }
/** /**
@@ -0,0 +1,256 @@
<?php
namespace Tests\Feature\Extension;
use App\Enums\ExtensionStatus;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Providers\ModuleRouteServiceProvider;
use App\Providers\PluginRouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Facade;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use Tests\TestCase;
/**
* 확장 라우트가 "활성 상태" 로 게이트되는지에 대한 회귀 테스트.
*
* 확장을 비활성화하면 화면·메뉴·프론트엔드 에셋은 사라지지만, 라우트 등록이 게이트되지
* 않으면 그 확장의 API 는 계속 호출 가능한 상태로 남는다. 컨트롤러 파일이 그대로 있으므로
* 요청은 정상 처리되고, 오류도 로그도 남지 않는다 — 화면만 보고는 알 수 없다.
*
* 모듈 쪽에는 이 게이트가 있었으나 플러그인 쪽에는 처음부터 없었다(도입된 적 없음).
* 두 경로가 같은 기준을 쓰는지 실제 라우트 등록 결과로 확인한다.
*/
class ExtensionRouteActiveGateTest extends TestCase
{
use RefreshDatabase;
/** @var array<int, string> 이 테스트가 라우트 등록까지 확인할 확장 */
protected array $requiredExtensions = ['plugins/sirsoft-gdpr', 'modules/sirsoft-page'];
/** @var Router|null 교체 전 원본 라우터 */
private ?Router $originalRouter = null;
protected function tearDown(): void
{
$this->restoreRouter();
parent::tearDown();
}
#[Test]
public function 비활성_플러그인의_라우트는_등록되지_않는다(): void
{
$this->seedPlugin('sirsoft-gdpr', ExtensionStatus::Active);
PluginManager::invalidatePluginStatusCache();
$active = $this->registerPluginRoutes();
$this->assertNotEmpty(
$this->urisFor($active, 'api/plugins/sirsoft-gdpr'),
'활성 플러그인의 라우트가 등록되지 않았다 — 판정기 모집단이 비어 아래 단언이 무의미해진다'
);
$this->seedPlugin('sirsoft-gdpr', ExtensionStatus::Inactive);
PluginManager::invalidatePluginStatusCache();
$inactive = $this->registerPluginRoutes();
$this->assertSame(
[],
$this->urisFor($inactive, 'api/plugins/sirsoft-gdpr'),
'비활성 플러그인의 라우트가 등록됐다 — 화면·메뉴만 사라지고 API 는 계속 호출 가능한 상태가 된다'
);
}
#[Test]
public function 비활성_모듈의_라우트는_등록되지_않는다(): void
{
$this->seedModule('sirsoft-page', ExtensionStatus::Active);
ModuleManager::invalidateModuleStatusCache();
$active = $this->registerModuleRoutes();
$this->assertNotEmpty(
$this->urisFor($active, 'api/modules/sirsoft-page'),
'활성 모듈의 라우트가 등록되지 않았다 — 판정기 모집단이 비어 아래 단언이 무의미해진다'
);
$this->seedModule('sirsoft-page', ExtensionStatus::Inactive);
ModuleManager::invalidateModuleStatusCache();
$inactive = $this->registerModuleRoutes();
$this->assertSame(
[],
$this->urisFor($inactive, 'api/modules/sirsoft-page'),
'비활성 모듈의 라우트가 등록됐다'
);
}
#[Test]
public function 두_라우트_프로바이더가_같은_활성_게이트를_쓴다(): void
{
// 한쪽에만 게이트가 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만
// 나타난다. 소스에서 게이트 존재를 함께 확인해, 한쪽만 제거되는 회귀를 막는다.
$cases = [
ModuleRouteServiceProvider::class => 'getActiveModuleIdentifiers',
PluginRouteServiceProvider::class => 'getActivePluginIdentifiers',
];
foreach ($cases as $class => $resolver) {
$source = file_get_contents((new ReflectionClass($class))->getFileName());
$this->assertStringContainsString(
'self::'.$resolver.'()',
$source,
$class.' 가 활성 확장 목록을 조회하지 않는다'
);
$this->assertMatchesRegularExpression(
'/if \(! in_array\(\$\w+, \$active\w+\)\) \{\s*continue;/',
$source,
$class.' 가 활성 목록으로 라우트 등록을 게이트하지 않는다 — '
.'비활성 확장의 API 가 계속 호출 가능해진다'
);
}
}
/**
* 플러그인 라우트를 격리된 라우터에 등록하고 그 라우터를 반환합니다.
*
* @return Router 등록 결과가 담긴 라우터
*/
private function registerPluginRoutes(): Router
{
return $this->registerRoutesWith(PluginRouteServiceProvider::class, 'loadPluginRoutes');
}
/**
* 모듈 라우트를 격리된 라우터에 등록하고 그 라우터를 반환합니다.
*
* @return Router 등록 결과가 담긴 라우터
*/
private function registerModuleRoutes(): Router
{
return $this->registerRoutesWith(ModuleRouteServiceProvider::class, 'loadModuleRoutes');
}
/**
* 프로바이더의 라우트 로더를 격리된 라우터 위에서 실행합니다.
*
* 애플리케이션 라우터를 그대로 쓰면 이미 부팅 시 등록된 라우트와 섞여 "이번 호출이
* 등록한 것" 을 분리할 수 없다. 빈 라우터로 교체해 이번 호출의 결과만 관측한다.
*
* @param string $providerClass 라우트 프로바이더 클래스
* @param string $loader 라우트 로더 메서드명
* @return Router 등록 결과가 담긴 라우터
*/
private function registerRoutesWith(string $providerClass, string $loader): Router
{
$this->restoreRouter();
$this->originalRouter = app('router');
$fresh = new Router(app('events'), app());
app()->instance('router', $fresh);
Facade::clearResolvedInstance('router');
$provider = new $providerClass(app());
(new ReflectionClass($provider))->getMethod($loader)->invoke($provider);
$this->restoreRouter();
return $fresh;
}
/**
* 교체했던 애플리케이션 라우터를 원복합니다.
*/
private function restoreRouter(): void
{
if ($this->originalRouter === null) {
return;
}
app()->instance('router', $this->originalRouter);
Facade::clearResolvedInstance('router');
$this->originalRouter = null;
}
/**
* 라우터에 등록된 URI 중 주어진 접두사로 시작하는 것을 반환합니다.
*
* @param Router $router 대상 라우터
* @param string $prefix URI 접두사
* @return array<int, string> 매칭된 URI 목록
*/
private function urisFor(Router $router, string $prefix): array
{
$uris = [];
foreach ($router->getRoutes() as $route) {
if (str_starts_with($route->uri(), $prefix)) {
$uris[] = $route->uri();
}
}
return array_values(array_unique($uris));
}
/**
* plugins 테이블에 지정한 상태로 행을 준비합니다.
*
* @param string $identifier 플러그인 식별자
* @param ExtensionStatus $status 적용할 상태
*/
private function seedPlugin(string $identifier, ExtensionStatus $status): void
{
$this->seedExtensionRow('plugins', $identifier, $status);
}
/**
* modules 테이블에 지정한 상태로 행을 준비합니다.
*
* @param string $identifier 모듈 식별자
* @param ExtensionStatus $status 적용할 상태
*/
private function seedModule(string $identifier, ExtensionStatus $status): void
{
$this->seedExtensionRow('modules', $identifier, $status);
}
/**
* 확장 테이블에 식별자/상태 행을 upsert 합니다.
*
* @param string $table 대상 테이블 (modules|plugins)
* @param string $identifier 확장 식별자
* @param ExtensionStatus $status 적용할 상태
*/
private function seedExtensionRow(string $table, string $identifier, ExtensionStatus $status): void
{
$existing = DB::table($table)->where('identifier', $identifier)->first();
if ($existing !== null) {
DB::table($table)->where('identifier', $identifier)->update([
'status' => $status->value,
'updated_at' => now(),
]);
return;
}
DB::table($table)->insert([
'identifier' => $identifier,
'vendor' => explode('-', $identifier)[0],
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'version' => '1.0.0',
'status' => $status->value,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -71,7 +71,9 @@ class ExtensionRouteCacheInvalidationTest extends TestCase
foreach ($methods as $method) { foreach ($methods as $method) {
$this->assertStringContainsString( $this->assertStringContainsString(
'RouteCacheHelper::rebuild()', 'RouteCacheHelper::rebuild()',
$this->methodSource($class, $method), // 주석을 걷어낸 뒤 본다 — 설명 주석의 언급만으로 통과하면
// 호출이 사라져도 green 이 된다.
$this->stripComments($this->methodSource($class, $method)),
$class.'::'.$method.' 이 라우트 캐시를 갱신하지 않는다 — ' $class.'::'.$method.' 이 라우트 캐시를 갱신하지 않는다 — '
.'캐시가 걸린 사이트에서 이 조작 뒤 확장 라우트가 404 가 된다' .'캐시가 걸린 사이트에서 이 조작 뒤 확장 라우트가 404 가 된다'
); );
@@ -105,6 +107,77 @@ class ExtensionRouteCacheInvalidationTest extends TestCase
} }
} }
#[Test]
public function 라우트_캐시_재생성은_상태_캐시_무효화_뒤에_온다(): void
{
$targets = [
ModuleManager::class => 'invalidateModuleStatusCache',
PluginManager::class => 'invalidatePluginStatusCache',
];
foreach ($targets as $class => $invalidator) {
$found = 0;
$reflection = new ReflectionClass($class);
foreach ($reflection->getMethods() as $method) {
// trait 메서드는 getDeclaringClass() 가 사용 클래스를 가리키므로 이 필터만으로는
// 걸러지지 않는다. methodSource() 는 클래스 파일을 줄 번호로 자르므로,
// 다른 파일에 정의된 메서드가 섞이면 엉뚱한 구간을 읽고 판정이 오염된다.
if ($method->getFileName() !== $reflection->getFileName()) {
continue;
}
// 주석을 걷어낸 뒤 판정한다 — 주석 안의 호출 언급을 실제 호출로 오인하면
// 순서 판정이 통째로 뒤집힌다(설명 주석이 호출보다 앞서는 것이 보통이다).
$source = $this->stripComments($this->methodSource($class, $method->getName()));
$rebuildAt = strpos($source, 'RouteCacheHelper::rebuild()');
if ($rebuildAt === false) {
continue;
}
$found++;
$invalidateAt = strpos($source, $invalidator.'()');
$this->assertNotFalse(
$invalidateAt,
$class.'::'.$method->getName().' 이 라우트 캐시를 구우면서 상태 캐시를 무효화하지 않는다'
);
// route:cache 는 새 앱을 부팅해 캐시된 활성 확장 목록을 읽는다.
// 무효화가 뒤에 오면 방금 바뀐 상태가 반영되지 않은 채 라우트가 박제된다.
$this->assertLessThan(
$rebuildAt,
$invalidateAt,
$class.'::'.$method->getName().' 이 상태 캐시 무효화보다 먼저 라우트 캐시를 굽는다 — '
.'그 확장의 라우트가 빠진 채 박제되어 오류 없이 404 가 된다 (#519 회귀)'
);
}
// 판정기 자신이 모집단을 잃는 것을 막는 가드 — 리플렉션 필터가 잘못되면
// 0건을 순회하고도 green 이 된다.
$this->assertGreaterThanOrEqual(5, $found, $class.' 에서 굽기 지점을 도출하지 못했다 — 판정기 모집단이 비었다');
}
}
#[Test]
public function 업데이트는_상태_복원_뒤에_훅_캐시를_다시_굽는다(): void
{
foreach ([[ModuleManager::class, 'updateModule'], [PluginManager::class, 'updatePlugin']] as [$class, $method]) {
// 주석을 걷어낸 뒤 판정한다 — 설명 주석의 언급만으로 통과하면 안 된다.
$source = $this->stripComments($this->methodSource($class, $method));
// Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너를 누락한 채 남는다.
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 stale 은 조용히 통과한다.
$this->assertStringContainsString(
'regenerateHookCache()',
$source,
$class.'::'.$method.' 이 상태 복원 뒤 훅 캐시를 재생성하지 않는다'
);
}
}
/** /**
* 지정한 메서드의 소스 본문을 반환합니다. * 지정한 메서드의 소스 본문을 반환합니다.
* *
@@ -124,4 +197,34 @@ class ExtensionRouteCacheInvalidationTest extends TestCase
$target->getEndLine() - $target->getStartLine() + 1 $target->getEndLine() - $target->getStartLine() + 1
)); ));
} }
/**
* 소스에서 주석을 제거합니다.
*
* 호출 순서를 문자열 위치로 판정하므로, 그 호출을 설명하는 주석이 실제 호출로
* 오인되면 판정이 뒤집힌다. 어휘 분석으로 주석 토큰만 걷어낸다.
*
* @param string $source 대상 소스
* @return string 주석이 제거된 소스
*/
private function stripComments(string $source): string
{
$stripped = '';
foreach (token_get_all('<?php '.$source) as $token) {
if (is_array($token)) {
if ($token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) {
continue;
}
$stripped .= $token[1];
continue;
}
$stripped .= $token;
}
return $stripped;
}
} }
@@ -0,0 +1,283 @@
<?php
namespace Tests\Feature\Http;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 오류 문구의 치환 자리가 비어 노출되는 것을 막는 회귀 테스트.
*
* `:error` 같은 치환 자리를 가진 다국어 키를 파라미터 없이 부르면, Laravel 은 그 자리를
* 그대로 둔 문장을 돌려준다. 그래서 운영자 화면에는 "모듈 활성화에 실패했습니다: :error"
* 처럼 내부 자리표시자가 그대로 보인다. 예외도 로그도 남지 않고, 실패했을 때만 드러나므로
* 정상 흐름 테스트로는 잡히지 않는다.
*
* 호출부를 손으로 열거하지 않는다 — 새 실패 분기가 생겼을 때 그 지점만 조용히 빠진다.
* lang 파일에서 `:error` 를 요구하는 키를 도출하고, 그 키를 쓰는 호출부 전수를 검사한다.
*/
class ErrorMessageParamSubstitutionTest extends TestCase
{
#[Test]
public function error_치환_자리를_가진_키는_언제나_파라미터와_함께_호출된다(): void
{
$missing = [];
$checked = 0;
foreach ($this->phpFilesIn(app_path()) as $file) {
$source = File::get($file);
foreach ($this->errorCallsIn($source) as [$offset, $call]) {
if (! preg_match("/^\(\s*'([^']+)'/", $call, $m)) {
continue;
}
// 키를 실제로 해석해 판정한다. lang 파일을 직접 파싱하면 중첩 키
// (`modules.errors.install_failed`)의 경로를 잃어 오탐·누락이 함께 생긴다.
// 존재하지 않는 키는 번역기가 키 자체를 돌려주므로 자연히 걸러진다.
if (! str_contains(trans($m[1], [], 'ko'), ':error')) {
continue;
}
$checked++;
// messageParams(넷째 인자)에 'error' 키가 실렸는지 본다.
// 셋째 인자 errors 페이로드는 다른 통로이므로 치환에 쓰이지 않는다.
// 배열의 첫 키가 아닐 수 있으므로(`['module' => ..., 'error' => ...]`)
// 인자를 최상위 쉼표 기준으로 갈라 넷째 인자만 본다.
$params = $this->topLevelArguments($call)[3] ?? '';
if (preg_match("/'error'\s*=>/", $params) === 1) {
continue;
}
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
$missing[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file).':'.$line.' '.$m[1];
}
}
$this->assertGreaterThan(
50,
$checked,
'검사한 호출부가 너무 적다 — 호출부 탐지 정규식이 모집단을 잃었다'
);
$this->assertSame(
[],
$missing,
'다음 호출부가 :error 치환 파라미터 없이 오류 문구를 반환한다 — '
."운영자 화면에 자리표시자 \":error\" 가 그대로 노출된다:\n "
.implode("\n ", $missing)
);
}
#[Test]
public function error_치환_자리를_가진_키는_예외_생성자에서도_파라미터와_함께_전달된다(): void
{
$missing = [];
$checked = 0;
foreach ($this->phpFilesIn(app_path()) as $file) {
$source = File::get($file);
foreach ($this->exceptionConstructorsIn($source) as [$offset, $call]) {
$args = $this->topLevelArguments($call);
if (! preg_match("/^\s*'([^']+)'/", $args[0] ?? '', $m)) {
continue;
}
if (! str_contains(trans($m[1], [], 'ko'), ':error')) {
continue;
}
$checked++;
// 예외 생성자는 `->error()` 와 인자 배치가 다르다 —
// 파라미터 배열이 넷째가 아니라 **둘째** 인자다.
if (preg_match("/'error'\s*=>/", $args[1] ?? '') === 1) {
continue;
}
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
$missing[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file).':'.$line.' '.$m[1];
}
}
$this->assertGreaterThan(
0,
$checked,
'검사한 예외 생성자가 0건이다 — 탐지 정규식이 모집단을 잃었다'
);
$this->assertSame(
[],
$missing,
'다음 예외 생성자가 :error 치환 파라미터 없이 던져진다 — 그 메시지가 응답으로 '
."나가면 운영자 화면에 자리표시자 \":error\" 가 그대로 노출된다:\n "
.implode("\n ", $missing)
);
}
/**
* 소스에서 `new *OperationException(...)` 생성자 호출 전체를 잘라 반환합니다.
*
* `->error()` 스캐너는 첫 인자가 문자열 리터럴이 아니면 건너뛰므로, 키를 들고 다니는
* 예외로 던지는 경로는 그 판정에 걸리지 않는다. 그 축을 이 스캐너가 덮는다.
*
* @param string $source 대상 소스
* @return array<int, array{0: int, 1: string}> [호출 시작 오프셋, 호출 전체 문자열]
*/
private function exceptionConstructorsIn(string $source): array
{
$calls = [];
if (! preg_match_all('/new\s+\\\\?(\w*OperationException)\(/', $source, $m, PREG_OFFSET_CAPTURE)) {
return $calls;
}
foreach ($m[0] as [$match, $at]) {
foreach ($this->callsIn(substr($source, $at), $match) as [$rel, $call]) {
if ($rel === 0) {
$calls[] = [$at, $call];
break;
}
}
}
return $calls;
}
/**
* 소스에서 `->error(...)` 호출 전체를 괄호 균형으로 잘라 반환합니다.
*
* 한 줄만 보면 여러 줄에 걸친 호출의 뒷부분(치환 파라미터)을 놓쳐 오탐이 된다.
*
* @param string $source 대상 소스
* @return array<int, array{0: int, 1: string}> [호출 시작 오프셋, 호출 전체 문자열]
*/
private function errorCallsIn(string $source): array
{
return $this->callsIn($source, '->error(');
}
/**
* 소스에서 지정한 여는 토큰으로 시작하는 호출 전체를 괄호 균형으로 잘라 반환합니다.
*
* @param string $source 대상 소스
* @param string $token 호출 시작 토큰 (여는 괄호 포함, 예: `->error(`)
* @return array<int, array{0: int, 1: string}> [호출 시작 오프셋, 호출 전체 문자열]
*/
private function callsIn(string $source, string $token): array
{
$calls = [];
$offset = 0;
while (($at = strpos($source, $token, $offset)) !== false) {
$open = $at + strlen($token) - 1;
$depth = 0;
$end = null;
for ($i = $open, $len = strlen($source); $i < $len; $i++) {
if ($source[$i] === '(') {
$depth++;
} elseif ($source[$i] === ')') {
$depth--;
if ($depth === 0) {
$end = $i;
break;
}
}
}
if ($end === null) {
break;
}
$calls[] = [$at, substr($source, $open, $end - $open + 1)];
$offset = $at + 1;
}
return $calls;
}
/**
* 호출 문자열 `( ... )` 을 최상위 쉼표 기준으로 갈라 인자 목록을 반환합니다.
*
* 중첩 괄호·대괄호와 따옴표 안의 쉼표는 경계로 보지 않는다.
*
* @param string $call 괄호를 포함한 호출 문자열
* @return array<int, string> 인자 문자열 목록
*/
private function topLevelArguments(string $call): array
{
$inner = substr($call, 1, -1);
$args = [];
$buffer = '';
$depth = 0;
$quote = null;
for ($i = 0, $len = strlen($inner); $i < $len; $i++) {
$char = $inner[$i];
if ($quote !== null) {
$buffer .= $char;
if ($char === '\\') {
$buffer .= $inner[++$i] ?? '';
} elseif ($char === $quote) {
$quote = null;
}
continue;
}
if ($char === "'" || $char === '"') {
$quote = $char;
$buffer .= $char;
continue;
}
if ($char === '(' || $char === '[') {
$depth++;
} elseif ($char === ')' || $char === ']') {
$depth--;
} elseif ($char === ',' && $depth === 0) {
$args[] = trim($buffer);
$buffer = '';
continue;
}
$buffer .= $char;
}
if (trim($buffer) !== '') {
$args[] = trim($buffer);
}
return $args;
}
/**
* 주어진 디렉토리 아래의 PHP 파일 경로를 모두 반환합니다.
*
* @param string $directory 대상 디렉토리
* @return array<int, string> PHP 파일 경로 목록
*/
private function phpFilesIn(string $directory): array
{
$files = [];
foreach (File::allFiles($directory) as $file) {
if ($file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
}
@@ -38,40 +38,29 @@ class GenericCatchStatusCodeContractTest extends TestCase
/** /**
* 예외 원문을 메시지 키로 넘기는 것이 아직 남아 있는 지점. * 예외 원문을 메시지 키로 넘기는 것이 아직 남아 있는 지점.
* *
* 코어 확장 설치 경로는 `\RuntimeException(__('key', [...]))` 형태로 서비스·헬퍼 * 확장 설치 경로 6곳(모듈·플러그인·템플릿 × from-file/from-github)이 여기 있었다.
* 40여 곳에서 던져진다. 키를 들고 다니는 예외로 승격하려면 설치/업데이트 경로 전체를 * 도메인 실패가 이미 `*OperationException`(errorKey + params)으로 승격되어 있었는데
* 건드려야 하므로 별도 작업으로 분리한다 — 여기 남겨 두는 이유는 "판정기가 못 봐서 * catch 만 부모 `\RuntimeException` 으로 남아, 이미 번역된 문장을 키 자리로 넘기고
* 통과한 것" 과 "알고 남긴 것" 을 구분하기 위해서다. 목록이 늘어나면 실패한다. * 있었다. typed catch 로 좁히고 원본 키·파라미터를 넘기도록 바꿔 전부 해소했다.
*
* 비운 채로 둔다 — 새 위반이 생기면 그 자리에서 실패해야 한다.
* *
* @var array<int, string> * @var array<int, string>
*/ */
private const KNOWN_EXCEPTION_TEXT_AS_KEY = [ private const KNOWN_EXCEPTION_TEXT_AS_KEY = [];
'app/Http/Controllers/Api/Admin/ModuleController.php:437',
'app/Http/Controllers/Api/Admin/ModuleController.php:461',
'app/Http/Controllers/Api/Admin/PluginController.php:448',
'app/Http/Controllers/Api/Admin/PluginController.php:472',
'app/Http/Controllers/Api/Admin/TemplateController.php:352',
'app/Http/Controllers/Api/Admin/TemplateController.php:376',
];
/** /**
* 광역 `\RuntimeException` catch 가 4xx 를 반환하는 것이 아직 남아 있는 지점. * 광역 `\RuntimeException` catch 가 4xx 를 반환하는 것이 아직 남아 있는 지점.
* *
* `\RuntimeException` 은 도메인 예외의 부모가 되기 쉬워, 도메인 실패를 typed 로 * `\RuntimeException` 은 도메인 예외의 부모가 되기 쉬워, 도메인 실패를 typed 로
* 승격한 뒤에도 이 catch 를 남겨 두면 남는 것은 인프라 예외뿐인데 그것까지 4xx 로 * 승격한 뒤에도 이 catch 를 남겨 두면 남는 것은 인프라 예외뿐인데 그것까지 4xx 로
* 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이며, 같은 설치 경로 개편에서 * 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이었고 함께 해소되었다.
* 함께 해소된다. *
* 비운 채로 둔다 — 새 위반이 생기면 그 자리에서 실패해야 한다.
* *
* @var array<string, string> * @var array<string, string>
*/ */
private const KNOWN_BROAD_RUNTIME_CATCH_4XX = [ private const KNOWN_BROAD_RUNTIME_CATCH_4XX = [];
'app/Http/Controllers/Api/Admin/ModuleController.php::installFromFile' => '설치 경로가 도메인 실패를 키 없는 RuntimeException 으로 던진다 — 예외 승격과 함께 해소',
'app/Http/Controllers/Api/Admin/ModuleController.php::installFromGithub' => '동일',
'app/Http/Controllers/Api/Admin/PluginController.php::installFromFile' => '동일',
'app/Http/Controllers/Api/Admin/PluginController.php::installFromGithub' => '동일',
'app/Http/Controllers/Api/Admin/TemplateController.php::installFromFile' => '동일',
'app/Http/Controllers/Api/Admin/TemplateController.php::installFromGithub' => '동일',
];
/** /**
* 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인). * 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인).