fix(identity): 잔여 과제 전건 처리 + 로딩 표시 실렌더 확정

앞선 커밋이 CHANGELOG 에 주장한 "로딩 표시가 나오지 않던 문제 수정" 이 실제로
화면에 나타나는지 브라우저로 확정했다. 전달 경로부터 DOM 마운트·해제까지 측정했고,
지연·에러 응답에서도 영구 로딩이 남지 않음을 확인했다.

측정 과정에서 앞선 세션의 판단 하나가 틀렸음을 확인했다 — "오버레이 DOM 이 어느
화면에서도 관측되지 않는다" 는 관측 대상 노드를 잘못 잡은 것이었다. 스피너 방식
레이아웃은 다른 노드를 쓰며, 대조군에서도 안 보였던 이유가 이것이다.

미뤄 두었던 과제 4건을 함께 구현했다.

- 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시한다.
 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았다
- 플러그인 설정 화면이 저장된 비밀값을 다시 내려받던 것을 가려서 표시한다.
 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지된다
- 언어팩 반영 누락 검사가 버전만 비교해 "버전 같고 내용 다름" 을 놓치던 사각을
 콘텐츠 비교로 닫았다
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 6개를 표준 형태로 통일했다.
 예전 이름도 함께 발행하므로 기존 확장은 수정 없이 동작한다

정적 검사 룰 2종의 오탐·사각을 함께 고쳤고, 개명으로 검사 대상에 편입된 파일의
사전 결함 8건을 해소했다. 검수용 데이터는 전량 정리했다.
This commit is contained in:
HeuJung committed 2026-07-31 00:32:48 +09:00
1 parent bd215bc586
commit b127afcea7
38 files changed
+503 -53

No files matched your search

