fix(identity): 잔여 과제 전건 처리 + 로딩 표시 실렌더 확정
앞선 커밋이 CHANGELOG 에 주장한 "로딩 표시가 나오지 않던 문제 수정" 이 실제로 화면에 나타나는지 브라우저로 확정했다. 전달 경로부터 DOM 마운트·해제까지 측정했고, 지연·에러 응답에서도 영구 로딩이 남지 않음을 확인했다. 측정 과정에서 앞선 세션의 판단 하나가 틀렸음을 확인했다 — "오버레이 DOM 이 어느 화면에서도 관측되지 않는다" 는 관측 대상 노드를 잘못 잡은 것이었다. 스피너 방식 레이아웃은 다른 노드를 쓰며, 대조군에서도 안 보였던 이유가 이것이다. 미뤄 두었던 과제 4건을 함께 구현했다. - 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시한다. 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았다 - 플러그인 설정 화면이 저장된 비밀값을 다시 내려받던 것을 가려서 표시한다. 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지된다 - 언어팩 반영 누락 검사가 버전만 비교해 "버전 같고 내용 다름" 을 놓치던 사각을 콘텐츠 비교로 닫았다 - 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 6개를 표준 형태로 통일했다. 예전 이름도 함께 발행하므로 기존 확장은 수정 없이 동작한다 정적 검사 룰 2종의 오탐·사각을 함께 고쳤고, 개명으로 검사 대상에 편입된 파일의 사전 결함 8건을 해소했다. 검수용 데이터는 전량 정리했다.
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
|
||||
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
|
||||
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
|
||||
- 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -75,6 +76,7 @@
|
||||
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
|
||||
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
|
||||
- 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다.
|
||||
- 플러그인 설정 화면이 저장된 비밀값(암호화 키·API 시크릿 등)을 다시 내려받던 것을 가려서 표시하도록 바로잡았습니다. 값이 저장되어 있다는 표시만 보여주며, 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지됩니다.
|
||||
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
|
||||
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
|
||||
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
|
||||
|
||||
@@ -19,6 +19,13 @@ class HookManager implements HookManagerInterface
|
||||
|
||||
private static array $dispatching = [];
|
||||
|
||||
/**
|
||||
* 구 훅 이름 사용 경고를 이미 남긴 훅 이름 집합 — 요청마다 한 번씩만 경고합니다.
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $legacyHookNoticeShown = [];
|
||||
|
||||
/**
|
||||
* 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는
|
||||
* 단일 핸들러 패턴에 사용됩니다. (내부 전용)
|
||||
@@ -362,4 +369,43 @@ class HookManager implements HookManagerInterface
|
||||
|
||||
return static::applyFilters($hookName, $value, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 이름과 구 이름을 함께 발행하는 Filter 실행
|
||||
*
|
||||
* 이름이 표준(`core.{대상}.{동작}_validation_rules`)과 어긋난 채 이미 공개된 훅을 표준 이름으로
|
||||
* 옮길 때 사용합니다. 표준 이름을 먼저 적용하고 그 결과에 구 이름을 다시 적용하므로,
|
||||
* 새로 구독하는 확장과 구 이름을 구독 중인 기존 확장이 함께 동작합니다.
|
||||
*
|
||||
* 구 이름에 실제 구독자가 있을 때만 한 번 경고를 남깁니다 — 개명 사실을 확장 개발자가 알 수 있게
|
||||
* 하되, 구독자가 없는 환경에서 로그를 어지럽히지 않기 위함입니다.
|
||||
*
|
||||
* @param string $hookName 표준 훅 이름
|
||||
* @param string $legacyHookName 구 훅 이름 (하위호환용)
|
||||
* @param mixed $value 필터링할 값
|
||||
* @param mixed ...$args 필터에 전달할 추가 인자
|
||||
* @return mixed 필터링된 값
|
||||
*/
|
||||
public static function applyFiltersWithLegacyName(
|
||||
string $hookName,
|
||||
string $legacyHookName,
|
||||
mixed $value = null,
|
||||
...$args
|
||||
): mixed {
|
||||
$value = static::applyFilters($hookName, $value, ...$args);
|
||||
|
||||
if (! isset(self::$filters[$legacyHookName])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! isset(self::$legacyHookNoticeShown[$legacyHookName])) {
|
||||
self::$legacyHookNoticeShown[$legacyHookName] = true;
|
||||
Log::warning('구 훅 이름 사용 중 — 표준 이름으로 옮겨 주세요', [
|
||||
'legacy' => $legacyHookName,
|
||||
'standard' => $hookName,
|
||||
]);
|
||||
}
|
||||
|
||||
return static::applyFilters($legacyHookName, $value, ...$args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Extension\PluginManager;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
|
||||
use App\Services\PluginSettingsService;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
@@ -18,13 +20,35 @@ class PluginSettingsController extends AdminBaseController
|
||||
* PluginSettingsController 생성자
|
||||
*
|
||||
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
|
||||
* @param PluginManager $pluginManager 설정 스키마(sensitive 플래그) 조회용
|
||||
*/
|
||||
public function __construct(
|
||||
private PluginSettingsService $pluginSettingsService
|
||||
private PluginSettingsService $pluginSettingsService,
|
||||
private PluginManager $pluginManager
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에 실을 설정에서 민감값을 마스크로 치환한다.
|
||||
*
|
||||
* 이 응답은 관리자 화면으로 나가므로 암호화 키·시크릿의 평문이 브라우저·개발자 도구·프록시
|
||||
* 로그에 남지 않아야 한다. 값이 저장되어 있다는 사실만 마스크로 알린다. 운영자가 값을 건드리지
|
||||
* 않으면 화면이 마스크를 그대로 되돌려 보내고, 저장 단계가 그것을 걸러 기존 값을 보존한다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 복호화된 설정
|
||||
* @return array<string, mixed> 마스킹된 설정
|
||||
*/
|
||||
private function maskSensitive(string $identifier, array $settings): array
|
||||
{
|
||||
$plugin = $this->pluginManager->getPlugin($identifier);
|
||||
|
||||
return $plugin === null
|
||||
? $settings
|
||||
: SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema());
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 조회합니다.
|
||||
*
|
||||
@@ -39,7 +63,7 @@ class PluginSettingsController extends AdminBaseController
|
||||
return $this->notFound('plugins.not_found');
|
||||
}
|
||||
|
||||
return $this->success('messages.success', $settings);
|
||||
return $this->success('messages.success', $this->maskSensitive($identifier, $settings));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +89,7 @@ class PluginSettingsController extends AdminBaseController
|
||||
|
||||
return $this->success(
|
||||
'plugins.settings.updated',
|
||||
$this->pluginSettingsService->get($identifier)
|
||||
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,19 +106,16 @@ class UpdatePluginSettingsRequest extends FormRequest
|
||||
$rules[$field] = $fieldRules;
|
||||
}
|
||||
|
||||
// audit:allow core-formrequest-hook-filter reason: 훅 이름이 표준(`{대상}.{동작}_validation_rules`)과
|
||||
// 어긋난 상태로 이미 공개되어 있다(`core.plugin_settings.update_rules`). 같은 이탈이 코어에
|
||||
// 6개 더 있으며(core.auth.validate_reset_token_rules / core.auth.verify_password_rules /
|
||||
// core.extension.changelog_rules / core.layout.get_validation_messages /
|
||||
// core.search.validation_rules / core.user.upload_avatar_rules), 이름을 바꾸면 이 훅을
|
||||
// 구독 중인 제3자 확장이 조용히 동작을 멈춘다. 일괄 개명은 PO 판단 사항이라 여기서
|
||||
// 단독으로 바꾸지 않는다.
|
||||
// 표준 이름(`core.{대상}.{동작}_validation_rules`)으로 발행하되, 이미 공개돼 구독 중일 수 있는
|
||||
// 구 이름(`core.plugin_settings.update_rules`)도 함께 발행한다 — 구 이름을 구독하는 제3자
|
||||
// 확장이 조용히 멈추지 않도록 한다. 구 이름은 구독자가 있을 때만 1회 경고가 남는다.
|
||||
//
|
||||
// 3번째 인자로 이번 요청의 입력값을 함께 넘긴다. 확장이 "현재 입력한 모드에 따라
|
||||
// 다른 필드를 필수로 만드는" 조건부 규칙을 만들려면 입력값이 필요한데, 그것이 없어
|
||||
// 확장이 request() 를 직접 들여다보던 것을 없애기 위함이다.
|
||||
// 기존 2인자 구독자는 그대로 동작한다(초과 인자는 무시된다).
|
||||
return HookManager::applyFilters(
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.plugin_settings.update_validation_rules',
|
||||
'core.plugin_settings.update_rules',
|
||||
$rules,
|
||||
$identifier,
|
||||
|
||||
@@ -13,11 +13,13 @@ class ValidateResetTokenRequest extends FormRequest
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* 공개 엔드포인트이므로 항상 통과시킨다 — 접근 제어는 미들웨어가 담당한다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // 공개 엔드포인트
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,8 @@ class ValidateResetTokenRequest extends FormRequest
|
||||
'email' => 'required|email',
|
||||
];
|
||||
|
||||
return HookManager::applyFilters(
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.auth.validate_reset_token_validation_rules',
|
||||
'core.auth.validate_reset_token_rules',
|
||||
$rules,
|
||||
$this
|
||||
|
||||
@@ -9,6 +9,8 @@ class VerifyPasswordRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -27,6 +29,9 @@ class VerifyPasswordRequest extends FormRequest
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.auth.verify_password_rules', $rules);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.auth.verify_password_validation_rules',
|
||||
'core.auth.verify_password_rules',
|
||||
$rules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class ChangelogRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -33,7 +35,10 @@ class ChangelogRequest extends FormRequest
|
||||
'to_version' => 'nullable|string|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$/|required_with:from_version',
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('core.extension.changelog_rules', $rules);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.extension.changelog_validation_rules',
|
||||
'core.extension.changelog_rules',
|
||||
$rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ class SearchRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -39,7 +41,10 @@ class SearchRequest extends FormRequest
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
// 예: sort의 in 규칙, board_slug 등 모듈별 파라미터
|
||||
return HookManager::applyFilters('core.search.validation_rules', $rules, $this);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.search.index_validation_rules',
|
||||
'core.search.validation_rules',
|
||||
$rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,8 @@ class UploadAvatarRequest extends FormRequest
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -46,7 +48,10 @@ class UploadAvatarRequest extends FormRequest
|
||||
],
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('core.user.upload_avatar_rules', $rules);
|
||||
return HookManager::applyFiltersWithLegacyName(
|
||||
'core.user.upload_avatar_validation_rules',
|
||||
'core.user.upload_avatar_rules',
|
||||
$rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use App\Traits\FiltersFrontendSchema;
|
||||
use App\Traits\NormalizesSettingsData;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -140,6 +141,17 @@ class PluginSettingsService
|
||||
return $settings ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 저장합니다.
|
||||
*
|
||||
* 스키마의 sensitive 필드는 암호화하여 보관하며, 화면이 마스크를 그대로 되돌려 보낸 항목은
|
||||
* "변경 없음" 으로 보아 기존 값을 유지합니다. 저장은 기존 설정 위 병합이므로 요청에 없는
|
||||
* 키는 종전 값이 남습니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 저장할 설정 (검증 통과분)
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function save(string $identifier, array $settings): bool
|
||||
{
|
||||
// Before 훅
|
||||
@@ -162,6 +174,9 @@ class PluginSettingsService
|
||||
|
||||
// 스키마 기반으로 민감한 필드 암호화
|
||||
$schema = $pluginInstance->getSettingsSchema();
|
||||
// 화면이 마스크를 그대로 되돌려 보낸 sensitive 필드는 "변경 없음" 이다 — 병합에서 기존 값이
|
||||
// 유지되도록 요청에서 제거한다. 빈 문자열은 제거하지 않는다(지우겠다는 명시적 의사).
|
||||
$settings = SensitiveSettingMask::stripUnchanged($settings, $schema);
|
||||
$settings = $this->encryptSensitiveFields($settings, $schema);
|
||||
|
||||
// 기존 설정과 병합
|
||||
@@ -202,6 +217,14 @@ class PluginSettingsService
|
||||
return $storage->put('settings', self::SETTINGS_FILENAME, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 기본값으로 되돌립니다.
|
||||
*
|
||||
* 설정 파일을 삭제하고 캐시를 비웁니다 — 이후 조회는 플러그인이 선언한 기본값을 돌려줍니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return bool 초기화 성공 여부
|
||||
*/
|
||||
public function reset(string $identifier): bool
|
||||
{
|
||||
// Before 훅
|
||||
@@ -230,6 +253,14 @@ class PluginSettingsService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정 디렉토리를 통째로 삭제합니다.
|
||||
*
|
||||
* 플러그인 삭제 시 남은 설정 파일을 정리하는 용도입니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function deleteSettingsDirectory(string $identifier): bool
|
||||
{
|
||||
// Before 훅
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 민감 설정값(sensitive: true)의 응답 마스킹 규약.
|
||||
*
|
||||
* 관리자 설정 조회 응답은 저장된 비밀값(암호화 키, API 시크릿 등)을 평문으로 되돌려주지 않는다.
|
||||
* 값이 저장되어 있다는 사실만 마스크로 알리고, 실제 값은 서버 안에만 둔다 — 화면·브라우저 개발자
|
||||
* 도구·프록시 로그에 비밀값이 남지 않게 하기 위함이다.
|
||||
*
|
||||
* 저장 쪽과 짝을 이룬다: 운영자가 값을 건드리지 않으면 화면이 마스크를 그대로 되돌려 보내므로,
|
||||
* 저장 단계에서 마스크를 걸러내 기존 값을 보존한다. 빈 문자열은 마스크가 아니라 "지우겠다" 는
|
||||
* 명시적 의사로 취급한다.
|
||||
*
|
||||
* 내부 소비자(provider 에 자격증명을 주입하는 listener 등)는 마스킹되지 않은 값을 그대로 받는다 —
|
||||
* 마스킹은 오직 관리자 API 응답 경계에서만 적용한다.
|
||||
*
|
||||
* @since 7.0.6
|
||||
*/
|
||||
final class SensitiveSettingMask
|
||||
{
|
||||
/**
|
||||
* 저장된 비밀값을 대신하는 마스크 문자열.
|
||||
*
|
||||
* 실제 값으로 오인될 수 없도록 통상적인 키·시크릿 문자셋에 없는 형태를 쓴다.
|
||||
*/
|
||||
public const MASK = '••••••••';
|
||||
|
||||
/**
|
||||
* 스키마의 sensitive 필드를 마스크로 치환한다.
|
||||
*
|
||||
* 값이 비어 있는 필드는 마스크를 씌우지 않는다 — 화면이 "아직 입력되지 않음" 을 구분해야 한다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 설정 배열 (복호화된 상태)
|
||||
* @param array<string, mixed> $schema 설정 스키마 (sensitive 플래그 보유)
|
||||
* @return array<string, mixed> 마스킹된 설정 배열
|
||||
*/
|
||||
public static function apply(array $settings, array $schema): array
|
||||
{
|
||||
foreach ($schema as $field => $config) {
|
||||
if (! is_array($config) || ! ($config['sensitive'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($field, $settings)) {
|
||||
continue;
|
||||
}
|
||||
if ($settings[$field] === null || $settings[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings[$field] = self::MASK;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 요청에서 마스크로 되돌아온 sensitive 필드를 제거한다.
|
||||
*
|
||||
* 제거된 필드는 저장 단계의 기존 설정 병합에서 종전 값이 유지된다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 저장 요청 설정 배열
|
||||
* @param array<string, mixed> $schema 설정 스키마
|
||||
* @return array<string, mixed> 마스크 필드가 제거된 설정 배열
|
||||
*/
|
||||
public static function stripUnchanged(array $settings, array $schema): array
|
||||
{
|
||||
foreach ($schema as $field => $config) {
|
||||
if (! is_array($config) || ! ($config['sensitive'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if (($settings[$field] ?? null) === self::MASK) {
|
||||
unset($settings[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -2860,7 +2860,7 @@ HTTP/1.1 200
|
||||
| token | body | string | 예 | — | 인증/검증 토큰 |
|
||||
| email | body | email | 예 | — | 이메일 주소 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_rules`, `core.auth.validate_reset_token_messages`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_validation_rules`, `core.auth.validate_reset_token_messages`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| avatar | body | image | 예 | max 2048 | 아바타 이미지 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -103,6 +103,6 @@ Content-Type: image/png
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_rules` 훅으로 검증 규칙을 추가할 수 있습니다.
|
||||
**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_validation_rules` 훅으로 검증 규칙을 추가할 수 있습니다.
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -1586,7 +1586,7 @@ HTTP/1.1 200
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_rules` 훅으로 파라미터를 확장할 수 있습니다.
|
||||
**설명** 코어의 버전별 변경사항(CHANGELOG)을 구조화된 배열로 조회합니다. `source`(active/bundled/github)로 어느 위치의 CHANGELOG를 읽을지, `from_version`/`to_version`으로 조회 범위를 지정합니다. `core.settings.read` 권한이 필요하며, 업데이트 안내 화면에서 새 버전에 무엇이 바뀌는지 보여줄 때 사용합니다. 확장은 `core.extension.changelog_validation_rules` 훅으로 파라미터를 확장할 수 있습니다.
|
||||
|
||||
|
||||
### POST /api/admin/core-update/check
|
||||
|
||||
@@ -902,7 +902,7 @@ HTTP/1.1 200
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -753,7 +753,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -924,7 +924,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin_settings.update_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
| sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
|
||||
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.index_validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
|
||||
| category_id | query | integer | 아니오 | — | category 식별자 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.validation_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.index_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -704,7 +704,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| from_version | query | string | 아니오 | — | 시작 버전 (범위 하한) |
|
||||
| to_version | query | string | 아니오 | — | 대상 버전 (범위 상한) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.extension.changelog_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| password | body | string | 예 | — | 비밀번호 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_rules`).
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -131,6 +131,15 @@ core.permission.update_validation_rules
|
||||
core.menu.store_validation_rules
|
||||
core.menu.update_validation_rules
|
||||
|
||||
# 구 이름에서 표준 이름으로 옮긴 훅 (7.0.6) — 구 이름도 함께 발행되므로 기존 구독은 유지되나,
|
||||
# 새로 구독할 때는 표준 이름을 쓴다. 구 이름에 구독자가 있으면 로그에 1회 안내가 남는다.
|
||||
core.plugin_settings.update_validation_rules # 구: core.plugin_settings.update_rules
|
||||
core.auth.validate_reset_token_validation_rules # 구: core.auth.validate_reset_token_rules
|
||||
core.auth.verify_password_validation_rules # 구: core.auth.verify_password_rules
|
||||
core.extension.changelog_validation_rules # 구: core.extension.changelog_rules
|
||||
core.search.index_validation_rules # 구: core.search.validation_rules
|
||||
core.user.upload_avatar_validation_rules # 구: core.user.upload_avatar_rules
|
||||
|
||||
# Layout Extension 훅
|
||||
core.layout_extension.before_apply
|
||||
core.layout_extension.after_apply
|
||||
|
||||
@@ -14,7 +14,7 @@ use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
|
||||
*
|
||||
* core.search.results Filter Hook을 구독하여 검색 결과에 게시글을 추가합니다.
|
||||
* core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다.
|
||||
* core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
* core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
*/
|
||||
class SearchPostsListener implements HookListenerInterface
|
||||
{
|
||||
@@ -43,7 +43,7 @@ class SearchPostsListener implements HookListenerInterface
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.search.validation_rules' => [
|
||||
'core.search.index_validation_rules' => [
|
||||
'method' => 'addValidationRules',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
|
||||
@@ -38,7 +38,7 @@ class SearchPostsListenerTest extends TestCase
|
||||
|
||||
$this->assertArrayHasKey('core.search.results', $hooks);
|
||||
$this->assertArrayHasKey('core.search.build_response', $hooks);
|
||||
$this->assertArrayHasKey('core.search.validation_rules', $hooks);
|
||||
$this->assertArrayHasKey('core.search.index_validation_rules', $hooks);
|
||||
|
||||
foreach ($hooks as $hook) {
|
||||
$this->assertEquals('filter', $hook['type']);
|
||||
|
||||
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
*
|
||||
* core.search.results Filter Hook을 구독하여 검색 결과에 상품을 추가합니다.
|
||||
* core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다.
|
||||
* core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
* core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
*/
|
||||
class SearchProductsListener implements HookListenerInterface
|
||||
{
|
||||
@@ -41,7 +41,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'priority' => 20,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.search.validation_rules' => [
|
||||
'core.search.index_validation_rules' => [
|
||||
'method' => 'addValidationRules',
|
||||
'priority' => 20,
|
||||
'type' => 'filter',
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ class SearchProductsListenerTest extends TestCase
|
||||
|
||||
$this->assertArrayHasKey('core.search.results', $hooks);
|
||||
$this->assertArrayHasKey('core.search.build_response', $hooks);
|
||||
$this->assertArrayHasKey('core.search.validation_rules', $hooks);
|
||||
$this->assertArrayHasKey('core.search.index_validation_rules', $hooks);
|
||||
|
||||
// 모두 filter 타입인지 확인
|
||||
foreach ($hooks as $hook) {
|
||||
|
||||
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Page\Services\PageService;
|
||||
*
|
||||
* core.search.results Filter Hook을 구독하여 검색 결과에 페이지를 추가합니다.
|
||||
* core.search.build_response Filter Hook을 구독하여 응답 구조를 생성합니다.
|
||||
* core.search.validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
* core.search.index_validation_rules Filter Hook을 구독하여 검색 파라미터 규칙을 추가합니다.
|
||||
*/
|
||||
class SearchPagesListener implements HookListenerInterface
|
||||
{
|
||||
@@ -37,7 +37,7 @@ class SearchPagesListener implements HookListenerInterface
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.search.validation_rules' => [
|
||||
'core.search.index_validation_rules' => [
|
||||
'method' => 'addValidationRules',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
|
||||
@@ -49,7 +49,7 @@ class SearchPagesListenerTest extends ModuleTestCase
|
||||
$this->assertCount(3, $hooks);
|
||||
$this->assertArrayHasKey('core.search.results', $hooks);
|
||||
$this->assertArrayHasKey('core.search.build_response', $hooks);
|
||||
$this->assertArrayHasKey('core.search.validation_rules', $hooks);
|
||||
$this->assertArrayHasKey('core.search.index_validation_rules', $hooks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,7 +76,7 @@ class Plugin extends AbstractPlugin
|
||||
*
|
||||
* 라이브 모드(is_test_mode=false) 진입 시 live_mid / live_api_key 를 required 로 강제하는
|
||||
* 조건부 검증은 본 정적 스키마로 표현할 수 없어 ValidateInicisSettingsListener 가
|
||||
* core.plugin_settings.update_rules 필터로 동적 부여한다.
|
||||
* core.plugin_settings.update_validation_rules 필터로 동적 부여한다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Lang;
|
||||
*
|
||||
* 코어 UpdatePluginSettingsRequest 의 정적 스키마는 `required`/타입 규칙만 표현할 수 있어
|
||||
* "is_test_mode=false 일 때만 live_mid / live_api_key 필수" 같은 조건부 검증을 담을 수 없다.
|
||||
* 본 listener 가 `core.plugin_settings.update_rules` filter 로 라이브 모드 진입 시 라이브
|
||||
* 본 listener 가 `core.plugin_settings.update_validation_rules` filter 로 라이브 모드 진입 시 라이브
|
||||
* 자격증명에 required 규칙을 동적 부여한다.
|
||||
*
|
||||
* 검증 기준은 InicisIdentityProvider::isAvailable() 의 라이브 운영 가능 조건
|
||||
@@ -34,7 +34,7 @@ class ValidateInicisSettingsListener implements HookListenerInterface
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.plugin_settings.update_rules' => [
|
||||
'core.plugin_settings.update_validation_rules' => [
|
||||
'method' => 'addLiveModeRules',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ description: |
|
||||
(PUT /api/admin/plugins/{id}/settings → UpdatePluginSettingsRequest → PluginSettingsService)에서:
|
||||
- (라이브 검증) is_test_mode=false(라이브 모드) 진입 시 live_mid / live_api_key 를 required 로 강제.
|
||||
조건부 검증은 코어 정적 스키마로 표현 불가하므로 ValidateInicisSettingsListener 가
|
||||
core.plugin_settings.update_rules 필터로 동적 부여.
|
||||
core.plugin_settings.update_validation_rules 필터로 동적 부여.
|
||||
- (연관) plugin.php::getSettingsSchema() 를 결제 플러그인 동형으로 정의하고 키 필드에
|
||||
sensitive:true 를 부여해 API 키 평문 저장 결함 해소 (저장 시 암호화 / 조회 시 복호화).
|
||||
- (연관) verify()/cancel() 의 재처리 가드가 enum 캐스팅된 status 와
|
||||
@@ -44,7 +44,7 @@ test_files:
|
||||
|
||||
rules_layer_coverage:
|
||||
- rule: filter-hook-type-marker
|
||||
coverage: ValidateInicisSettingsListener::getSubscribedHooks() 가 core.plugin_settings.update_rules 에 type='filter' 명시 (반환값 무시 회귀 차단).
|
||||
coverage: ValidateInicisSettingsListener::getSubscribedHooks() 가 core.plugin_settings.update_validation_rules 에 type='filter' 명시 (반환값 무시 회귀 차단).
|
||||
- rule: listener-must-implement-hooklistenerinterface
|
||||
coverage: ValidateInicisSettingsListener implements HookListenerInterface + 정적 getSubscribedHooks().
|
||||
- rule: i18n-key-naming
|
||||
@@ -57,7 +57,7 @@ design_rationale: |
|
||||
코어 UpdatePluginSettingsRequest 가 빈 스키마를 받음 → 검증 규칙 0개 → 무조건 통과.
|
||||
- GDPR 은 자체 라우트+FormRequest 로 정밀 검증하나, 본인인증 플러그인엔 과함.
|
||||
- 채택: (a) 스키마를 plugin.php 로 이전(결제 플러그인 동형 + sensitive) + (b) 라이브 조건부
|
||||
required 는 코어가 이미 발행하는 core.plugin_settings.update_rules 필터로 주입. 코어 무수정.
|
||||
required 는 코어가 이미 발행하는 core.plugin_settings.update_validation_rules 필터로 주입. 코어 무수정.
|
||||
- 라이브 검증 기준은 InicisIdentityProvider::isAvailable()(live_mid + live_api_key 비어있지 않음)
|
||||
과 일치. live_mid 의 SRB 프리픽스는 buildLiveMid() 가 자동 부착하므로 별도 형식 검증 미도입.
|
||||
- (라벨 로케일 보강) ServiceProvider 의 validation.attributes 라벨 등록이 ['ko','en'] 하드코딩이라
|
||||
|
||||
@@ -42,3 +42,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- 플러그인을 설치하면 성인인증 항목의 본인확인 수단이 NHN KCP 로 자동 설정됩니다. 환경설정에서 다른 수단으로 변경할 수 있습니다. 플러그인을 삭제하면 이 설정도 함께 정리되어 존재하지 않는 인증 수단이 남지 않습니다(운영자가 다른 수단으로 바꿔 둔 경우에는 그 설정을 유지합니다).
|
||||
- 한국어·영어를 기본 제공하며, 일본어는 번들 언어팩으로 제공됩니다.
|
||||
- 코어 7.0.6 이상이 필요합니다. 운영 모드 전환 시 사이트코드·암호화 키를 필수로 검사하는 기능이 코어 7.0.6 의 설정 저장 검증 표면을 사용합니다.
|
||||
- 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시합니다. 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았습니다.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -80,7 +80,7 @@ class Plugin extends AbstractPlugin
|
||||
*
|
||||
* 라이브 모드(is_test_mode=false) 진입 시 live_site_cd / live_enc_key 를 required 로
|
||||
* 강제하는 조건부 검증은 정적 스키마로 표현할 수 없어 ValidateKcpSettingsListener 가
|
||||
* `core.plugin_settings.update_rules` 필터로 동적 부여한다.
|
||||
* `core.plugin_settings.update_validation_rules` 필터로 동적 부여한다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
|
||||
+36
@@ -247,6 +247,42 @@ describe('startAuth 핸들러', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* 인증창을 그냥 닫으면 서버에도 그 거래가 끝났음을 알려야 한다.
|
||||
*
|
||||
* 브라우저만 알고 있으면 인증 이력이 "보낸 뒤 소식 없는" 상태로, 거래 행은 소비 시각 없이
|
||||
* 남는다. 만료 시간이 지나면 정리되지만 그때까지 그 거래키는 유효한 상태로 남는다.
|
||||
*
|
||||
* @scenario environment=desktop,outcome=cancelled
|
||||
* @effects abandoned_challenge_is_cancelled_on_server
|
||||
*/
|
||||
it('인증창을 그냥 닫으면 서버에 취소를 통보한다', async () => {
|
||||
vi.useFakeTimers();
|
||||
const popup = { closed: false, close: vi.fn() };
|
||||
vi.stubGlobal('open', vi.fn(() => popup));
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ success: true }) }));
|
||||
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch);
|
||||
globalState.identityChallenge = {
|
||||
provider_id: 'nhnkcp',
|
||||
purpose: 'signup',
|
||||
challenge_id: 'challenge-abandoned',
|
||||
public_payload: { call_url: CALL_URL, reg_cert_key: REG_CERT_KEY },
|
||||
};
|
||||
|
||||
await startAuthHandler();
|
||||
popup.closed = true;
|
||||
vi.advanceTimersByTime(500);
|
||||
await vi.runAllTicks?.();
|
||||
|
||||
const calls = fetchMock.mock.calls as unknown as Array<[string, RequestInit]>;
|
||||
const cancelCall = calls.find(([url]) => String(url).includes('/cancel'));
|
||||
expect(cancelCall, '취소 엔드포인트 호출이 있어야 한다').toBeTruthy();
|
||||
expect(cancelCall![0]).toBe('/api/identity/challenges/challenge-abandoned/cancel');
|
||||
expect(cancelCall![1].method).toBe('POST');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* 닫힘 감지 후에도 감시 타이머가 계속 돌면 다음 시도의 상태를 덮어써 버린다.
|
||||
*
|
||||
|
||||
@@ -431,6 +431,38 @@ function isChallengeConsumed(challengeId?: string): boolean {
|
||||
return !!challengeId && consumedChallengeIds.has(challengeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증을 마치지 않고 이탈한 challenge 를 서버에도 종료로 알린다.
|
||||
*
|
||||
* 사용자가 인증창을 그냥 닫으면 브라우저는 그 거래를 끝난 것으로 처리하지만, 서버는 아무 통보를
|
||||
* 받지 못해 인증 이력이 `sent` 상태로, 거래 행은 `consumed_at = NULL` 로 남는다. TTL(15분)이
|
||||
* 지나면 만료되므로 기능 결함은 아니나, 관리자 이력에서 "보낸 뒤 소식 없는" 행이 쌓이고 그 거래키가
|
||||
* 만료 전까지 유효한 상태로 남는다. 코어 취소 엔드포인트를 호출해 두 상태를 함께 닫는다.
|
||||
*
|
||||
* 실패해도 흐름을 막지 않는다 — 이탈 정리는 부가 작업이고, 재시도는 브라우저측 소비 표시만으로도
|
||||
* 정상 동작한다(새 거래를 발급받는다).
|
||||
*
|
||||
* @param challengeId 이탈한 challenge 식별자
|
||||
*/
|
||||
async function reportAbandonedChallenge(challengeId?: string): Promise<void> {
|
||||
if (!challengeId) return;
|
||||
|
||||
const token = getG7Core()?.api?.getToken?.() ?? null;
|
||||
|
||||
try {
|
||||
await fetch(`/api/identity/challenges/${encodeURIComponent(challengeId)}/cancel`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('이탈 거래 취소 통보 실패 (재시도 흐름에는 영향 없음)', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 challenge 를 발급받아 `_global.identityChallenge` 를 갱신한다.
|
||||
*
|
||||
@@ -626,9 +658,14 @@ function watchPopup(popup: Window): void {
|
||||
// 이 거래는 이미 표준창에 제출됐다. KCP 거래키에는 유효 시간이 있어 같은 거래로 다시
|
||||
// 열면 한참 뒤의 재시도가 아무 안내 없이 실패할 수 있으므로, 다시 시작할 때 새 거래를
|
||||
// 받도록 소비 표시를 남긴다.
|
||||
markChallengeConsumed(
|
||||
getG7Core()?.state?.getGlobal?.()?.identityChallenge?.challenge_id as string | undefined,
|
||||
);
|
||||
const abandonedId = getG7Core()?.state?.getGlobal?.()?.identityChallenge?.challenge_id as
|
||||
| string
|
||||
| undefined;
|
||||
markChallengeConsumed(abandonedId);
|
||||
|
||||
// 브라우저만 알고 있으면 서버 이력은 `sent` 로, 거래 행은 `consumed_at = NULL` 로
|
||||
// 남는다. 코어 취소 엔드포인트로 두 상태를 함께 닫는다.
|
||||
void reportAbandonedChallenge(abandonedId);
|
||||
}
|
||||
}, POPUP_CLOSED_POLL_MS);
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Lang;
|
||||
*
|
||||
* 코어 UpdatePluginSettingsRequest 의 정적 스키마는 `required`/타입 규칙만 표현할 수 있어
|
||||
* "is_test_mode=false 일 때만 live_site_cd / live_enc_key 필수" 같은 조건부 검증을 담을 수 없다.
|
||||
* 본 listener 가 `core.plugin_settings.update_rules` filter 로 라이브 모드 진입 시 라이브
|
||||
* 본 listener 가 `core.plugin_settings.update_validation_rules` filter 로 라이브 모드 진입 시 라이브
|
||||
* 자격증명에 required 규칙을 동적 부여한다.
|
||||
*
|
||||
* 검증 기준은 KcpIdentityProvider::isAvailable() 의 라이브 운영 가능 조건과 일치시킨다.
|
||||
@@ -31,7 +31,7 @@ class ValidateKcpSettingsListener implements HookListenerInterface
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.plugin_settings.update_rules' => [
|
||||
'core.plugin_settings.update_validation_rules' => [
|
||||
'method' => 'addLiveModeRules',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
|
||||
+77
-2
@@ -7,6 +7,7 @@ use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\PluginSettingsService;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Plugins\Sirsoft\VerificationNhnkcp\Tests\PluginTestCase;
|
||||
|
||||
@@ -132,6 +133,74 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 설정 응답에 암호화 키 평문이 실리지 않는지 확인한다.
|
||||
*
|
||||
* 저장은 암호화되어 있어도 조회 응답이 복호화된 값을 그대로 내려보내면 브라우저·개발자 도구·
|
||||
* 프록시 로그에 비밀값이 남는다. 값이 있다는 사실만 마스크로 알려야 한다.
|
||||
*
|
||||
* @scenario mode=live,live_credentials=complete
|
||||
*
|
||||
* @effects sensitive_key_is_not_returned_in_plain_text
|
||||
*/
|
||||
public function test_admin_response_masks_encryption_key(): void
|
||||
{
|
||||
$this->putSettings([
|
||||
'is_test_mode' => false,
|
||||
'live_site_cd' => 'A1B2C',
|
||||
'live_enc_key' => 'super-secret-live-key',
|
||||
])->assertStatus(200)
|
||||
->assertJsonPath('data.live_enc_key', SensitiveSettingMask::MASK);
|
||||
|
||||
$show = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/admin/plugins/'.self::PLUGIN_IDENTIFIER.'/settings');
|
||||
|
||||
$show->assertStatus(200)->assertJsonPath('data.live_enc_key', SensitiveSettingMask::MASK);
|
||||
$this->assertStringNotContainsString('super-secret-live-key', $show->getContent());
|
||||
|
||||
// 민감하지 않은 항목은 그대로 보여야 한다 (마스킹이 과하게 번지지 않는지).
|
||||
$show->assertJsonPath('data.live_site_cd', 'A1B2C');
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스크를 그대로 되돌려 보내면 저장된 값이 유지되는지 확인한다.
|
||||
*
|
||||
* 운영자가 암호화 키를 건드리지 않고 다른 항목만 바꿔 저장하면, 화면은 마스크를 그대로 보낸다.
|
||||
* 그것을 값으로 받아 저장하면 저장된 키가 마스크 문자열로 덮여 운영 인증이 통째로 멈춘다.
|
||||
*
|
||||
* @scenario mode=live,live_credentials=complete
|
||||
*
|
||||
* @effects sensitive_key_is_not_returned_in_plain_text
|
||||
*/
|
||||
public function test_resubmitting_mask_preserves_stored_key(): void
|
||||
{
|
||||
$this->putSettings([
|
||||
'is_test_mode' => false,
|
||||
'live_site_cd' => 'A1B2C',
|
||||
'live_enc_key' => 'super-secret-live-key',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 화면이 마스크를 그대로 되돌려 보내는 상황 재현
|
||||
$this->putSettings([
|
||||
'is_test_mode' => false,
|
||||
'live_site_cd' => 'Z9Y8X',
|
||||
'live_enc_key' => SensitiveSettingMask::MASK,
|
||||
])->assertStatus(200);
|
||||
|
||||
$this->assertSame(
|
||||
'super-secret-live-key',
|
||||
app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER, 'live_enc_key'),
|
||||
'마스크 재전송은 저장된 키를 덮어써서는 안 된다',
|
||||
);
|
||||
$this->assertSame(
|
||||
'Z9Y8X',
|
||||
app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER, 'live_site_cd'),
|
||||
'같은 요청의 다른 항목은 정상 저장되어야 한다',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* core.plugins.update 권한을 가진 관리자 생성.
|
||||
*/
|
||||
@@ -139,11 +208,17 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$permission = Permission::firstOrCreate(
|
||||
$updatePermission = Permission::firstOrCreate(
|
||||
['identifier' => 'core.plugins.update'],
|
||||
['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin']
|
||||
);
|
||||
|
||||
// 설정 조회(GET)는 별도 권한을 요구한다 — 마스킹 응답 검증에 필요.
|
||||
$readPermission = Permission::firstOrCreate(
|
||||
['identifier' => 'core.plugins.read'],
|
||||
['name' => json_encode(['ko' => '플러그인 조회', 'en' => 'Read Plugins']), 'type' => 'admin']
|
||||
);
|
||||
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
|
||||
@@ -154,7 +229,7 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase
|
||||
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
||||
'type' => 'admin',
|
||||
]);
|
||||
$testRole->permissions()->sync([$permission->id]);
|
||||
$testRole->permissions()->sync([$updatePermission->id, $readPermission->id]);
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
|
||||
@@ -21,6 +21,7 @@ effects:
|
||||
- mobile_return_shows_failure_notice_and_strips_error_param
|
||||
- popup_blocked_shows_notice_without_entering_progress_state
|
||||
- popup_closed_without_result_restores_start_button
|
||||
- abandoned_challenge_is_cancelled_on_server
|
||||
- failed_result_keeps_panel_open_for_retry
|
||||
- verified_result_resolves_and_closes_panel
|
||||
- retry_after_failure_issues_new_transaction
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 표준 이름으로 옮긴 훅의 하위호환 발행 검증.
|
||||
*
|
||||
* 이름이 표준(`core.{대상}.{동작}_validation_rules`)과 어긋난 채 공개된 훅을 표준 이름으로 옮길 때,
|
||||
* 구 이름을 구독 중인 제3자 확장이 조용히 멈추면 안 된다. 두 이름이 함께 발행되고 값이 순서대로
|
||||
* 이어지는지, 구독이 없는 이름은 무해하게 지나가는지 고정한다.
|
||||
*
|
||||
* @since 7.0.6
|
||||
*/
|
||||
class HookManagerLegacyNameTest extends TestCase
|
||||
{
|
||||
private const STANDARD = 'core.probe.action_validation_rules';
|
||||
|
||||
private const LEGACY = 'core.probe.action_rules';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::clearFilter(self::STANDARD);
|
||||
HookManager::clearFilter(self::LEGACY);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_standard_name_subscriber_receives_value(): void
|
||||
{
|
||||
HookManager::addFilter(self::STANDARD, fn (array $rules) => $rules + ['standard' => true]);
|
||||
|
||||
$result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base' => true]);
|
||||
|
||||
$this->assertSame(['base' => true, 'standard' => true], $result);
|
||||
}
|
||||
|
||||
public function test_legacy_name_subscriber_still_receives_value(): void
|
||||
{
|
||||
HookManager::addFilter(self::LEGACY, fn (array $rules) => $rules + ['legacy' => true]);
|
||||
|
||||
$result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
['base' => true, 'legacy' => true],
|
||||
$result,
|
||||
'구 이름을 구독 중인 확장이 개명으로 조용히 멈춰서는 안 된다',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_both_names_apply_in_order(): void
|
||||
{
|
||||
HookManager::addFilter(self::STANDARD, fn (array $r) => [...$r, 'standard']);
|
||||
HookManager::addFilter(self::LEGACY, fn (array $r) => [...$r, 'legacy']);
|
||||
|
||||
$result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base']);
|
||||
|
||||
$this->assertSame(['base', 'standard', 'legacy'], $result, '표준 이름 결과에 구 이름이 이어 적용된다');
|
||||
}
|
||||
|
||||
public function test_no_subscriber_returns_value_unchanged(): void
|
||||
{
|
||||
$result = HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, ['base' => true]);
|
||||
|
||||
$this->assertSame(['base' => true], $result);
|
||||
}
|
||||
|
||||
public function test_extra_arguments_reach_both_names(): void
|
||||
{
|
||||
$seen = [];
|
||||
HookManager::addFilter(self::STANDARD, function (array $r, string $id) use (&$seen) {
|
||||
$seen['standard'] = $id;
|
||||
|
||||
return $r;
|
||||
});
|
||||
HookManager::addFilter(self::LEGACY, function (array $r, string $id) use (&$seen) {
|
||||
$seen['legacy'] = $id;
|
||||
|
||||
return $r;
|
||||
});
|
||||
|
||||
HookManager::applyFiltersWithLegacyName(self::STANDARD, self::LEGACY, [], 'probe-plugin');
|
||||
|
||||
$this->assertSame(['standard' => 'probe-plugin', 'legacy' => 'probe-plugin'], $seen);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user