fix(core,extensions): 설치 완료 스크롤 + 미인증 화면 테마 버튼 무반응 수정

Modern PHP User Group 2026-09 정기모임 설치 리뷰에서 접수된 제보 2건.

설치 완료·실패·중단·필수파일 안내는 페이지 최상단에 뜨는데, 진행 로그를 보느라
화면이 아래에 머물러 있으면 안내가 눈에 들어오지 않았다. 네 경로 모두 DOM 변경이
끝난 뒤 결과 섹션으로 부드럽게 이동시킨다(모션 최소화 설정 시 즉시 이동).

미인증 3화면의 테마 버튼은 setTheme 을 params.theme 으로 불렀는데 핸들러는
action.target 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
아무 일도 하지 않았다. 로그인 이후 화면은 핸들러를 거치지 않는 ThemeToggle 을
쓰기 때문에 정상이었고, 그래서 이 세 화면에서만 나타났다.

같은 계약 불일치가 편집기 액션 레시피(admin 9·basic 3)와 두 템플릿 문서에도 있어
함께 고쳤다. 편집기로 만든 액션은 생성 즉시 no-op 이 되는데 오류가 남지 않는다.
정적 검사 레지스트리가 오히려 틀린 계약(params.theme 필수)을 강제하고 있어 올바른
형태를 막고 있었으므로 두 미러를 함께 정정했다.

테마를 고치는 과정에서 별개 결함이 드러났다. GDPR 스토리지 인터셉터가 기능 쿠키
미동의 상태에서 필수 목록 밖 저장을 부팅마다 파기하는데, 화면 테마가 그 목록에서
빠져 있었다. 저장소의 setItem 호출을 기계 도출해 대조한 결과 같은 이유로 사라지던
항목이 13건 더 있었다 — 비회원 주문 조회, 결제창 복귀 기록, 본인인증 복귀 기록,
관리자 화면 상태, 자산 주소 형식 캐시. 전량 필수로 분류하고, 동의 안내 문구가
"다크모드는 기능 쿠키" 라고 말하던 부분을 사실에 맞게 정정했다(기설치본의 저장된
문구는 업그레이드 스텝이 정정하며, 운영자가 고친 문구는 건드리지 않는다).

재발 방지는 허용목록을 직접 import 하고 모집단을 디렉토리 순회로 도출하는 커버리지
테스트가 맡는다 — 손으로 열거하지 않으므로 새 저장 키가 등재를 빠뜨리면 붉어진다.
This commit is contained in:
HeuJung committed 2026-09-03 18:42:30 +09:00
1 parent 92923a5011
commit 8cf829f953
47 files changed
+1424 -282

No files matched your search