@@ -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>>
*/
@@ -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',
@@ -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 의 설정 저장 검증 표면을 사용합니다.
- 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시합니다. 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았습니다.
@@ -1,2 +1,2 @@
var SirsoftVerificationNhnkcp=function(c){"use strict";const i="sirsoft-verification_nhnkcp",M="nhnkcp",k="width=410,height=500,scrollbars=yes,resizable=yes",E="kcp_cert_popup",S="identity-challenge-modal",A="g7.identity.redirectStash",h="sirsoft-verification_nhnkcp.formStash",l={info:(...e)=>console.info(`[${i}]`,...e),warn:(...e)=>console.warn(`[${i}]`,...e),error:(...e)=>console.error(`[${i}]`,...e)};function u(){return window.G7Core??null}const R={9999:`$t:${i}.errors.cancelled`,NOT_ADULT:`$t:${i}.errors.not_adult`,DUPLICATE:`$t:${i}.errors.duplicate`,REMOTE_CALL_FAILED:`$t:${i}.errors.remote_call_failed`,DECRYPT_FAILED:`$t:${i}.errors.decrypt_failed`,INCOMPLETE_IDENTITY:`$t:${i}.errors.incomplete_identity`,NOT_FOUND:`$t:${i}.errors.not_found`,ALREADY_CONSUMED:`$t:${i}.errors.already_consumed`,EXPIRED:`$t:${i}.errors.not_found`,IDENTITY_BINDING_MISMATCH:`$t:${i}.errors.binding_mismatch`,STORAGE_FAILED:`$t:${i}.errors.storage_failed`,PROVIDER_ERROR:`$t:${i}.errors.verify_failed`},v=`$t:${i}.errors.verify_failed`,T=new Set(["NOT_ADULT","DUPLICATE"]);function F(e){return R[e]??v}function g(e,t){const n=R[e];if(n)return n;const r=(t??"").trim();return r===""?v:e?`[${e}] ${r}`:r}const N=1023;function b(){if(typeof window>"u")return!1;const e=window.navigator?.userAgent??"";return/Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(e)?!0:typeof window.innerWidth=="number"&&window.innerWidth<=N}function p(e){const t=u()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}function I(){const e=u()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function _(e,t="error"){const n=u()?.dispatch;if(typeof n=="function")try{await n({handler:"toast",params:{type:t,message:e}})}catch{}}function O(e,t){const n=document.createElement("form");n.setAttribute("name","kcpCertForm"),n.setAttribute("method","POST"),n.setAttribute("action",e.call_url),n.setAttribute("target",t),n.setAttribute("accept-charset","UTF-8"),n.style.display="none";const r={reg_cert_key:e.reg_cert_key,kcp_page_submit_yn:"Y"};for(const[o,s]of Object.entries(r)){const a=document.createElement("input");a.type="hidden",a.name=o,a.value=s,n.appendChild(a)}document.body.appendChild(n),n.submit(),window.setTimeout(()=>{try{n.remove()}catch{}},0)}function U(){try{window.sessionStorage.setItem(A,JSON.stringify({return_url:window.location.href,payload:{},stashed_at:Date.now()}))}catch(e){l.warn("복귀 주소 보관 실패 — 인증 후 처음 화면으로 돌아갈 수 있습니다.",e)}try{window.sessionStorage.setItem(h,JSON.stringify({fields:G(),stashed_at:Date.now()}))}catch(e){l.warn("입력값 보관 실패",e)}}function G(){const e={};return document.querySelectorAll("input[name], select[name], textarea[name]").forEach(n=>{const r=n.getAttribute("name")??"";if(!r)return;const o=(n.getAttribute("type")??"").toLowerCase();if(o==="password"||o==="file"||o==="hidden")return;if(o==="checkbox"||o==="radio"){n.checked&&(e[r]=String(n.value??"on"));return}const s=String(n.value??"");s!==""&&(e[r]=s)}),e}function H(e){const t=Object.keys(e);if(t.length===0)return;let n=0;const r=window.setInterval(()=>{n+=1;let o=0;t.forEach(s=>{const a=document.querySelector(`[name="${Y(s)}"]`);if(!a)return;const d=(a.getAttribute("type")??"").toLowerCase();if(d==="checkbox"||d==="radio"){const f=a;f.value===e[s]&&!f.checked&&f.click(),o+=1;return}j(a,e[s]),o+=1}),(o===t.length||n>=25)&&window.clearInterval(r)},200)}function Y(e){const t=globalThis.CSS;return t&&typeof t.escape=="function"?t.escape(e):e.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}function j(e,t){const n=Object.getPrototypeOf(e),r=Object.getOwnPropertyDescriptor(n,"value");r?.set?r.set.call(e,t):e.value=t,e.dispatchEvent(new Event("input",{bubbles:!0})),e.dispatchEvent(new Event("change",{bubbles:!0}))}function K(e){const t=u()?.dispatch;return typeof t!="function"?Promise.resolve():t({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:S}]}}).catch(()=>{})}const P=new Set;function $(e){e&&P.add(e)}function V(e){return!!e&&P.has(e)}async function B(e){const t=u(),n={purpose:e.purpose};e.provider_id&&(n.provider_id=e.provider_id),e.target&&(n.target=e.target);try{const r=t?.api?.getToken?.()??null,o=(()=>{try{return window.localStorage?.getItem("g7_locale")}catch{return null}})(),s=await fetch("/api/identity/challenges",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...o?{"Accept-Language":o}:{},...r?{Authorization:`Bearer ${r}`}:{}},body:JSON.stringify(n)}),a=await s.json().catch(()=>null);if(!s.ok||!a?.success)return l.error("challenge 재발급 실패",a),null;const d=a.data??a,f={...e,challenge_id:d.id,expires_at:d.expires_at,public_payload:d.public_payload??{},error:null};return t?.state?.set?.({identityChallenge:f}),f}catch(r){return l.error("challenge 재발급 중 오류",r),null}}async function y(){const e=u();let t=e?.state?.getGlobLine truncated
var SirsoftVerificationNhnkcp=function(c){"use strict";const i="sirsoft-verification_nhnkcp",M="nhnkcp",k="width=410,height=500,scrollbars=yes,resizable=yes",E="kcp_cert_popup",S="identity-challenge-modal",A="g7.identity.redirectStash",h="sirsoft-verification_nhnkcp.formStash",l={info:(...e)=>console.info(`[${i}]`,...e),warn:(...e)=>console.warn(`[${i}]`,...e),error:(...e)=>console.error(`[${i}]`,...e)};function u(){return window.G7Core??null}const R={9999:`$t:${i}.errors.cancelled`,NOT_ADULT:`$t:${i}.errors.not_adult`,DUPLICATE:`$t:${i}.errors.duplicate`,REMOTE_CALL_FAILED:`$t:${i}.errors.remote_call_failed`,DECRYPT_FAILED:`$t:${i}.errors.decrypt_failed`,INCOMPLETE_IDENTITY:`$t:${i}.errors.incomplete_identity`,NOT_FOUND:`$t:${i}.errors.not_found`,ALREADY_CONSUMED:`$t:${i}.errors.already_consumed`,EXPIRED:`$t:${i}.errors.not_found`,IDENTITY_BINDING_MISMATCH:`$t:${i}.errors.binding_mismatch`,STORAGE_FAILED:`$t:${i}.errors.storage_failed`,PROVIDER_ERROR:`$t:${i}.errors.verify_failed`},v=`$t:${i}.errors.verify_failed`,T=new Set(["NOT_ADULT","DUPLICATE"]);function F(e){return R[e]??v}function g(e,t){const n=R[e];if(n)return n;const r=(t??"").trim();return r===""?v:e?`[${e}] ${r}`:r}const N=1023;function b(){if(typeof window>"u")return!1;const e=window.navigator?.userAgent??"";return/Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(e)?!0:typeof window.innerWidth=="number"&&window.innerWidth<=N}function p(e){const t=u()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}function I(){const e=u()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function _(e,t="error"){const n=u()?.dispatch;if(typeof n=="function")try{await n({handler:"toast",params:{type:t,message:e}})}catch{}}function O(e,t){const n=document.createElement("form");n.setAttribute("name","kcpCertForm"),n.setAttribute("method","POST"),n.setAttribute("action",e.call_url),n.setAttribute("target",t),n.setAttribute("accept-charset","UTF-8"),n.style.display="none";const r={reg_cert_key:e.reg_cert_key,kcp_page_submit_yn:"Y"};for(const[o,s]of Object.entries(r)){const a=document.createElement("input");a.type="hidden",a.name=o,a.value=s,n.appendChild(a)}document.body.appendChild(n),n.submit(),window.setTimeout(()=>{try{n.remove()}catch{}},0)}function U(){try{window.sessionStorage.setItem(A,JSON.stringify({return_url:window.location.href,payload:{},stashed_at:Date.now()}))}catch(e){l.warn("복귀 주소 보관 실패 — 인증 후 처음 화면으로 돌아갈 수 있습니다.",e)}try{window.sessionStorage.setItem(h,JSON.stringify({fields:G(),stashed_at:Date.now()}))}catch(e){l.warn("입력값 보관 실패",e)}}function G(){const e={};return document.querySelectorAll("input[name], select[name], textarea[name]").forEach(n=>{const r=n.getAttribute("name")??"";if(!r)return;const o=(n.getAttribute("type")??"").toLowerCase();if(o==="password"||o==="file"||o==="hidden")return;if(o==="checkbox"||o==="radio"){n.checked&&(e[r]=String(n.value??"on"));return}const s=String(n.value??"");s!==""&&(e[r]=s)}),e}function H(e){const t=Object.keys(e);if(t.length===0)return;let n=0;const r=window.setInterval(()=>{n+=1;let o=0;t.forEach(s=>{const a=document.querySelector(`[name="${Y(s)}"]`);if(!a)return;const d=(a.getAttribute("type")??"").toLowerCase();if(d==="checkbox"||d==="radio"){const f=a;f.value===e[s]&&!f.checked&&f.click(),o+=1;return}j(a,e[s]),o+=1}),(o===t.length||n>=25)&&window.clearInterval(r)},200)}function Y(e){const t=globalThis.CSS;return t&&typeof t.escape=="function"?t.escape(e):e.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}function j(e,t){const n=Object.getPrototypeOf(e),r=Object.getOwnPropertyDescriptor(n,"value");r?.set?r.set.call(e,t):e.value=t,e.dispatchEvent(new Event("input",{bubbles:!0})),e.dispatchEvent(new Event("change",{bubbles:!0}))}function K(e){const t=u()?.dispatch;return typeof t!="function"?Promise.resolve():t({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:S}]}}).catch(()=>{})}const P=new Set;function $(e){e&&P.add(e)}function V(e){return!!e&&P.has(e)}async function B(e){if(!e)return;const t=u()?.api?.getToken?.()??null;try{await fetch(`/api/identity/challenges/${encodeURIComponent(e)}/cancel`,{method:"POST",credentials:"include",headers:{Accept:"application/json",...t?{Authorization:`Bearer ${t}`}:{}}})}catch(n){l.warn("이탈 거래 취소 통보 실패 (재시도 흐름에는 영향 없음)",n)}}async function q(e){const t=u(),n={purpose:e.purpose};e.provider_id&&(n.provider_id=e.provider_id),e.target&&(n.target=e.target);try{const r=t?.api?.getToken?.()??null,o=(()=>{try{return window.localStorage?.getItem("g7_locale")}catch{return null}})(),s=await fetch("/api/identity/challenges",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...o?{"Accept-Language":o}:{},...r?{Authorization:`Bearer ${r}`}:{}},body:JSON.stringify(n)}),a=await s.json().catch(()=>null);if(!Line truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -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>>
*/
@@ -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);
}
@@ -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',
@@ -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