+10 -1
View File
@@ -33,6 +33,12 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를
차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만
차단"이 아니기 때문입니다.
**허용목록은 둘입니다** — 쿠키(`cookieInterceptor.ts` · `CookieConsentMiddleware`)와 저장소
(`storageInterceptor.ts` 의 `DEFAULT_NECESSARY_ALLOWLIST`)는 서로 다른 목록이고 항목 수도
다릅니다. 위 "4종" 은 쿠키 쪽 이야기이며, 저장소 쪽은 코어가 동작에 필요로 하는 키와 WP29
Opinion 04/2012 §3.6 의 user-initiated preference 예외 항목(`g7_locale` 언어 설정,
`g7_color_scheme` 화면 테마)을 담습니다. 둘을 같은 목록으로 착각하면 한쪽만 고치게 됩니다.
<!-- @intent END -->
## 2. 디렉토리 지도
@@ -129,6 +135,8 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행)
- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화
- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
- [ ] `storageInterceptor.ts` 의 `DEFAULT_NECESSARY_ALLOWLIST`(저장소 쪽, 쿠키 목록과 별개)를 고치면 **동의 안내 문구도 함께** 고친다 — 항목이 어느 카테고리에 속하는지 사용자에게 말하는 자리가 `plugin.php`(설치 시드) · `src/Services/CookieCategoryService.php`(런타임 폴백) · `resources/lang/{ko,en}.json`(관리자 안내) · `editor-spec.json`(편집기 샘플) 넷이다. 한 곳만 고치면 동의 고지가 실제 동작과 어긋난 채 남는다
- [ ] 그 문구를 고쳤으면 기설치본의 **저장된** 안내도 정정하는 업그레이드 스텝을 동반한다 — 카테고리 정의는 설치 시점에 시드되고 이후 갱신되지 않는다 (선례: `upgrades/data/1.0.4/migrations/01_RetagThemeAsStrictlyNecessary.php`)
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-gdpr --force`
## 6. 금지 패턴
@@ -140,6 +148,7 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
| 저장소 허용목록만 고치고 동의 안내 문구는 그대로 두기 | 목록·문구 4곳·업그레이드 스텝을 한 작업 단위로 | 안내가 "이 항목은 기능 쿠키이고 거부하면 저장되지 않는다" 라고 말하는데 실제로는 항상 저장되면, 고지 자체가 사실과 달라진다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -148,7 +157,7 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` |
| Vitest | 12개 | `vitest.config.ts` |
| Vitest | 13개 | `vitest.config.ts` |
| Playwright | 3개 | `tests/Playwright` |
| 시나리오 매니페스트 | 5개 | `tests/scenarios` |
@@ -16,6 +16,9 @@
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
- 쿠키 동의 저장 요청이 서버만 기록해야 하는 출처(회원가입 시 동의)를 직접 지정할 수 있던 문제를 수정했습니다 — 동의 이력의 출처가 실제 동의 경로와 일치합니다.
- 화면 테마(밝게/어둡게/시스템 설정) 선택이 저장되지 않던 문제를 수정했습니다. 기능 쿠키에 동의하기 전에는 테마를 바꿔도 새로고침하면 원래대로 돌아갔고, 관리자·사용자 화면 모두 같았습니다. 테마는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과 같은 필수 항목으로 분류해 동의 여부와 무관하게 저장합니다.
- 이에 맞춰 쿠키 동의 안내 문구를 정정했습니다 — 필수 항목 설명에 화면 테마가 포함되고, 기능 쿠키 설명에서는 빠집니다. 이미 설치된 사이트의 저장된 안내 문구도 업데이트 시 함께 정정됩니다(운영자가 직접 고친 문구는 그대로 둡니다).
- 화면 테마와 같은 이유로 사라지던 나머지 항목도 함께 필수로 분류했습니다. 비회원이 주문 후 주문내역을 조회할 때 쓰는 정보, 결제창에서 돌아왔을 때 결제 중단 사유를 서버에 알리는 기록, 본인인증을 마치고 원래 화면·입력 내용으로 복귀하는 데 쓰는 기록, 관리자 화면의 메뉴 접기·목록 필터 표시·닫은 경고·레이아웃 편집기 작업 상태, 그리고 사이트가 자산 주소 형식을 기억해 두는 내부 캐시가 해당합니다. 기능 쿠키 동의 여부와 무관하게 유지됩니다.
## [1.0.3] - 2026-08-19
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-gdpr",
"version": "1.0.2",
"version": "1.0.4",
"components": {
"basic": [],
"composite": [],
+1 -1
View File
@@ -1 +1 @@
var SirsoftGdpr=(function(p){"use strict";function Z(e,t){const n=e.toLowerCase(),r=t.toLowerCase();if(r.startsWith("*.")){const o=r.slice(1);return n.endsWith(o)&&n!==o.slice(1)}return n===r}function R(e,t){if(typeof window>"u"||typeof URL>"u")return null;let n,r;try{const o=new URL(e,window.location.href);r=o.protocol,n=o.hostname}catch{return null}if(r!=="http:"&&r!=="https:"||n===""||n===window.location.hostname)return null;for(const[o,i]of Object.entries(t))if(o!=="necessary"){for(const s of i)if(Z(n,s))return o}return null}const b="data-gdpr-category",_="gdpr-blocked-embed",j="data-gdpr-original",k=[`script[${b}]`,`iframe[${b}]`,"script[src]","iframe[src]","img[src]","link[href]"].join(", ");let l=null,y={categories:{},needs_renewal:!1},N={};function P(e){if(e&&typeof e=="object"&&"categories"in e){const t=e;return{categories:{...t.categories},needs_renewal:t.needs_renewal===!0}}return{categories:{...e},needs_renewal:!1}}function H(e){y=P(e),l!==null&&m()}function ee(e){N={...e},l!==null&&m()}function te(){typeof document>"u"||l!==null||(m(),l=new MutationObserver(e=>{for(const t of e)t.addedNodes.forEach(n=>{n instanceof HTMLElement&&re(n)})}),l.observe(document.documentElement,{childList:!0,subtree:!0}),window.addEventListener("gdpr:consent-changed",ne))}function ne(e){const t=e.detail;t!==null&&typeof t=="object"&&(y=P(t)),m()}function re(e){ie(e)&&C(e),e.querySelectorAll(k).forEach(t=>C(t))}function m(){typeof document>"u"||(document.querySelectorAll(`.${_}`).forEach(e=>{const t=e.dataset.gdprBlockedCategory;t&&A(t)&&se(e)}),document.querySelectorAll(k).forEach(e=>C(e)))}function C(e){if(e.classList.contains(_)||e.hasAttribute("data-gdpr-blocked-category"))return;const t=e.getAttribute(b);if(t!==null&&t!==""){if(A(t))return;M(e,t);return}const n=oe(e);if(n===null)return;const r=R(n,N);r!==null&&(A(r)||M(e,r))}function oe(e){return e instanceof HTMLScriptElement||e instanceof HTMLIFrameElement||e instanceof HTMLImageElement?e.getAttribute("src"):e instanceof HTMLLinkElement?e.getAttribute("href"):null}function ie(e){if(typeof e.matches!="function")return!1;try{return e.matches(k)}catch{return!1}}function M(e,t){const n=document.createElement("div");n.className=_,n.dataset.gdprBlockedCategory=t,n.dataset.gdprOriginalTag=e.tagName.toLowerCase(),n.setAttribute(j,e.outerHTML),n.setAttribute("aria-hidden","true"),n.style.display="none",e.replaceWith(n)}function se(e){const t=e.getAttribute(j);if(!t){e.remove();return}const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;r!==null?e.replaceWith(r):e.remove()}function A(e){return e==="necessary"?!0:y.needs_renewal?!1:y.categories[e]===!0}const ce=500,ae=["click","keydown","change","submit","pointerdown","touchstart"];let B=!1,E=0;function le(e){e.isTrusted&&(E=Date.now())}function ue(){if(!B){B=!0;for(const e of ae){const t=le;window.addEventListener(e,t,{capture:!0,passive:!0})}}}function G(){const e=window.event;return e&&e.isTrusted?!0:E===0?!1:Date.now()-E<=ce}const u=["XSRF-TOKEN","laravel_session","laravel_maintenance","gdpr_session"];let T=!1,a=null,w={functionalConsented:!1,necessaryAllowlist:u};function fe(e){const t=e.indexOf("=");return t<=0?null:e.substring(0,t).trim()}function de(e){return w.necessaryAllowlist.includes(e)}function ge(e){const t=fe(e);return t===null?!1:!!(pe(e)||de(t)||w.functionalConsented||G())}function pe(e){const t=e.toLowerCase(),n=t.match(/max-age\s*=\s*(-?\d+)/);return!!(n&&parseInt(n[1],10)<=0||t.includes("expires=thu, 01 jan 1970")||t.includes("expires=thu, 01-jan-1970"))}function ye(e){if(T)return;if(T=!0,w=e,a=Object.getOwnPropertyDescriptor(Document.prototype,"cookie")??null,!a||!a.set||!a.get){T=!1;return}const t=a.set,n=a.get;Object.defineProperty(Document.prototype,"cookie",{configurable:!0,get(){return n.call(this)},set(r){ge(r)&&t.call(this,r)}})}function $(e){w=e}const f=[{key:"g7_locale",storage:"localStorage",matchType:"exact"},{key:"auth_token",storage:"localStorage",matchType:"exact"},{key:"g7_cache_version",storage:"localStorage",matchType:"exact"},{key:"g7_cart_key",storage:"localStorage",matchType:"exact"},{key:"g7-devtools-panel",storage:"localStorage",matchType:"exact"},{key:"g7_devtools_",matchType:"prefix"},{key:"g7_filters_",matchType:"prefix"},{key:"g7_columns_",matchType:"prefix"},{key:"g7_order_",matchType:"prefix"}];let U=!1,S=null,h={functionalConsented:!1,necessaryAllowlist:f};function me(e,t){for(const n of h.necessaryAllowlist){if(n.storage&&n.storage!==t)continue;const r=n.matchType??"exact";if(r==="exact"&&n.key===e||r==="prefix"&&e.startsWith(n.key))return!0}return!1}function we(e,t){return!!(me(e,t)||h.functionalConsented||G())}function he(e){if(U)return;U=!0,h=e;const t=Storage.prototype;S=t.setItem,t.setItem=function(n,r){const o=this===window.sessionStorage?"sessionStorage":"localStorage";we(n,o)&&S&&S.call(this,n,r)}}function W(e){h=e}function be(e,t,n){for(const r of n){if(r.storage&&r.storage!==t)continue;const o=r.matchType??"exact";if(o==="exact"&&r.kLine truncated
var SirsoftGdpr=(function(p){"use strict";function Z(e,t){const n=e.toLowerCase(),r=t.toLowerCase();if(r.startsWith("*.")){const o=r.slice(1);return n.endsWith(o)&&n!==o.slice(1)}return n===r}function R(e,t){if(typeof window>"u"||typeof URL>"u")return null;let n,r;try{const o=new URL(e,window.location.href);r=o.protocol,n=o.hostname}catch{return null}if(r!=="http:"&&r!=="https:"||n===""||n===window.location.hostname)return null;for(const[o,s]of Object.entries(t))if(o!=="necessary"){for(const i of s)if(Z(n,i))return o}return null}const _="data-gdpr-category",k="gdpr-blocked-embed",j="data-gdpr-original",b=[`script[${_}]`,`iframe[${_}]`,"script[src]","iframe[src]","img[src]","link[href]"].join(", ");let l=null,y={categories:{},needs_renewal:!1},N={};function P(e){if(e&&typeof e=="object"&&"categories"in e){const t=e;return{categories:{...t.categories},needs_renewal:t.needs_renewal===!0}}return{categories:{...e},needs_renewal:!1}}function M(e){y=P(e),l!==null&&m()}function ee(e){N={...e},l!==null&&m()}function te(){typeof document>"u"||l!==null||(m(),l=new MutationObserver(e=>{for(const t of e)t.addedNodes.forEach(n=>{n instanceof HTMLElement&&re(n)})}),l.observe(document.documentElement,{childList:!0,subtree:!0}),window.addEventListener("gdpr:consent-changed",ne))}function ne(e){const t=e.detail;t!==null&&typeof t=="object"&&(y=P(t)),m()}function re(e){se(e)&&T(e),e.querySelectorAll(b).forEach(t=>T(t))}function m(){typeof document>"u"||(document.querySelectorAll(`.${k}`).forEach(e=>{const t=e.dataset.gdprBlockedCategory;t&&C(t)&&ie(e)}),document.querySelectorAll(b).forEach(e=>T(e)))}function T(e){if(e.classList.contains(k)||e.hasAttribute("data-gdpr-blocked-category"))return;const t=e.getAttribute(_);if(t!==null&&t!==""){if(C(t))return;H(e,t);return}const n=oe(e);if(n===null)return;const r=R(n,N);r!==null&&(C(r)||H(e,r))}function oe(e){return e instanceof HTMLScriptElement||e instanceof HTMLIFrameElement||e instanceof HTMLImageElement?e.getAttribute("src"):e instanceof HTMLLinkElement?e.getAttribute("href"):null}function se(e){if(typeof e.matches!="function")return!1;try{return e.matches(b)}catch{return!1}}function H(e,t){const n=document.createElement("div");n.className=k,n.dataset.gdprBlockedCategory=t,n.dataset.gdprOriginalTag=e.tagName.toLowerCase(),n.setAttribute(j,e.outerHTML),n.setAttribute("aria-hidden","true"),n.style.display="none",e.replaceWith(n)}function ie(e){const t=e.getAttribute(j);if(!t){e.remove();return}const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;r!==null?e.replaceWith(r):e.remove()}function C(e){return e==="necessary"?!0:y.needs_renewal?!1:y.categories[e]===!0}const ce=500,ae=["click","keydown","change","submit","pointerdown","touchstart"];let B=!1,S=0;function le(e){e.isTrusted&&(S=Date.now())}function ue(){if(!B){B=!0;for(const e of ae){const t=le;window.addEventListener(e,t,{capture:!0,passive:!0})}}}function G(){const e=window.event;return e&&e.isTrusted?!0:S===0?!1:Date.now()-S<=ce}const u=["XSRF-TOKEN","laravel_session","laravel_maintenance","gdpr_session"];let A=!1,a=null,w={functionalConsented:!1,necessaryAllowlist:u};function fe(e){const t=e.indexOf("=");return t<=0?null:e.substring(0,t).trim()}function de(e){return w.necessaryAllowlist.includes(e)}function ge(e){const t=fe(e);return t===null?!1:!!(pe(e)||de(t)||w.functionalConsented||G())}function pe(e){const t=e.toLowerCase(),n=t.match(/max-age\s*=\s*(-?\d+)/);return!!(n&&parseInt(n[1],10)<=0||t.includes("expires=thu, 01 jan 1970")||t.includes("expires=thu, 01-jan-1970"))}function ye(e){if(A)return;if(A=!0,w=e,a=Object.getOwnPropertyDescriptor(Document.prototype,"cookie")??null,!a||!a.set||!a.get){A=!1;return}const t=a.set,n=a.get;Object.defineProperty(Document.prototype,"cookie",{configurable:!0,get(){return n.call(this)},set(r){ge(r)&&t.call(this,r)}})}function $(e){w=e}const f=[{key:"g7_locale",storage:"localStorage",matchType:"exact"},{key:"g7_color_scheme",storage:"localStorage",matchType:"exact"},{key:"auth_token",storage:"localStorage",matchType:"exact"},{key:"g7_cache_version",storage:"localStorage",matchType:"exact"},{key:"g7_asset_url_mode",storage:"localStorage",matchType:"prefix"},{key:"g7_cart_key",storage:"localStorage",matchType:"exact"},{key:"g7-devtools-panel",storage:"localStorage",matchType:"exact"},{key:"g7_guest_order_token",storage:"localStorage",matchType:"exact"},{key:"g7_guest_order_number",storage:"localStorage",matchType:"exact"},{key:"g7_guest_order_expires_at",storage:"localStorage",matchType:"exact"},{key:"g7_devtools_",matchType:"prefix"},{key:"g7_filters_",matchType:"prefix"},{key:"g7_columns_",matchType:"prefix"},{key:"g7_order_",matchType:"prefix"},{key:"g7_admin_sidebar_collapsed",storage:"localStorage",matchType:"exact"},{key:"g7_filter_visibility_",matchType:"prefix"},{key:"g7_dismissed_warnings",storage:"localStorage",matchType:"exact"},{key:"g7le.",matchType:"prefix"},{key:"g7:sirsoft-pay_kginicis:pendingClose",storage:"sessionStorage",matchType:"exact"Line truncated
@@ -305,7 +305,7 @@ HTTP/1.1 200
"id": null,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "necessary",
"is_required": true,
"is_consented": false,
@@ -322,7 +322,7 @@ HTTP/1.1 200
"id": null,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "functional",
"is_required": false,
"is_consented": false,
@@ -82,8 +82,8 @@ HTTP/1.1 200
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled."
}
},
{
@@ -94,8 +94,8 @@ HTTP/1.1 200
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
}
},
{
@@ -325,8 +325,8 @@ HTTP/1.1 200
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled."
}
},
{
@@ -337,8 +337,8 @@ HTTP/1.1 200
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
}
},
{
@@ -222,8 +222,8 @@
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled."
}
},
{
@@ -234,8 +234,8 @@
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
}
},
{
@@ -397,7 +397,7 @@
"id": 1,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "cookie",
"is_required": true,
"is_consented": true,
@@ -416,7 +416,7 @@
"id": 2,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
@@ -478,7 +478,7 @@
"id": 1,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "cookie",
"is_required": true,
"is_consented": true,
@@ -497,7 +497,7 @@
"id": 2,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
+7 -7
View File
@@ -408,22 +408,22 @@ class Plugin extends AbstractPlugin
'required' => true,
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
'description' => [
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
// g7_locale·g7_color_scheme 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자가 화면에서 직접 고른 표시 환경) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled.',
],
],
[
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// 자체 functional 키 (표시 통화 등) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// Phase 2 에서 실제 게이팅 (Storage.prototype 가로채기 + cookie 가로채기 + Set-Cookie 미들웨어) 구현 예정.
'key' => 'functional',
'required' => false,
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
'description' => [
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
'ko' => '사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
],
],
[
@@ -67,6 +67,17 @@ describe('functionalCleaner', () => {
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
// dev-g7#640: 동의 철회 정리에서도 테마는 언어 설정과 같이 남아야 한다.
it('strictly necessary 키 (g7_color_scheme) 는 보존', () => {
window.localStorage.setItem('g7_color_scheme', 'dark');
window.localStorage.setItem('app_pref', 'value');
cleanupFunctionalArtifacts();
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
it('prefix 매칭 키 (g7_devtools_*) 는 보존', () => {
window.localStorage.setItem('g7_devtools_filter', 'enabled');
window.localStorage.setItem('app_pref', 'value');
@@ -0,0 +1,403 @@
/**
* strictly necessary allowlist 모집단 커버리지 테스트
*
* functionalCleaner 는 functional 미동의(기본 상태)에서 **부팅마다** allowlist 밖의
* localStorage / sessionStorage 를 전량 파기한다. 그래서 코어·확장이 새로 저장 키를
* 도입하면서 allowlist 등재를 잊으면, 그 설정은 "저장은 되는데 새로고침하면 사라지는"
* 상태가 된다 — 예외도 콘솔 오류도 남지 않아 증상만으로는 원인을 특정할 수 없다.
*
* 이 테스트는 allowlist 를 **손으로 열거하지 않는다.** 저장소의 소스를 훑어
* `.setItem()` 이 쓰는 키를 기계 도출하고, 그 전량이 allowlist 에 등재되었거나
* 의도적 비필수(INTENTIONALLY_NON_NECESSARY)로 선언되었는지 검사한다.
* 새 저장 키가 추가되면 그 시점에 붉어진다.
*
* @module sirsoft-gdpr/__tests__/necessaryAllowlistCoverage
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { DEFAULT_NECESSARY_ALLOWLIST } from '../storageInterceptor';
/**
* 저장소 루트를 탐색합니다. (artisan + composer.json 동시 보유 디렉토리)
*
* @return 저장소 루트 절대경로
*/
function findRepoRoot(): string {
let dir = __dirname;
for (let i = 0; i < 12; i += 1) {
if (
fs.existsSync(path.join(dir, 'artisan'))
&& fs.existsSync(path.join(dir, 'composer.json'))
) {
return dir;
}
dir = path.dirname(dir);
}
throw new Error('저장소 루트를 찾지 못했습니다.');
}
const REPO_ROOT = findRepoRoot();
/**
* 스캔 대상 디렉토리 (glob 없이 실제 디렉토리 열거로 확장 전량 포함).
*
* @return 존재하는 스캔 대상 절대경로 배열
*/
function scanRoots(): string[] {
const roots: string[] = [path.join(REPO_ROOT, 'resources', 'js', 'core')];
const extensionGroups: Array<[string, string[]]> = [
['templates', ['src']],
['modules', ['resources', 'js']],
['plugins', ['resources', 'js']],
];
for (const [group, tail] of extensionGroups) {
const bundled = path.join(REPO_ROOT, group, '_bundled');
if (!fs.existsSync(bundled)) {
continue;
}
for (const entry of fs.readdirSync(bundled)) {
const candidate = path.join(bundled, entry, ...tail);
if (fs.existsSync(candidate)) {
roots.push(candidate);
}
}
}
return roots.filter((dir) => fs.existsSync(dir));
}
/**
* 디렉토리를 재귀 순회하며 .ts/.tsx 파일을 수집합니다. (테스트 디렉토리 제외)
*
* @param dir 순회 시작 디렉토리
* @param out 누적 배열
* @return void
*/
function collectSourceFiles(dir: string, out: string[]): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === 'node_modules' || entry.name === 'dist') {
continue;
}
collectSourceFiles(full, out);
continue;
}
if (/\.(ts|tsx)$/.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
out.push(full);
}
}
}
/**
* 파일 안에서 식별자에 대입된 문자열 리터럴을 찾습니다.
*
* @param source 파일 원문
* @param ident 식별자
* @return 리터럴 값 또는 null
*/
function resolveIdentifierLiteral(source: string, ident: string): string | null {
const re = new RegExp(
`(?:const|let|var|readonly)\\s+${ident}\\s*(?::[^=]+)?=\\s*(['"])([^'"]*)\\1`,
);
const m = re.exec(source);
return m ? m[2] : null;
}
/**
* 주석을 제거합니다.
*
* 주석 안의 `.setItem()` 서술이 호출로 오인되면 쓰레기 키가 모집단에 섞이고,
* 그 노이즈가 실제 미해석 호출을 가린다.
*
* @param source 파일 원문
* @return 주석이 공백으로 치환된 원문 (오프셋 보존)
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
}
/**
* `import { X } from './y'` 를 따라가 다른 파일의 상수를 해석합니다.
*
* 코어의 저장 키 상수는 대부분 별도 모듈(`constants.ts` · `types.ts`)에 모여 있어,
* 같은 파일만 보면 그 키가 통째로 모집단에서 빠진다.
*
* @param file 호출이 있는 파일 절대경로
* @param source 그 파일 원문
* @param ident 식별자
* @return 리터럴 값 또는 null
*/
function resolveImportedLiteral(file: string, source: string, ident: string): string | null {
const importRe = new RegExp(
`import\\s*\\{[^}]*\\b${ident}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
);
const m = importRe.exec(source);
if (m === null || !m[1].startsWith('.')) {
return null;
}
const base = path.resolve(path.dirname(file), m[1]);
const candidates = [
`${base}.ts`, `${base}.tsx`,
path.join(base, 'index.ts'), path.join(base, 'index.tsx'),
];
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) {
continue;
}
const target = stripComments(fs.readFileSync(candidate, 'utf-8'));
const value = resolveIdentifierLiteral(target, ident);
if (value !== null) {
return value;
}
}
return null;
}
/**
* 식별자에 대입된 **템플릿 리터럴** 원문을 찾습니다.
*
* `const fullKey = ` + 백틱 표현식처럼 키가 한 단계 변수를 거쳐 조립되는 형태를
* 놓치면 그 키가 모집단에서 통째로 빠진다 (접두사 키가 전부 이 형태다).
*
* @param source 파일 원문
* @param ident 식별자
* @return 백틱을 포함한 템플릿 원문 또는 null
*/
function resolveIdentifierTemplate(source: string, ident: string): string | null {
// 초기화식이 삼항/`useMemo(() => ...)` 로 감싸인 경우까지 닿도록, 선언 뒤
// 가까운 범위에서 첫 백틱 템플릿을 찾는다 (저장 키 조립의 실제 형태들).
const declRe = new RegExp(`(?:const|let|var|readonly)\\s+${ident}\\s*(?::[^=]+)?=`);
const decl = declRe.exec(source);
if (decl === null) {
return null;
}
const window = source.slice(decl.index, decl.index + 400);
const tpl = /`[^`]*`/.exec(window);
return tpl ? tpl[0] : null;
}
interface DiscoveredKey {
key: string;
matchType: 'exact' | 'prefix';
origin: string;
}
/**
* `.setItem(...)` 호출의 키 표현식을 정적으로 해석합니다.
*
* 해석 가능한 형태: 문자열 리터럴 · 식별자(같은 파일의 상수) · 템플릿 리터럴(정적 접두사).
*
* @param keyExpr 키 표현식 원문
* @param source 파일 원문 (식별자 해석용)
* @return 해석 결과 또는 null (해석 불가)
*/
function resolveKeyExpression(
keyExpr: string,
source: string,
file: string,
): { key: string; matchType: 'exact' | 'prefix' } | null {
const expr = keyExpr.trim();
const literal = /^(['"])(.*)\1$/.exec(expr);
if (literal) {
return { key: literal[2], matchType: 'exact' };
}
// `this.TOKEN_KEY` · `TemplateApp.LOCALE_STORAGE_KEY` 같은 멤버 표현식은
// 마지막 조각이 곧 상수명이다. 여기서 끊으면 코어 키 다수가 통째로 빠진다.
const member = /^(?:[A-Za-z_$][\w$]*\.)+([A-Za-z_$][\w$]*)$/.exec(expr);
const ident = member ? member[1] : expr;
if (/^[A-Za-z_$][\w$]*$/.test(ident)) {
const value = resolveIdentifierLiteral(source, ident);
if (value !== null) {
return { key: value, matchType: 'exact' };
}
// 변수를 한 단계 거쳐 조립되는 템플릿 키 (접두사 키의 대표 형태).
const template = resolveIdentifierTemplate(source, ident);
if (template !== null) {
return resolveKeyExpression(template, source, file);
}
// 다른 모듈에 모여 있는 상수.
const imported = resolveImportedLiteral(file, source, ident);
return imported === null ? null : { key: imported, matchType: 'exact' };
}
// `storageKey()` 처럼 키를 조립해 돌려주는 무인자 함수. 본문의 return 템플릿을
// 따라간다 — 여기서 끊으면 그 키의 허용목록 등재가 아무 검사도 받지 않는다
// (등재를 지워도 초록인 죽은 축이 된다).
const call = /^([A-Za-z_$][\w$]*)\(\s*\)$/.exec(expr);
if (call !== null) {
const fnRe = new RegExp(`function\\s+${call[1]}\\s*\\([^)]*\\)[^{]*\\{`);
const fn = fnRe.exec(source);
if (fn !== null) {
const body = source.slice(fn.index, fn.index + 600);
const ret = /return\s+(`[^`]*`|['"][^'"]*['"])/.exec(body);
if (ret !== null) {
return resolveKeyExpression(ret[1], source, file);
}
}
return null;
}
if (expr.startsWith('`')) {
const body = expr.slice(1, -1);
// 선두가 `${IDENT}` 이면 그 상수를 펼쳐 정적 접두사를 만든다.
const leading = /^\$\{\s*([A-Za-z_$][\w$]*)\s*\}/.exec(body);
let prefix = '';
let rest = body;
if (leading) {
const value = resolveIdentifierLiteral(source, leading[1]);
if (value === null) {
return null;
}
prefix = value;
rest = body.slice(leading[0].length);
}
const staticHead = rest.split('${')[0];
prefix += staticHead;
return prefix === '' ? null : { key: prefix, matchType: 'prefix' };
}
return null;
}
/**
* 저장소 전체에서 storage 저장 키를 도출합니다.
*
* @return 도출된 키 목록
*/
function discoverStorageKeys(): { keys: DiscoveredKey[]; unresolved: string[] } {
const files: string[] = [];
for (const root of scanRoots()) {
collectSourceFiles(root, files);
}
const found = new Map<string, DiscoveredKey>();
const unresolved: string[] = [];
// 수신자 표현식을 좁히지 않는다 — `safeSessionStorage()?.setItem(...)` 처럼
// 호출 결과에 바로 붙는 형태를 놓치면 그 키가 모집단에서 통째로 빠진다.
const callRe = /\.setItem\(\s*([^,]+?)\s*,/g;
for (const file of files) {
const source = stripComments(fs.readFileSync(file, 'utf-8'));
callRe.lastIndex = 0;
let m: RegExpExecArray | null = callRe.exec(source);
while (m !== null) {
const origin = path.relative(REPO_ROOT, file).replace(/\\/g, '/');
const resolved = resolveKeyExpression(m[1], source, file);
if (resolved === null) {
// 조용히 버리지 않는다 — 버리면 모집단이 부분적으로 비어도 초록이 된다.
unresolved.push(`${m[1].replace(/\s+/g, ' ')} ← ${origin}`);
} else {
const id = `${resolved.matchType}:${resolved.key}`;
if (!found.has(id)) {
found.set(id, { ...resolved, origin });
}
}
m = callRe.exec(source);
}
}
return {
keys: [...found.values()].sort((a, b) => a.key.localeCompare(b.key)),
unresolved: [...new Set(unresolved)].sort(),
};
}
/**
* 필수(strictly necessary) 가 **아니라고 의도적으로 판단한** 키.
*
* 여기 적힌 키는 functional 동의가 없으면 파기되는 것이 설계 의도다.
* 새 키를 여기 넣을 때는 사용자에게 그 상실이 수용 가능한지 근거를 함께 남긴다.
*/
/**
* 키가 실행 시점에 정해져 **정적으로 해석할 수 없는** 쓰기 지점.
*
* 이 목록은 사각을 지우는 것이 아니라 드러내 고정한다. 정렬된 문자열로 비교하므로
* 새 동적 쓰기 지점이 생기면 그 즉시 붉어진다.
*/
const DECLARED_DYNAMIC_CALL_SITES: ReadonlyArray<{ site: string; reason: string }> = [
{
site: 'key ← resources/js/core/template-engine/ActionDispatcher.ts',
reason: '`saveToLocalStorage` 핸들러 — 키를 레이아웃 액션이 넘긴다. 레이아웃이 임의로 정하는 값이라 목록으로 열거할 수 없다.',
},
{
site: 'key ← templates/_bundled/sirsoft-basic/src/handlers/storageHandlers.ts',
reason: '`setLocalStorage` 핸들러 — 위와 같은 이유 (레이아웃이 키를 정한다).',
},
];
const INTENTIONALLY_NON_NECESSARY: ReadonlyArray<{ key: string; reason: string }> = [
{
key: 'g7_preferred_currency',
reason: '표시 통화 — functional 카테고리 설명에 명시된 선호도. 미동의 시 기본 통화로 표시되는 것이 의도된 동작이다.',
},
];
/**
* 도출된 키가 allowlist 에 등재되어 있는지 검사합니다.
*
* 저장소 종류(localStorage/sessionStorage)는 개별 테스트가 잠그며, 여기서는
* "필수 목록이 이 키를 알고 있는가" 만 본다.
*
* @param discovered 도출된 키
* @return 등재 여부
*/
function isDeclared(discovered: DiscoveredKey): boolean {
return DEFAULT_NECESSARY_ALLOWLIST.some((entry) => {
const entryMatch = entry.matchType ?? 'exact';
if (entryMatch === 'exact') {
return entry.key === discovered.key;
}
// prefix 항목은 그 접두사로 시작하는 키(정확형·접두형 모두)를 덮는다.
return discovered.key.startsWith(entry.key);
});
}
describe('strictly necessary allowlist 모집단 커버리지', () => {
const { keys: discovered, unresolved } = discoverStorageKeys();
it('저장 키 도출이 비어 있지 않다 (모집단 하한 — 스캐너가 죽으면 붉어진다)', () => {
expect(discovered.length).toBeGreaterThanOrEqual(10);
});
it('정적 해석 불가 지점이 선언된 목록과 정확히 일치한다 (부분 누락 방지)', () => {
// 해석 못 한 표현식을 버리면 그 키는 검사 대상에서 사라지는데, 결과는
// "이상 0건" 과 구분되지 않는다. 그래서 버리지 않고 **선언**한다 —
// 새 동적 쓰기 지점이 생기면 여기서 붉어지고, 그때 그 키가 파기돼도
// 되는지 판단해 선언에 추가하거나 정적 상수로 바꾼다.
expect(unresolved).toEqual(DECLARED_DYNAMIC_CALL_SITES.map((s) => s.site));
});
it('도출된 모든 저장 키가 allowlist 등재 또는 의도적 비필수 선언 중 하나에 해당한다', () => {
const exempt = new Set(INTENTIONALLY_NON_NECESSARY.map((e) => e.key));
const uncovered = discovered
.filter((d) => !exempt.has(d.key))
.filter((d) => !isDeclared(d))
.map((d) => `${d.key} (${d.matchType}) ← ${d.origin}`);
expect(uncovered).toEqual([]);
});
it('의도적 비필수 선언은 실제로 존재하는 키만 담는다 (사문화 방지)', () => {
const discoveredKeys = new Set(discovered.map((d) => d.key));
const stale = INTENTIONALLY_NON_NECESSARY
.filter((e) => !discoveredKeys.has(e.key))
.map((e) => e.key);
expect(stale).toEqual([]);
});
});
@@ -43,6 +43,18 @@ describe('storageInterceptor', () => {
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
});
// dev-g7#640: 화면 테마가 목록에서 빠져 있어, 동의 전에는 테마를 바꿔도 저장이
// 조용히 버려졌다 (새로고침하면 원래대로). 미인증 화면뿐 아니라 관리자 화면 전체가 같았다.
it('strictly necessary 키 (g7_color_scheme) → 항상 통과 (미동의여도)', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('g7_color_scheme', 'dark');
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
});
it('prefix 매칭 (g7_devtools_*) → 통과', () => {
installStorageInterceptor({
functionalConsented: false,
@@ -139,11 +151,13 @@ describe('storageInterceptor', () => {
__setLastInteractionForTest(Date.now());
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(true); // user-initiated 면제
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary
expect(isStorageAllowed('g7_color_scheme', 'localStorage')).toBe(true); // necessary
// user-initiated 가 만료된 시점엔 차단
__setLastInteractionForTest(0);
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(false);
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary 는 항상 통과
expect(isStorageAllowed('g7_color_scheme', 'localStorage')).toBe(true); // necessary 는 항상 통과
// 함수 호출만으로 storage 변경 X
expect(window.localStorage.getItem('app_pref')).toBeNull();
@@ -7,8 +7,8 @@
*
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
* 1. strictly necessary allowlist 매칭 → 항상 허용
* (XSRF-TOKEN, g7_locale, auth_token, g7_cart_key, g7_cache_version,
* g7-devtools-panel, g7_devtools_*, g7_filters_*, g7_columns_*, g7_order_*)
* (등재 항목은 `DEFAULT_NECESSARY_ALLOWLIST` 가 SSoT — 여기에 목록을 복제하지
* 않는다. 사본을 두면 항목이 늘 때 한쪽만 고쳐져 조용히 어긋난다.)
* 2. functional 동의 → 허용
* 3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
* 4. 그 외 → 차단
@@ -59,25 +59,62 @@ export interface StorageInterceptorConfig {
* 본 목록은 G7 코어가 정상 동작하는 데 반드시 필요한 키만 포함. 운영자가 추가 등록 불가
* (코드 상수). 운영자가 추가하려면 본 파일 수정 + PR 필요.
*
* prefix 매칭: g7_devtools_*, g7_filters_*, g7_columns_*, g7_order_*
* exact 매칭: g7_locale, auth_token, g7_cache_version, g7_cart_key, g7-devtools-panel
* 계열: 인증·세션 / 사용자 명시 선택(언어·테마) / 구매 동선(장바구니·비회원 주문) /
* 관리자 화면 상태 / 레이아웃 편집기 작업 상태 / 결제 복귀 기록 / 본인인증 복귀.
*
* 이 배열이 유일한 정본이다. 코어·확장이 새 저장 키를 도입하면서 여기 등재를 잊으면
* functionalCleaner 가 부팅마다 그 키를 파기해 "저장은 되는데 새로고침하면 사라지는"
* 상태가 되고, 예외도 로그도 남지 않는다. 그 누락은 저장소의 `.setItem()` 을 기계
* 도출해 대조하는 `__tests__/necessaryAllowlistCoverage.test.ts` 가 잡는다.
*/
export const DEFAULT_NECESSARY_ALLOWLIST: readonly NecessaryAllowlistEntry[] = [
// 사용자 명시 선택 (WP29 §3.6) — 다국어 설정
{ key: 'g7_locale', storage: 'localStorage', matchType: 'exact' },
// 사용자 명시 선택 (WP29 §3.6) — 화면 테마(밝게/어둡게/시스템 설정).
// 언어 설정과 같은 범주다: 사용자가 화면에서 직접 고른 표시 환경이며 추적에 쓰이지
// 않는다. 목록에서 빠져 있는 동안에는 테마를 바꿔도 새로고침하면 되돌아갔고,
// 그 증상이 미인증 화면뿐 아니라 관리자 화면 전체에 나타났다 (dev-g7#640).
{ key: 'g7_color_scheme', storage: 'localStorage', matchType: 'exact' },
// 인증 토큰 — 로그인 유지 필수 (strictly necessary, Art.6(1)(b))
{ key: 'auth_token', storage: 'localStorage', matchType: 'exact' },
// 코어 캐시 버전 — 운영자가 의도적 갱신 시 사용
{ key: 'g7_cache_version', storage: 'localStorage', matchType: 'exact' },
// 자산 URL 형식 판정 캐시 — 사용자 정보가 아니라 서버 능력 판정 결과다.
// 파기되면 재방문마다 기본 형식으로 첫 자산 요청을 보내 404 를 겪은 뒤에야
// 대체 형식으로 넘어간다. 키에 캐시 버전이 붙으므로 접두사로 등재한다.
{ key: 'g7_asset_url_mode', storage: 'localStorage', matchType: 'prefix' },
// 장바구니 게스트 키 — 익명 카트 식별 (구매 동선 필수)
{ key: 'g7_cart_key', storage: 'localStorage', matchType: 'exact' },
// devtools UI 상태 — 개발자 환경 (strictly necessary 개발자 도구)
{ key: 'g7-devtools-panel', storage: 'localStorage', matchType: 'exact' },
// 비회원 주문 조회 — 주문 이행에 필요 (Art.6(1)(b)). 게스트 장바구니 키와 같은 범주로,
// 파기되면 방금 주문한 비회원이 자기 주문내역에 도달할 수 없다.
{ key: 'g7_guest_order_token', storage: 'localStorage', matchType: 'exact' },
{ key: 'g7_guest_order_number', storage: 'localStorage', matchType: 'exact' },
{ key: 'g7_guest_order_expires_at', storage: 'localStorage', matchType: 'exact' },
// 관리자 페이지 상태 (필터/정렬/컬럼/devtools) — 사용자 의사로 조작
{ key: 'g7_devtools_', matchType: 'prefix' },
{ key: 'g7_filters_', matchType: 'prefix' },
{ key: 'g7_columns_', matchType: 'prefix' },
{ key: 'g7_order_', matchType: 'prefix' },
// 관리자 화면 표시 설정 — 테마와 같은 범주 (사용자가 화면에서 직접 고른 표시 환경).
// `g7_filters_` 는 목록 필터 '값' 이고 `g7_filter_visibility_` 는 필터 '표시 여부' 라
// 접두사가 서로 덮지 않는다 — 목록에 따로 서야 한다 (dev-g7#640).
{ key: 'g7_admin_sidebar_collapsed', storage: 'localStorage', matchType: 'exact' },
{ key: 'g7_filter_visibility_', matchType: 'prefix' },
{ key: 'g7_dismissed_warnings', storage: 'localStorage', matchType: 'exact' },
// 레이아웃 편집기 작업 상태 (라우트 트리 접힘·클립보드) — 운영자 편집 동선 유지
{ key: 'g7le.', matchType: 'prefix' },
// 결제 진행 기록 — 결제창 복귀 시 종료·실패 사유 보고에 필요 (Art.6(1)(b)).
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로 sessionStorage 에 맡겨 두는데,
// 부팅 시 파기되면 그 보고가 통째로 누락된다.
{ key: 'g7:sirsoft-pay_kginicis:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
{ key: 'g7:sirsoft-pay_nhnkcp:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
{ key: 'g7:sirsoft-tosspayments:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
{ key: '__sirsoftKginicisMobilePaymentReturnPending', matchType: 'exact' },
// 본인인증 복귀 — 인증창에서 돌아왔을 때 원래 화면·입력 내용 복원에 필요
{ key: 'g7.identity.redirectStash', matchType: 'exact' },
{ key: 'sirsoft-verification_nhnkcp.formStash', matchType: 'exact' },
];
let installed = false;
@@ -6,7 +6,7 @@
*
* WP29 Opinion 04/2012 §3.6 + EDPB Guidelines 2/2023 §16 의 "user-initiated
* preference" 면제 적용 — functional 카테고리 미동의 상태에서도 사용자가 직접
* 트리거한 설정 저장 (다크모드 토글, 통화 변경 등) 은 허용해야 함. 그렇지 않으면
* 트리거한 설정 저장 (통화 변경 등) 은 허용해야 함. 그렇지 않으면
* 메뉴 클릭 시점에 setItem 차단되어 UX 가 무너짐.
*
* 판정 기준:
@@ -124,11 +124,11 @@
"scope_label": "Auto-blocking scope",
"tools_label": "Common tools",
"necessary": {
"scope": "Not auto-blocked. Session/auth tokens, shopping basket identifier, user-selected language preference at registration, and cookie consent records are essential to site operation and always allowed.",
"tools": "Session IDs, authentication tokens, CSRF tokens, shopping basket identifier, language preference, etc. (no configuration required)"
"scope": "Not auto-blocked. Session/auth tokens, shopping basket identifier, user-selected language preference and display theme, and cookie consent records are essential to site operation and always allowed.",
"tools": "Session IDs, authentication tokens, CSRF tokens, shopping basket identifier, language preference, display theme, etc. (no configuration required)"
},
"functional": {
"scope": "External resources matching the functional blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. User preferences such as dark mode and currency selection are also persisted only after consent.",
"scope": "External resources matching the functional blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. User preferences such as currency selection are also persisted only after consent.",
"tools": "Customer support chatbots (Crisp, Intercom, Tawk.to), translation widgets, user-preference synchronization services, etc."
},
"analytics": {
@@ -124,11 +124,11 @@
"scope_label": "자동 차단 대상",
"tools_label": "대표 도구 예시",
"necessary": {
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정 등 (별도 설정 불필요)"
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정, 화면 테마 등 (별도 설정 불필요)"
},
"functional": {
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 다크모드·통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
"tools": "고객지원 챗봇 (Crisp, Intercom, Tawk.to), 다국어 자동 번역 위젯, 사용자 설정 동기화 서비스 등"
},
"analytics": {
@@ -202,21 +202,21 @@ class CookieCategoryService
'required' => true,
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
'description' => [
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
// g7_locale·g7_color_scheme 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자가 화면에서 직접 고른 표시 환경) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled.',
],
],
[
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// 자체 functional 키 (표시 통화 등) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
'key' => 'functional',
'required' => false,
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
'description' => [
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
'ko' => '사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
],
],
[
@@ -0,0 +1,17 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* sirsoft-gdpr 플러그인 1.0.4 업그레이드 스텝
*
* 저장된 쿠키 카테고리 설명에서 화면 테마를 「기능 쿠키」로 안내하던 문구를 정정한다.
* 화면 테마(`g7_color_scheme`)가 언어 설정과 같은 strictly necessary 항목으로 재분류되어,
* 동의 여부와 무관하게 저장되기 때문이다. 소스 기본값은 교정했으나 기설치본은 설치 시점에
* 시드된 설정 파일을 그대로 쓰므로 안내 문구만 사실과 어긋난 채 남는다.
*
* 모든 비즈니스 로직은 data/1.0.4/migrations/ 로 격리(AbstractUpgradeStep 규약).
*/
class Upgrade_1_0_4 extends AbstractUpgradeStep {}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Upgrades\Data\Ext\Plugins\SirsoftGdpr\V1_0_4\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use App\Support\ExtensionStoragePath;
use Illuminate\Support\Facades\File;
/**
* 저장된 쿠키 카테고리 안내에서 화면 테마의 분류를 정정합니다.
*
* 배경:
* 화면 테마(`g7_color_scheme`)는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과
* 같은 strictly necessary 항목입니다. 그런데 저장 게이트의 필수 목록에서 빠져 있어, 기능
* 쿠키에 동의하기 전에는 테마를 바꿔도 저장이 조용히 버려졌습니다(새로고침하면 원래대로).
* 재분류로 그 동작은 고쳤지만, 동의 안내는 여전히 "다크모드는 기능 쿠키이고 거부하면 매
* 방문마다 기본값" 이라고 말합니다. 안내가 실제 동작과 어긋난 채 남으면 동의 고지 자체가
* 사실과 다른 상태가 되므로 저장된 문구도 함께 정정합니다.
*
* 멱등: 이미 정정된 문구는 그대로 둡니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - **알려진 구 문구와 정확히 일치할 때만** 교체합니다 — 운영자나 다른 경로가 바꾼 문구를
* 덮어쓰지 않습니다.
* - 설명이 아예 없는 항목에는 **넣지 않습니다** — 배너는 설명이 있을 때만 문단을 그리므로,
* 없던 설명을 주입하면 화면에 없던 문단이 새로 생깁니다(이 스텝의 목적 밖).
* - 카테고리 구성(키·필수 여부·라벨)은 건드리지 않습니다.
*
* V-1 안전(docs/extension/upgrade-step-guide.md §13): 파일 시스템과 코어 경로 해석기만
* 사용하고 Service / Manager / Repository 를 해석하지 않습니다.
*/
final class RetagThemeAsStrictlyNecessary implements DataMigration
{
/**
* 정정 대상 문구 (1.0.4 시점 동결) — 카테고리 키 => [로케일 => [구 문구, 새 문구]].
*
* @var array<string, array<string, array{0: string, 1: string}>>
*/
private const REPLACEMENTS = [
'necessary' => [
'ko' => [
'세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
],
'en' => [
'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled.',
],
],
'functional' => [
'ko' => [
'사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
],
'en' => [
'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
],
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 사람이 읽을 수 있는 짧은 식별자
*/
public function name(): string
{
return 'RetagThemeAsStrictlyNecessary';
}
/**
* 저장된 쿠키 카테고리 설명 문구를 정정합니다. idempotent.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
*/
public function run(UpgradeContext $context): void
{
// 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다 직접 조립하면
// 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
$path = ExtensionStoragePath::plugin('sirsoft-gdpr', 'settings').'/setting.json';
if (! File::exists($path)) {
$context->logger->info('[sirsoft-gdpr] 설정 파일 없음 — 설치 시 기본값이 시드하므로 skip');
return;
}
$settings = json_decode(File::get($path), true);
if (! is_array($settings) || ! isset($settings['cookie_categories'])) {
$context->logger->info('[sirsoft-gdpr] 저장된 쿠키 카테고리 없음 — 문구 정정 skip');
return;
}
// cookie_categories 는 settings 컬럼이 string 이라 json_encode 된 형태로 저장된다.
$raw = $settings['cookie_categories'];
$wasEncoded = is_string($raw);
$categories = $wasEncoded ? json_decode($raw, true) : $raw;
if (! is_array($categories)) {
$context->logger->warning('[sirsoft-gdpr] 쿠키 카테고리 JSON 형식 비정상 — 문구 정정 skip');
return;
}
$changed = [];
foreach ($categories as $index => $category) {
if (! is_array($category) || ! isset($category['key'])) {
continue;
}
$rules = self::REPLACEMENTS[$category['key']] ?? null;
// 설명이 없는 항목에는 넣지 않는다 — 없던 문단을 새로 만들지 않기 위함.
if ($rules === null || ! isset($category['description']) || ! is_array($category['description'])) {
continue;
}
foreach ($rules as $locale => [$before, $after]) {
if (($category['description'][$locale] ?? null) === $before) {
$categories[$index]['description'][$locale] = $after;
$changed[] = "{$category['key']}.{$locale}";
}
}
}
if ($changed === []) {
$context->logger->info('[sirsoft-gdpr] 정정 대상 문구 없음 — 이미 최신이거나 운영자가 수정함');
return;
}
$settings['cookie_categories'] = $wasEncoded
? json_encode($categories, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: $categories;
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$context->logger->info('[sirsoft-gdpr] 쿠키 카테고리 안내 문구 정정: '.implode(', ', $changed));
}
}