Merge pull request from gnuboard:HeuJung/issue640
HeuJung/issue640
This commit is contained in:
@@ -75,6 +75,7 @@
|
|||||||
- 설치 과정의 모든 응답이 계정 이름·경로·외부 명령 출력의 문자 인코딩과 무관하게 전달되도록 범위를 넓혔습니다. 7.0.2 에서는 설치 진행 로그 한 곳만 보완했는데, 같은 원인이 다른 단계의 응답에도 남아 있었습니다. 아울러 진행 로그에 한글이 물음표로 깨져 남던 것도 이제 원래 글자로 기록됩니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
- 설치 과정의 모든 응답이 계정 이름·경로·외부 명령 출력의 문자 인코딩과 무관하게 전달되도록 범위를 넓혔습니다. 7.0.2 에서는 설치 진행 로그 한 곳만 보완했는데, 같은 원인이 다른 단계의 응답에도 남아 있었습니다. 아울러 진행 로그에 한글이 물음표로 깨져 남던 것도 이제 원래 글자로 기록됩니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
||||||
- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
||||||
- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다.
|
- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다.
|
||||||
|
- 설치 완료·실패·중단 안내와 필수 파일 생성 안내가 나타날 때 화면이 그 위치로 부드럽게 이동합니다. 종전에는 설치 진행 로그를 보느라 화면이 아래쪽에 머물러 있으면 안내가 표시되어도 눈에 들어오지 않았습니다. 화면 움직임을 최소화하도록 설정한 사용자에게는 즉시 이동합니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
|
||||||
|
|
||||||
## [7.0.9] - 2026-08-24
|
## [7.0.9] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -77650,8 +77650,8 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setState",
|
"handler": "setState",
|
||||||
"target": "local",
|
|
||||||
"params": {
|
"params": {
|
||||||
|
"target": "local",
|
||||||
"{{key}}": "{{value}}"
|
"{{key}}": "{{value}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77723,9 +77723,7 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setLocale",
|
"handler": "setLocale",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setTheme": {
|
"setTheme": {
|
||||||
@@ -77753,24 +77751,22 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToSection": {
|
"scrollToSection": {
|
||||||
"label": "$t:editor.action.scroll_to_section.label",
|
"label": "$t:editor.action.scroll_to_section.label",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "sectionId",
|
"key": "targetId",
|
||||||
"label": "$t:editor.action.scroll_to_section.param_section_id",
|
"label": "$t:editor.action.scroll_to_section.param_target_id",
|
||||||
"widget": "text"
|
"widget": "text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "scrollToSection",
|
"handler": "scrollToSection",
|
||||||
"params": {
|
"params": {
|
||||||
"sectionId": "{{sectionId}}"
|
"targetId": "{{targetId}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -77795,8 +77791,8 @@ HTTP/1.1 200
|
|||||||
"label": "$t:editor.action.set_date_range.preset_month"
|
"label": "$t:editor.action.set_date_range.preset_month"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "year",
|
"value": "1year",
|
||||||
"label": "$t:editor.action.set_date_range.preset_year"
|
"label": "$t:editor.action.set_date_range.preset_1year"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -77812,32 +77808,29 @@ HTTP/1.1 200
|
|||||||
"label": "$t:editor.action.toggle_filter_visibility.label",
|
"label": "$t:editor.action.toggle_filter_visibility.label",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "filterKey",
|
"key": "storageKey",
|
||||||
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
|
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
|
||||||
|
"widget": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "filterId",
|
||||||
|
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
|
||||||
"widget": "text"
|
"widget": "text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "toggleFilterVisibility",
|
"handler": "toggleFilterVisibility",
|
||||||
"params": {
|
"params": {
|
||||||
"filterKey": "{{filterKey}}"
|
"storageKey": "{{storageKey}}",
|
||||||
|
"filterId": "{{filterId}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"saveMultilingualTag": {
|
"saveMultilingualTag": {
|
||||||
"label": "$t:editor.action.save_multilingual_tag.label",
|
"label": "$t:editor.action.save_multilingual_tag.label",
|
||||||
"params": [
|
"params": [],
|
||||||
{
|
|
||||||
"key": "tag",
|
|
||||||
"label": "$t:editor.action.save_multilingual_tag.param_tag",
|
|
||||||
"widget": "i18n-text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "saveMultilingualTag",
|
"handler": "saveMultilingualTag"
|
||||||
"params": {
|
|
||||||
"tag": "{{tag}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initTheme": {
|
"initTheme": {
|
||||||
@@ -77865,9 +77858,7 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initTheme",
|
"handler": "initTheme",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initMenuFromUrl": {
|
"initMenuFromUrl": {
|
||||||
@@ -77879,9 +77870,18 @@ HTTP/1.1 200
|
|||||||
},
|
},
|
||||||
"initFilterVisibility": {
|
"initFilterVisibility": {
|
||||||
"label": "$t:editor.action.init_filter_visibility.label",
|
"label": "$t:editor.action.init_filter_visibility.label",
|
||||||
"params": [],
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "storageKey",
|
||||||
|
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
|
||||||
|
"widget": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initFilterVisibility"
|
"handler": "initFilterVisibility",
|
||||||
|
"params": {
|
||||||
|
"storageKey": "{{storageKey}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -173497,8 +173497,8 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setState",
|
"handler": "setState",
|
||||||
"target": "local",
|
|
||||||
"params": {
|
"params": {
|
||||||
|
"target": "local",
|
||||||
"{{key}}": "{{value}}"
|
"{{key}}": "{{value}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173570,9 +173570,7 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setLocale",
|
"handler": "setLocale",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setTheme": {
|
"setTheme": {
|
||||||
@@ -173600,24 +173598,22 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToSection": {
|
"scrollToSection": {
|
||||||
"label": "$t:editor.action.scroll_to_section.label",
|
"label": "$t:editor.action.scroll_to_section.label",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "sectionId",
|
"key": "targetId",
|
||||||
"label": "$t:editor.action.scroll_to_section.param_section_id",
|
"label": "$t:editor.action.scroll_to_section.param_target_id",
|
||||||
"widget": "text"
|
"widget": "text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "scrollToSection",
|
"handler": "scrollToSection",
|
||||||
"params": {
|
"params": {
|
||||||
"sectionId": "{{sectionId}}"
|
"targetId": "{{targetId}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -173642,8 +173638,8 @@ HTTP/1.1 200
|
|||||||
"label": "$t:editor.action.set_date_range.preset_month"
|
"label": "$t:editor.action.set_date_range.preset_month"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "year",
|
"value": "1year",
|
||||||
"label": "$t:editor.action.set_date_range.preset_year"
|
"label": "$t:editor.action.set_date_range.preset_1year"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -173659,32 +173655,29 @@ HTTP/1.1 200
|
|||||||
"label": "$t:editor.action.toggle_filter_visibility.label",
|
"label": "$t:editor.action.toggle_filter_visibility.label",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "filterKey",
|
"key": "storageKey",
|
||||||
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
|
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
|
||||||
|
"widget": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "filterId",
|
||||||
|
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
|
||||||
"widget": "text"
|
"widget": "text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "toggleFilterVisibility",
|
"handler": "toggleFilterVisibility",
|
||||||
"params": {
|
"params": {
|
||||||
"filterKey": "{{filterKey}}"
|
"storageKey": "{{storageKey}}",
|
||||||
|
"filterId": "{{filterId}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"saveMultilingualTag": {
|
"saveMultilingualTag": {
|
||||||
"label": "$t:editor.action.save_multilingual_tag.label",
|
"label": "$t:editor.action.save_multilingual_tag.label",
|
||||||
"params": [
|
"params": [],
|
||||||
{
|
|
||||||
"key": "tag",
|
|
||||||
"label": "$t:editor.action.save_multilingual_tag.param_tag",
|
|
||||||
"widget": "i18n-text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "saveMultilingualTag",
|
"handler": "saveMultilingualTag"
|
||||||
"params": {
|
|
||||||
"tag": "{{tag}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initTheme": {
|
"initTheme": {
|
||||||
@@ -173712,9 +173705,7 @@ HTTP/1.1 200
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initTheme",
|
"handler": "initTheme",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initMenuFromUrl": {
|
"initMenuFromUrl": {
|
||||||
@@ -173726,9 +173717,18 @@ HTTP/1.1 200
|
|||||||
},
|
},
|
||||||
"initFilterVisibility": {
|
"initFilterVisibility": {
|
||||||
"label": "$t:editor.action.init_filter_visibility.label",
|
"label": "$t:editor.action.init_filter_visibility.label",
|
||||||
"params": [],
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "storageKey",
|
||||||
|
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
|
||||||
|
"widget": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initFilterVisibility"
|
"handler": "initFilterVisibility",
|
||||||
|
"params": {
|
||||||
|
"storageKey": "{{storageKey}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
|
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
|
||||||
|
- 「必須の保存項目」(필수 저장 항목) 설정 화면의 일본어 문구 추가 — 카드 제목·설명, 저장소 구분 3종(ブラウザストレージ / セッションストレージ / クッキー) 라벨과 설명, 안내 4항목(목적·와일드카드 표기·항목 이름 찾는 법·필수 항목만 등록), 잠금 항목 라벨·안내, 입력 placeholder
|
||||||
|
- 필수 저장 항목 허용목록의 검증 메시지 일본어 추가 — 저장소 구분별 형식 위반·길이 초과 메시지, 배열 형식 오류, 알 수 없는 저장소 구분
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 자동 차단 정책 안내의 기능 카테고리 범위 문구에서 다크모드를 뺐습니다 — 화면 테마가 필수 항목으로 재분류되어 동의 여부와 무관하게 저장됩니다.
|
||||||
|
|
||||||
## [1.0.1] - 2026-08-10
|
## [1.0.1] - 2026-08-10
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ return [
|
|||||||
'cookie_policy_version' => 'クッキーポリシーバージョン',
|
'cookie_policy_version' => 'クッキーポリシーバージョン',
|
||||||
'auto_blocking' => '自動ブロックポリシー',
|
'auto_blocking' => '自動ブロックポリシー',
|
||||||
'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記カテゴリー別ブロックドメイン一覧の外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(個別トグルなし — バナー表示と共に自動で動作)',
|
'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記カテゴリー別ブロックドメイン一覧の外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(個別トグルなし — バナー表示と共に自動で動作)',
|
||||||
|
'necessary_storage' => '必須の保存項目',
|
||||||
],
|
],
|
||||||
'nav' => [
|
'nav' => [
|
||||||
'operator' => '運営情報',
|
'operator' => '運営情報',
|
||||||
@@ -109,6 +110,9 @@ return [
|
|||||||
'category_required_badge' => '必須',
|
'category_required_badge' => '必須',
|
||||||
'category_optional_badge' => '選択',
|
'category_optional_badge' => '選択',
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'label' => '必須の保存項目許可リスト',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'save_success' => '設定が保存されました。',
|
'save_success' => '設定が保存されました。',
|
||||||
'save_error' => '設定保存中にエラーが発生しました。',
|
'save_error' => '設定保存中にエラーが発生しました。',
|
||||||
@@ -287,4 +291,14 @@ return [
|
|||||||
'category_required_badge' => '必須',
|
'category_required_badge' => '必須',
|
||||||
'view_policy_label' => '変更されたポリシー本文を表示',
|
'view_policy_label' => '変更されたポリシー本文を表示',
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'invalid_format_local_storage' => 'ブラウザストレージ — 項目の形式が正しくありません。(英字·数字と _ . : @ + - を使用し、末尾には * 1つのみ可能)',
|
||||||
|
'invalid_format_session_storage' => 'セッションストレージ — 項目の形式が正しくありません。(英字·数字と _ . : @ + - を使用し、末尾には * 1つのみ可能)',
|
||||||
|
'invalid_format_cookie' => 'Cookie — 項目の形式が正しくありません。(英字·数字と _ . : @ + - を使用し、末尾には * 1つのみ可能)',
|
||||||
|
'too_long_local_storage' => 'ブラウザストレージ — 項目は128文字を超えることはできません。',
|
||||||
|
'too_long_session_storage' => 'セッションストレージ — 項目は128文字を超えることはできません。',
|
||||||
|
'too_long_cookie' => 'Cookie — 項目は128文字を超えることはできません。',
|
||||||
|
'must_be_array' => 'ストレージ別の項目リストは配列である必要があります。',
|
||||||
|
'invalid_scope' => '不明なストレージの区分です: :scope (ブラウザストレージ·セッションストレージ·Cookie のみ使用できます)',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -53,12 +53,15 @@
|
|||||||
"cookie_categories": "クッキーカテゴリ",
|
"cookie_categories": "クッキーカテゴリ",
|
||||||
"cookie_policy_version": "クッキーポリシーバージョン",
|
"cookie_policy_version": "クッキーポリシーバージョン",
|
||||||
"auto_blocking": "自動ブロックポリシー",
|
"auto_blocking": "自動ブロックポリシー",
|
||||||
"auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースはユーザーの同意前まで自動ブロックされます。"
|
"auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースはユーザーの同意前まで自動ブロックされます。",
|
||||||
|
"necessary_storage": "必須保存項目",
|
||||||
|
"necessary_storage_desc": "機能クッキーに同意していない訪問者にも保存が許可される項目を管理します。リストにない項目は訪問するたびに削除されます。"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"operator": "運営情報",
|
"operator": "運営情報",
|
||||||
"cookie_banner": "クッキーバナー",
|
"cookie_banner": "クッキーバナー",
|
||||||
"auto_blocking_policy": "自動ブロックポリシー"
|
"auto_blocking_policy": "自動ブロックポリシー",
|
||||||
|
"necessary_storage": "必須保存項目"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enabled": "有効化",
|
"enabled": "有効化",
|
||||||
@@ -124,11 +127,11 @@
|
|||||||
"scope_label": "自動ブロック対象",
|
"scope_label": "自動ブロック対象",
|
||||||
"tools_label": "代表的なツール例",
|
"tools_label": "代表的なツール例",
|
||||||
"necessary": {
|
"necessary": {
|
||||||
"scope": "自動ブロックしません。セッション·ログイントークン、カート識別子、ユーザーが登録時に選択した言語設定、クッキー同意記録など、サイト動作に不可欠な項目は常に許可されます。",
|
"scope": "自動ブロックしません。セッション·ログイントークン、カート識別子、ユーザーが自ら選んだ言語設定と画面テーマ、クッキー同意記録など、サイト動作に不可欠な項目は常に許可されます。",
|
||||||
"tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定など(別途設定不要)"
|
"tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定、画面テーマなど(別途設定不要)"
|
||||||
},
|
},
|
||||||
"functional": {
|
"functional": {
|
||||||
"scope": "「自動ブロックポリシー」タブの機能カテゴリドメインリストに登録された外部リソースが、同意前までブロックされます。また、ダークモード·通貨選好などのユーザー利便設定も、同意後のみ保存されます。",
|
"scope": "「自動ブロックポリシー」タブの機能カテゴリドメインリストに登録された外部リソースが、同意前までブロックされます。また、通貨選好などのユーザー利便設定も、同意後のみ保存されます。",
|
||||||
"tools": "顧客サポートチャットボット(Crisp、Intercom、Tawk.to)、多言語自動翻訳ウィジェット、ユーザー設定同期サービスなど"
|
"tools": "顧客サポートチャットボット(Crisp、Intercom、Tawk.to)、多言語自動翻訳ウィジェット、ユーザー設定同期サービスなど"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
@@ -140,6 +143,27 @@
|
|||||||
"tools": "Meta Pixel(Facebook)、Google 広告リマーケティング、カカオピクセル、YouTube動画埋め込みなど"
|
"tools": "Meta Pixel(Facebook)、Google 広告リマーケティング、カカオピクセル、YouTube動画埋め込みなど"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"necessary_storage_allowlist": {
|
||||||
|
"warnings_title": "案内",
|
||||||
|
"warning_purpose": "ここに登録した項目のみ機能クッキー未同意状態で保存が維持されます。リストにない項目は訪問するたびに削除され、エラーや警告なく「設定が保存されない」という症状のみが現れます。",
|
||||||
|
"warning_wildcard": "末尾に * を付けると前部分が同じ項目をすべて含みます。例: g7_filters_* は g7_filters_orders_1 を含みます。* は末尾にのみ使用できます。",
|
||||||
|
"warning_discovery": "新しくインストールした拡張が保存する項目名はその拡張のドキュメントまたはブラウザ開発者ツール(Application → Storage)で確認できます。",
|
||||||
|
"warning_necessary_only": "サイト運営に必ず必要な項目のみ登録してください。追跡・分析目的の項目を入れると同意前ブロック原則が崩れます。",
|
||||||
|
"locked_label": "ロック項目(削除不可)",
|
||||||
|
"locked_hint": "なければサイトが動作しない項目です。リストから外すことはできません。",
|
||||||
|
"tag_input_placeholder": "項目名を入力してEnter(例: g7_locale, myplugin_*)",
|
||||||
|
"tag_no_options": "推奨項目がありません。直接入力してください。",
|
||||||
|
"scope": {
|
||||||
|
"local_storage": "ブラウザストレージ(localStorage)",
|
||||||
|
"session_storage": "セッションストレージ(sessionStorage)",
|
||||||
|
"cookie": "クッキー"
|
||||||
|
},
|
||||||
|
"scope_desc": {
|
||||||
|
"local_storage": "ブラウザを閉じても残る保存領域。言語·テーマのようなユーザー選択と管理者画面表示設定がここに保存されます。",
|
||||||
|
"session_storage": "タブを閉じると消える保存領域。決済画面·本人認証画面から戻るときに元の画面を復元するのに使われます。",
|
||||||
|
"cookie": "ブラウザがサーバーに一緒に送る値。サーバーが仕込むクッキーと画面が使うクッキーの両方がこのリストで判定されます。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save_success": "設定が保存されました。",
|
"save_success": "設定が保存されました。",
|
||||||
@@ -324,5 +348,15 @@
|
|||||||
"operator_and_storage_line": "本サイトは{entity}が運営しており、データは{location}に保存されます",
|
"operator_and_storage_line": "本サイトは{entity}が運営しており、データは{location}に保存されます",
|
||||||
"operator_only_line": "本サイトは{entity}が運営しています",
|
"operator_only_line": "本サイトは{entity}が運営しています",
|
||||||
"storage_only_line": "ユーザーデータは{location}に保存されます"
|
"storage_only_line": "ユーザーデータは{location}に保存されます"
|
||||||
|
},
|
||||||
|
"necessary_storage_allowlist": {
|
||||||
|
"invalid_format_local_storage": "ブラウザストレージ — 項目形式が正しくありません。(英数字と _ . : @ + - を使用し、末尾に * は1つだけ可能)",
|
||||||
|
"invalid_format_session_storage": "セッションストレージ — 項目形式が正しくありません。(英数字と _ . : @ + - を使用し、末尾に * は1つだけ可能)",
|
||||||
|
"invalid_format_cookie": "クッキー — 項目形式が正しくありません。(英数字と _ . : @ + - を使用し、末尾に * は1つだけ可能)",
|
||||||
|
"too_long_local_storage": "ブラウザストレージ — 項目は128文字を超えることはできません。",
|
||||||
|
"too_long_session_storage": "セッションストレージ — 項目は128文字を超えることはできません。",
|
||||||
|
"too_long_cookie": "クッキー — 項目は128文字を超えることはできません。",
|
||||||
|
"must_be_array": "ストレージごとの項目リストは配列である必要があります。",
|
||||||
|
"invalid_scope": "不明なストレージ区分です: :scope (ブラウザストレージ·セッションストレージ·クッキーのみ使用できます)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
- 확장 제거 시 표시되는 「운영자 파일 사본 보관」 안내 제목·설명의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로 안내가 일본어 로케일로 표시됩니다.
|
- 확장 제거 시 표시되는 「운영자 파일 사본 보관」 안내 제목·설명의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로 안내가 일본어 로케일로 표시됩니다.
|
||||||
- 확장 제거가 끝난 뒤 결과 화면 제목(「모듈 제거 완료」·「플러그인 제거 완료」·「템플릿 제거 완료」)의 일본어 번역을 추가했습니다 — 종전에는 결과 화면인데 제목이 「제거 확인」으로 남아 있었습니다.
|
- 확장 제거가 끝난 뒤 결과 화면 제목(「모듈 제거 완료」·「플러그인 제거 완료」·「템플릿 제거 완료」)의 일본어 번역을 추가했습니다 — 종전에는 결과 화면인데 제목이 「제거 확인」으로 남아 있었습니다.
|
||||||
- 환경설정 > 고급의 리버스 프록시 진단 항목(라벨·상태·안내 문구)의 일본어 번역을 추가했습니다.
|
- 환경설정 > 고급의 리버스 프록시 진단 항목(라벨·상태·안내 문구)의 일본어 번역을 추가했습니다.
|
||||||
|
- 레이아웃 편집기 「필터 보이기/숨기기」·「필터 표시 초기화」 동작의 저장 키·필터 ID 입력 항목 이름을 일본어로 추가했습니다.
|
||||||
|
- 사이드바 하위 메뉴 펼치기/접기 버튼의 화면 낭독기 라벨을 일본어로 추가했습니다.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 레이아웃 편집기 「기간 빠르게 선택」의 마지막 선택지 표기를 「今年」에서 「直近1年」으로 바꿨습니다 — 실제 계산 범위가 올해가 아니라 최근 1년이었습니다.
|
||||||
|
|
||||||
## [1.0.7] - 2026-08-24
|
## [1.0.7] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
"logout": "ログアウト",
|
"logout": "ログアウト",
|
||||||
"expand_sidebar": "サイドバーを展開",
|
"expand_sidebar": "サイドバーを展開",
|
||||||
"collapse_sidebar": "サイドバーを折りたたむ",
|
"collapse_sidebar": "サイドバーを折りたたむ",
|
||||||
|
"expand": "展開",
|
||||||
|
"collapse": "折りたたむ",
|
||||||
"module": "モジュール",
|
"module": "モジュール",
|
||||||
"plugin": "プラグイン",
|
"plugin": "プラグイン",
|
||||||
"status_active": "有効化",
|
"status_active": "有効化",
|
||||||
|
|||||||
@@ -1261,7 +1261,7 @@
|
|||||||
},
|
},
|
||||||
"scroll_to_section": {
|
"scroll_to_section": {
|
||||||
"label": "特定の領域にスクロール",
|
"label": "特定の領域にスクロール",
|
||||||
"param_section_id": "領域ID"
|
"param_target_id": "領域ID"
|
||||||
},
|
},
|
||||||
"set_date_range": {
|
"set_date_range": {
|
||||||
"label": "期間を素早く選択",
|
"label": "期間を素早く選択",
|
||||||
@@ -1269,15 +1269,15 @@
|
|||||||
"preset_today": "今日",
|
"preset_today": "今日",
|
||||||
"preset_week": "今週",
|
"preset_week": "今週",
|
||||||
"preset_month": "今月",
|
"preset_month": "今月",
|
||||||
"preset_year": "今年"
|
"preset_1year": "直近1年"
|
||||||
},
|
},
|
||||||
"toggle_filter_visibility": {
|
"toggle_filter_visibility": {
|
||||||
"label": "フィルターの表示·非表示",
|
"label": "フィルターの表示·非表示",
|
||||||
"param_filter_key": "フィルターキー"
|
"param_storage_key": "保存キー",
|
||||||
|
"param_filter_id": "フィルターID"
|
||||||
},
|
},
|
||||||
"save_multilingual_tag": {
|
"save_multilingual_tag": {
|
||||||
"label": "多言語タグを保存",
|
"label": "多言語タグを保存"
|
||||||
"param_tag": "タグ"
|
|
||||||
},
|
},
|
||||||
"init_theme": {
|
"init_theme": {
|
||||||
"label": "画面のテーマをリセット",
|
"label": "画面のテーマをリセット",
|
||||||
@@ -1290,7 +1290,8 @@
|
|||||||
"label": "アドレスからメニューを初期化"
|
"label": "アドレスからメニューを初期化"
|
||||||
},
|
},
|
||||||
"init_filter_visibility": {
|
"init_filter_visibility": {
|
||||||
"label": "フィルター表示をリセット"
|
"label": "フィルター表示をリセット",
|
||||||
|
"param_storage_key": "保存キー"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"condition": {
|
"condition": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
```text
|
```text
|
||||||
1. 유형: 플러그인 (sirsoft-gdpr) — 쿠키 동의 배너·동의 이력·GDPR/개인정보보호법 대응을 소유
|
1. 유형: 플러그인 (sirsoft-gdpr) — 쿠키 동의 배너·동의 이력·GDPR/개인정보보호법 대응을 소유
|
||||||
2. 확장 방식: `sirsoft-gdpr.consent.granted`/`revoked` 훅 구독, `data-gdpr-category` HTML 속성으로 자체 호스팅 자원 등록
|
2. 확장 방식: `sirsoft-gdpr.consent.granted`/`revoked` 훅 구독, `data-gdpr-category` HTML 속성으로 자체 호스팅 자원 등록
|
||||||
3. 건드리면 안 되는 것: `CookieConsentMiddleware`(functional 미동의 시 Set-Cookie 게이팅)의 strictly-necessary allowlist, 동의 이력(immutable append-only) 직접 수정
|
3. 건드리면 안 되는 것: 필수 허용목록의 **잠금 항목**(`Support\NecessaryAllowlist::locked()`), 동의 이력(immutable append-only) 직접 수정
|
||||||
4. 작업 위치: `plugins/_bundled/sirsoft-gdpr` — 활성 디렉토리 직접 수정 금지
|
4. 작업 위치: `plugins/_bundled/sirsoft-gdpr` — 활성 디렉토리 직접 수정 금지
|
||||||
5. 반영: `php artisan plugin:update sirsoft-gdpr --force`
|
5. 반영: `php artisan plugin:update sirsoft-gdpr --force`
|
||||||
```
|
```
|
||||||
@@ -29,10 +29,24 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
|
|||||||
재동의 화면을 보게 되어 UX 를 해칩니다.
|
재동의 화면을 보게 되어 UX 를 해칩니다.
|
||||||
|
|
||||||
**의도적으로 하지 않는 것**: 게스트 → 회원 동의 자동 승계(§README 소개 참고), 그리고 운영자가
|
**의도적으로 하지 않는 것**: 게스트 → 회원 동의 자동 승계(§README 소개 참고), 그리고 운영자가
|
||||||
등록한 "허용" functional 쿠키 화이트리스트도 두지 않습니다 — functional 미동의 시 strictly
|
등록한 "허용" functional 쿠키 화이트리스트도 두지 않습니다 — functional 미동의 시 필수 허용목록
|
||||||
necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를
|
밖의 **모든** 쿠키를 차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면
|
||||||
차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만
|
차단"이지 "등록된 것만 차단"이 아니기 때문입니다.
|
||||||
차단"이 아니기 때문입니다.
|
|
||||||
|
**필수 허용목록은 코드 상수가 아니라 운영자 설정입니다**(`necessary_storage_allowlist`).
|
||||||
|
관리자 환경설정의 「필수 저장 항목」 카드에서 저장소 구분(브라우저 저장소 / 세션 저장소 / 쿠키)
|
||||||
|
셋을 각각 편집하며, 끝의 `*` 는 앞부분 매칭입니다. `plugin.php` 의
|
||||||
|
`DEFAULT_NECESSARY_ALLOWLIST_CATALOG` 는 **신규 설치 시드용 출하 기본값이자 화면 추천 목록**일
|
||||||
|
뿐 판정 목록이 아닙니다 — 새 확장이 저장 키를 도입해도 이 플러그인을 고칠 필요가 없습니다.
|
||||||
|
|
||||||
|
그래서 **판정 목록은 하나**입니다. 서버(`CookieConsentMiddleware`)와 클라이언트 셋
|
||||||
|
(`storageInterceptor` · `cookieInterceptor` · `functionalCleaner`)이 같은 설정을 같은 매칭 규칙
|
||||||
|
(`Support\NecessaryAllowlist` ↔ `resources/js/necessaryAllowlist.ts`)으로 읽습니다. 예전에는
|
||||||
|
쿠키 목록과 저장소 목록이 따로 있었고, 그 둘이 어긋나도 아무 신호가 없었습니다.
|
||||||
|
|
||||||
|
**잠금 항목만 설정 밖입니다** — `auth_token` · `XSRF-TOKEN` · 세션 쿠키(런타임 해석) ·
|
||||||
|
`gdpr_session` 넷은 없으면 사이트가 서지 못하므로 코드가 정하고, 판정에는 언제나 운영자 목록과
|
||||||
|
합집합으로 얹힙니다. 설정에 담으면 저장 요청 한 번으로 지워져 잠금이 아니게 됩니다.
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 2. 디렉토리 지도
|
## 2. 디렉토리 지도
|
||||||
@@ -128,7 +142,12 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
|
|||||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||||
- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행)
|
- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행)
|
||||||
- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화
|
- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화
|
||||||
- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
|
- [ ] 잠금 집합(`NecessaryAllowlist::locked()`)이나 출하 카탈로그에 항목을 늘릴 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
|
||||||
|
- [ ] **필수 허용목록에 항목이 필요하면 코드가 아니라 관리자 화면에서 추가한다** — 운영자 설정(`necessary_storage_allowlist`)이 판정 목록이다. `plugin.php` 의 `DEFAULT_NECESSARY_ALLOWLIST_CATALOG` 를 고치는 것은 **신규 설치 기본값과 화면 추천 목록**을 바꾸는 일이며, 이미 설치된 사이트에는 반영되지 않는다
|
||||||
|
- [ ] 그 카탈로그를 고쳤다면 **동의 안내 문구도 함께** 고친다 — 항목이 어느 카테고리에 속하는지 사용자에게 말하는 자리가 `plugin.php`(설치 시드) · `src/Services/CookieCategoryService.php`(런타임 폴백) · `resources/lang/{ko,en}.json`(관리자 안내) · `editor-spec.json`(편집기 샘플) 넷이다. 한 곳만 고치면 동의 고지가 실제 동작과 어긋난 채 남는다
|
||||||
|
- [ ] 판정 규칙(매칭·잠금 집합·스코프 어휘)을 고치면 PHP(`src/Support/NecessaryAllowlist.php`)와 TS(`resources/js/necessaryAllowlist.ts`)를 **함께** 고친다 — 한쪽만 고치면 그 항목이 서버에서만(또는 브라우저에서만) 살아 있고, 그 어긋남은 예외도 로그도 남기지 않는다 (`__tests__/necessaryAllowlistCoverage.test.ts` 가 대조한다)
|
||||||
|
- [ ] 설정 키를 새로 노출할 때는 `config/settings/defaults.json` 의 `frontend_schema` 에 `expose: true` 를 **함께** 넣는다 — 빠지면 저장도 화면도 정상인데 브라우저 인라인 페이로드에만 값이 오지 않아 인터셉터가 빈 목록으로 선다
|
||||||
|
- [ ] 그 문구를 고쳤으면 기설치본의 **저장된** 안내도 정정하는 업그레이드 스텝을 동반한다 — 카테고리 정의는 설치 시점에 시드되고 이후 갱신되지 않는다 (선례: `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`
|
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-gdpr --force`
|
||||||
|
|
||||||
## 6. 금지 패턴
|
## 6. 금지 패턴
|
||||||
@@ -138,8 +157,13 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `gdpr_user_consent_histories` 행을 UPDATE/DELETE 로 직접 정정 | 정정이 필요하면 새 이력 행을 INSERT | 이력은 시점별 스냅샷이 생명 — 과거 행을 고치면 Art.7(1) 입증 자료로서 효력을 잃는다 |
|
| `gdpr_user_consent_histories` 행을 UPDATE/DELETE 로 직접 정정 | 정정이 필요하면 새 이력 행을 INSERT | 이력은 시점별 스냅샷이 생명 — 과거 행을 고치면 Art.7(1) 입증 자료로서 효력을 잃는다 |
|
||||||
| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
|
| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
|
||||||
| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
|
| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | 필수 허용목록(운영자 설정) ∪ 잠금 집합만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
|
||||||
|
| 소비자(인터셉터·정리기·미들웨어)가 자기 목록 사본을 들고 판정 | 넷 다 같은 설정을 같은 함수로 읽는다 | 사본은 갈라지는데 그 어긋남은 "그 항목만 안 되는" 상태로만 나타난다 |
|
||||||
|
| 잠금 항목을 설정(`necessary_storage_allowlist`)에 넣기 | 코드(`NecessaryAllowlist::locked()`)가 정하고 판정에서 합집합 | 설정에 있으면 저장 요청 한 번으로 지워져 잠금이 아니게 된다 |
|
||||||
|
| 세션 쿠키 이름을 `'laravel_session'` 으로 하드코딩 | `config('session.cookie')` 런타임 해석 | `SESSION_COOKIE` 를 지정한 사이트에서 그 항목이 죽고, 그 사실이 어디에도 드러나지 않는다 |
|
||||||
|
| 새 확장의 저장 키를 플러그인이 관측·수집해 자동 등재 | 운영자가 화면에서 직접 추가 | 저장 키 관측은 그 자체가 추적이다 — 동의 없이 하는 관측을 이 플러그인이 할 수는 없다 |
|
||||||
| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
|
| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
|
||||||
|
| 저장소 허용목록만 고치고 동의 안내 문구는 그대로 두기 | 목록·문구 4곳·업그레이드 스텝을 한 작업 단위로 | 안내가 "이 항목은 기능 쿠키이고 거부하면 저장되지 않는다" 라고 말하는데 실제로는 항상 저장되면, 고지 자체가 사실과 달라진다 |
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 7. 테스트 실행
|
## 7. 테스트 실행
|
||||||
@@ -147,10 +171,10 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
|
|||||||
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||||
| 종류 | 개수 | 위치 |
|
| 종류 | 개수 | 위치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` |
|
| PHPUnit | 25개 | `plugins/_bundled/sirsoft-gdpr/tests` |
|
||||||
| Vitest | 12개 | `vitest.config.ts` |
|
| Vitest | 13개 | `vitest.config.ts` |
|
||||||
| Playwright | 3개 | `tests/Playwright` |
|
| Playwright | 4개 | `tests/Playwright` |
|
||||||
| 시나리오 매니페스트 | 5개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 6개 | `tests/scenarios` |
|
||||||
|
|
||||||
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,21 @@
|
|||||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||||
|
- GDPR 설정 화면에 「필수 저장 항목」 카드를 추가했습니다. 기능 쿠키에 동의하지 않은 방문자에게도 저장이 허용되는 항목을 브라우저 저장소·세션 저장소·쿠키로 나누어 직접 편집할 수 있습니다. 새로 설치한 확장이 저장하는 항목이 동의 전에 지워진다면, 이제 이 화면에서 추가하면 됩니다.
|
||||||
|
- 항목 이름 끝에 `*` 를 붙이면 앞부분이 같은 항목을 모두 포함합니다. 예를 들어 `g7_filters_*` 는 `g7_filters_orders_1` 을 함께 포함합니다.
|
||||||
|
- 사이트 동작에 반드시 필요한 항목(로그인 토큰, CSRF 토큰, 세션 쿠키, 쿠키 동의 기록)은 「잠금 항목」으로 카드 위쪽에 표시되며 삭제할 수 없습니다.
|
||||||
|
- 이미 설치된 사이트는 업데이트 시 현재 기본 목록이 설정으로 옮겨집니다. 이미 편집한 값이 있으면 그대로 둡니다.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
|
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
|
||||||
- 쿠키 동의 저장 요청이 서버만 기록해야 하는 출처(회원가입 시 동의)를 직접 지정할 수 있던 문제를 수정했습니다 — 동의 이력의 출처가 실제 동의 경로와 일치합니다.
|
- 쿠키 동의 저장 요청이 서버만 기록해야 하는 출처(회원가입 시 동의)를 직접 지정할 수 있던 문제를 수정했습니다 — 동의 이력의 출처가 실제 동의 경로와 일치합니다.
|
||||||
|
- 화면 테마(밝게/어둡게/시스템 설정) 선택이 저장되지 않던 문제를 수정했습니다. 기능 쿠키에 동의하기 전에는 테마를 바꿔도 새로고침하면 원래대로 돌아갔고, 관리자·사용자 화면 모두 같았습니다. 테마는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과 같은 필수 항목으로 분류해 동의 여부와 무관하게 저장합니다.
|
||||||
|
- 이에 맞춰 쿠키 동의 안내 문구를 정정했습니다 — 필수 항목 설명에 화면 테마가 포함되고, 기능 쿠키 설명에서는 빠집니다. 이미 설치된 사이트의 저장된 안내 문구도 업데이트 시 함께 정정됩니다(운영자가 직접 고친 문구는 그대로 둡니다).
|
||||||
|
- 화면 테마와 같은 이유로 사라지던 나머지 항목도 함께 필수로 분류했습니다. 비회원이 주문 후 주문내역을 조회할 때 쓰는 정보, 결제창에서 돌아왔을 때 결제 중단 사유를 서버에 알리는 기록, 본인인증을 마치고 원래 화면·입력 내용으로 복귀하는 데 쓰는 기록, 관리자 화면의 메뉴 접기·목록 필터 표시·닫은 경고·레이아웃 편집기 작업 상태, 그리고 사이트가 자산 주소 형식을 기억해 두는 내부 캐시가 해당합니다. 기능 쿠키 동의 여부와 무관하게 유지됩니다.
|
||||||
|
- 자동 차단 정책 화면의 차단 도메인 입력칸에 추천 도메인이 나타나지 않던 문제를 수정했습니다. 이미 선택한 도메인만 목록에 다시 보여 추천이 동작하는 것처럼 보였습니다.
|
||||||
|
- 세션 쿠키 이름을 기본값(`laravel_session`)에서 바꾼 사이트에서, 브라우저 쪽 필수 목록의 세션 쿠키 항목이 실제 쿠키와 이름이 달라 동작하지 않던 문제를 수정했습니다. 이제 서버 설정에서 이름을 읽습니다.
|
||||||
|
- 쿠키 목록에서는 `이름_*` 형태의 앞부분 매칭이 동작하지 않던 문제를 수정했습니다. 브라우저 저장소 목록과 동일하게 동작합니다.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-19
|
## [1.0.3] - 2026-08-19
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ php artisan plugin:update sirsoft-gdpr --force
|
|||||||
| `banner_enabled` | 쿠키 배너 노출 | `true` |
|
| `banner_enabled` | 쿠키 배너 노출 | `true` |
|
||||||
| `banner_position` | 배너 위치 | `bottom_bar` |
|
| `banner_position` | 배너 위치 | `bottom_bar` |
|
||||||
| `blocked_domains` | 추적 도메인 차단 목록 | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` |
|
| `blocked_domains` | 추적 도메인 차단 목록 | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` |
|
||||||
|
| `necessary_storage_allowlist` | 필수 저장 항목 허용목록 | `{"localStorage":["g7_locale","g7_color_scheme","g7_cache_version","g7_asset_url_mode*","g7_cart_key","g7-devtools-panel","g7_guest_order_token","g7_guest_order_number","g7_guest_order_expires_at","g7_devtools_*","g7_filters_*","g7_columns_*","g7_order_*","g7_admin_sidebar_collapsed","g7_filter_visibility_*","g7_dismissed_warnings","g7le.*","__sirsoftKginicisMobilePaymentReturnPending","g7.identity.redirectStash","sirsoft-verification_nhnkcp.formStash"],"sessionStorage":["g7:sirsoft-pay_kginicis:pendingClose","g7:sirsoft-pay_nhnkcp:pendingClose","g7:sirsoft-tosspayments:pendingClose","g7.identity.redirectStash","sirsoft-verification_nhnkcp.formStash","__sirsoftKginicisMobilePaymentReturnPending","g7le.*","g7_devtools_*","g7_filters_*","g7_columns_*","g7_order_*","g7_filter_visibility_*"],"cookie":["laravel_maintenance"]}` |
|
||||||
| `cookie_categories` | 쿠키 카테고리 정의 | `[]` |
|
| `cookie_categories` | 쿠키 카테고리 정의 | `[]` |
|
||||||
|
|
||||||
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
|
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
|
||||||
@@ -124,6 +125,22 @@ Art.6 "동의 전 처리 금지"를 강제하는 메커니즘인 자동 차단
|
|||||||
Google Analytics, Facebook Pixel, Kakao Pixel 등)가 시드되어 있고 운영자가 추가·삭제할 수
|
Google Analytics, Facebook Pixel, Kakao Pixel 등)가 시드되어 있고 운영자가 추가·삭제할 수
|
||||||
있습니다. 도메인 형식은 `example.com` 또는 와일드카드 `*.example.com` 만 지원하며, `localhost`
|
있습니다. 도메인 형식은 `example.com` 또는 와일드카드 `*.example.com` 만 지원하며, `localhost`
|
||||||
같은 단일 라벨과 한글 도메인(xn-- 변환)은 지원하지 않습니다.
|
같은 단일 라벨과 한글 도메인(xn-- 변환)은 지원하지 않습니다.
|
||||||
|
|
||||||
|
`필수 저장 항목`(`necessary_storage_allowlist`)은 **기능 쿠키에 동의하지 않은 방문자에게도
|
||||||
|
저장이 허용되는 항목** 목록입니다. 이 목록 밖의 항목은 방문할 때마다 지워지므로, 새로 설치한
|
||||||
|
확장의 설정이 "저장했는데 새로고침하면 사라진다"면 그 확장이 쓰는 항목 이름을 여기에
|
||||||
|
추가하면 됩니다 — 이 플러그인을 고칠 필요가 없습니다. 항목 이름은 그 확장의 문서나 브라우저
|
||||||
|
개발자 도구(Application → Storage)에서 확인할 수 있습니다.
|
||||||
|
|
||||||
|
목록은 저장소 구분(브라우저 저장소 / 세션 저장소 / 쿠키) 셋으로 나뉘고, 이름 끝에 `*` 를
|
||||||
|
붙이면 앞부분이 같은 항목을 모두 포함합니다(`g7_filters_*` → `g7_filters_orders_1`). `*` 는
|
||||||
|
끝에만 쓸 수 있습니다 — 앞에 두면 모든 항목이 열려 동의 전 차단이 무의미해지기 때문입니다.
|
||||||
|
|
||||||
|
로그인 토큰·CSRF 토큰·세션 쿠키·쿠키 동의 기록은 `잠금 항목`으로 카드 위쪽에 따로 표시되며
|
||||||
|
삭제할 수 없습니다. 이 넷이 없으면 사이트가 동작하지 않습니다.
|
||||||
|
|
||||||
|
사이트 운영에 반드시 필요한 항목만 등록하세요. 추적·분석 목적의 항목을 여기 넣으면 동의 전
|
||||||
|
차단 원칙이 무너집니다.
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 사용 방법
|
## 사용 방법
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"identifier": "sirsoft-gdpr",
|
"identifier": "sirsoft-gdpr",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"components": {
|
"components": {
|
||||||
"basic": [],
|
"basic": [],
|
||||||
"composite": [],
|
"composite": [],
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"banner_enabled": { "type": "boolean", "expose": true },
|
"banner_enabled": { "type": "boolean", "expose": true },
|
||||||
"banner_position": { "type": "string", "expose": true },
|
"banner_position": { "type": "string", "expose": true },
|
||||||
"cookie_categories": { "type": "string", "expose": true },
|
"cookie_categories": { "type": "string", "expose": true },
|
||||||
"blocked_domains": { "type": "object", "expose": true }
|
"blocked_domains": { "type": "object", "expose": true },
|
||||||
|
"necessary_storage_allowlist": { "type": "object", "expose": true },
|
||||||
|
"necessary_storage_locked": { "type": "object", "expose": true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -305,7 +305,7 @@ HTTP/1.1 200
|
|||||||
"id": null,
|
"id": null,
|
||||||
"consent_key": "cookie_necessary",
|
"consent_key": "cookie_necessary",
|
||||||
"consent_label": "필수 쿠키",
|
"consent_label": "필수 쿠키",
|
||||||
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
||||||
"consent_category": "necessary",
|
"consent_category": "necessary",
|
||||||
"is_required": true,
|
"is_required": true,
|
||||||
"is_consented": false,
|
"is_consented": false,
|
||||||
@@ -322,7 +322,7 @@ HTTP/1.1 200
|
|||||||
"id": null,
|
"id": null,
|
||||||
"consent_key": "cookie_functional",
|
"consent_key": "cookie_functional",
|
||||||
"consent_label": "기능 쿠키",
|
"consent_label": "기능 쿠키",
|
||||||
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
||||||
"consent_category": "functional",
|
"consent_category": "functional",
|
||||||
"is_required": false,
|
"is_required": false,
|
||||||
"is_consented": false,
|
"is_consented": false,
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ HTTP/1.1 200
|
|||||||
"en": "Strictly Necessary"
|
"en": "Strictly Necessary"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
"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."
|
"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"
|
"en": "Functional"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
||||||
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
|
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,6 +139,17 @@ HTTP/1.1 200
|
|||||||
|
|
||||||
**설명** GDPR 플러그인의 관리자 설정 전체를 반환해 관리자 설정 화면 폼에 바인딩합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `cookie_categories` 같은 JSON 필드는 디코드하여 객체/배열로 노출합니다. 조회 전용이며 정책 버전 발행 등 부수 효과는 없습니다.
|
**설명** GDPR 플러그인의 관리자 설정 전체를 반환해 관리자 설정 화면 폼에 바인딩합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `cookie_categories` 같은 JSON 필드는 디코드하여 객체/배열로 노출합니다. 조회 전용이며 정책 버전 발행 등 부수 효과는 없습니다.
|
||||||
|
|
||||||
|
**추가 응답 필드**
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| settings.necessary_storage_allowlist | object | `{"localStorage":["g7_locale","g7_filters_*"],"sessionStorage":[],"cookie":["laravel_maintenance"]}` | 기능 쿠키 미동의 상태에서도 저장이 허용되는 항목(운영자 편집 대상). 저장소 구분(`localStorage`/`sessionStorage`/`cookie`)별 문자열 배열이며, 끝의 `*` 는 앞부분 매칭입니다 |
|
||||||
|
| settings.necessary_storage_locked | object | `{"localStorage":["auth_token"],"sessionStorage":[],"cookie":["XSRF-TOKEN","laravel_session","gdpr_session"]}` | 지울 수 없는 잠금 항목. 설정이 아니라 코드가 정하므로 저장 요청에 담아 보내도 반영되지 않으며, 판정에는 언제나 운영자 목록과 합쳐 적용됩니다. 세션 쿠키 이름은 `session.cookie` 설정에서 런타임 해석됩니다 |
|
||||||
|
| default_blocked_domains_preview | object | `{"functional":["*.crisp.chat"],"analytics":["google-analytics.com"],"marketing":[]}` | 출하 기본 차단 도메인 카탈로그. 관리자 화면 TagInput 의 자동완성 추천 출처입니다 |
|
||||||
|
| default_necessary_allowlist_preview | object | `{"localStorage":["g7_locale","g7_color_scheme"],"sessionStorage":[],"cookie":["laravel_maintenance"]}` | 출하 기본 허용목록 카탈로그. 위와 같이 추천 출처이며 잠금 항목은 담기지 않습니다 |
|
||||||
|
|
||||||
|
두 `*_preview` 는 `settings` 바깥, `data` 최상위에 실립니다.
|
||||||
|
|
||||||
|
|
||||||
### PUT /api/plugins/sirsoft-gdpr/admin/settings
|
### PUT /api/plugins/sirsoft-gdpr/admin/settings
|
||||||
<!-- @generated:start:api.plugins.sirsoft-gdpr.admin.settings.update -->
|
<!-- @generated:start:api.plugins.sirsoft-gdpr.admin.settings.update -->
|
||||||
@@ -260,7 +271,19 @@ HTTP/1.1 200
|
|||||||
|
|
||||||
<!-- @generated:end -->
|
<!-- @generated:end -->
|
||||||
|
|
||||||
**설명** 검증된 GDPR 관리자 설정을 저장합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.update` 권한이 필요합니다. 설정만 저장할 뿐 정책 버전은 자동 발행되지 않으며, 재동의가 필요한 변경이라면 운영자가 「+ 새 버전 발행」을 별도로 눌러야 합니다. 배너 문구·위치, 쿠키 카테고리, 차단 도메인 등을 갱신하는 쓰기 엔드포인트입니다.
|
**설명** 검증된 GDPR 관리자 설정을 저장합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.update` 권한이 필요합니다. 설정만 저장할 뿐 정책 버전은 자동 발행되지 않으며, 재동의가 필요한 변경이라면 운영자가 「+ 새 버전 발행」을 별도로 눌러야 합니다. 배너 문구·위치, 쿠키 카테고리, 차단 도메인, 필수 저장 항목 허용목록 등을 갱신하는 쓰기 엔드포인트입니다.
|
||||||
|
|
||||||
|
**추가 요청 파라미터**
|
||||||
|
|
||||||
|
| 파라미터 | 위치 | 타입 | 필수 | 기본값 | 설명 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| necessary_storage_allowlist | body | object | 아니오 | — | 저장소 구분별 허용 항목 목록. 키는 `localStorage` / `sessionStorage` / `cookie` 셋만 허용되며(그 외는 422), 값은 문자열 배열입니다. 항목은 영문·숫자와 `_ . : @ + -` 로 이루어지고 최대 128자이며, 끝에 `*` 를 하나 붙이면 앞부분 매칭이 됩니다(`*` 를 앞이나 중간에 두면 422). textarea 줄바꿈 문자열로 보내도 배열로 정규화됩니다 |
|
||||||
|
|
||||||
|
키를 **보내지 않으면** 기존 저장값을 그대로 둡니다. 빈 객체(`{}`)를 보내면 세 구분이 모두 빈 배열로 저장되어 잠금 항목만 남습니다.
|
||||||
|
|
||||||
|
검증 실패 시 에러 키는 `necessary_storage_allowlist.{구분}.{인덱스}` 형태이고, 알 수 없는 구분은 `necessary_storage_allowlist.{구분}` 으로 보고됩니다.
|
||||||
|
|
||||||
|
`necessary_storage_locked` 는 요청에 담아도 저장되지 않습니다 — 잠금 항목은 코드가 정하며 API 로 지울 수 없습니다.
|
||||||
|
|
||||||
|
|
||||||
### GET /api/plugins/sirsoft-gdpr/settings
|
### GET /api/plugins/sirsoft-gdpr/settings
|
||||||
@@ -325,8 +348,8 @@ HTTP/1.1 200
|
|||||||
"en": "Strictly Necessary"
|
"en": "Strictly Necessary"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
"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."
|
"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 +360,8 @@ HTTP/1.1 200
|
|||||||
"en": "Functional"
|
"en": "Functional"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
||||||
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
|
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,6 +43,18 @@
|
|||||||
iframe·1st-party 저장소 게이팅)은 이 액션 핸들러가 아니라 `dist/js/plugin.iife.js` 가 페이지
|
iframe·1st-party 저장소 게이팅)은 이 액션 핸들러가 아니라 `dist/js/plugin.iife.js` 가 페이지
|
||||||
로드 시 스스로 수행합니다 — 사용자 조작에 반응하는 것과 페이지 로드마다 항상 실행되는 것을
|
로드 시 스스로 수행합니다 — 사용자 조작에 반응하는 것과 페이지 로드마다 항상 실행되는 것을
|
||||||
액션 핸들러/전역 스크립트로 구분한 것입니다.
|
액션 핸들러/전역 스크립트로 구분한 것입니다.
|
||||||
|
|
||||||
|
그 저장소 게이팅의 판정 목록(필수 허용목록)은 **인라인 페이로드**로 옵니다. 인터셉터는
|
||||||
|
`fetchPublicSettings()` 앞에서 서야 동의 전 첫 저장을 막을 수 있으므로 응답을 기다릴 수 없고,
|
||||||
|
그래서 `window.G7Config.plugins['sirsoft-gdpr']` 의 `necessary_storage_allowlist` ·
|
||||||
|
`necessary_storage_locked` 를 동기로 읽습니다(`readInlineNecessaryAllowlist()`). 이 경로가
|
||||||
|
비면 인터셉터는 잠금 집합만으로 서고, 운영자가 등재한 항목이 그 창에서 파기됩니다 — 예외도
|
||||||
|
로그도 남지 않으므로 새 설정 키를 노출할 때는 `config/settings/defaults.json` 의
|
||||||
|
`frontend_schema` 에 `expose: true` 를 반드시 함께 넣어야 합니다.
|
||||||
|
|
||||||
|
판정 함수는 `resources/js/necessaryAllowlist.ts` 한 곳에 있고 인터셉터 둘과 정리기가 그것을
|
||||||
|
공유합니다. 소비자마다 따로 해석하면 저장소 카드는 와일드카드가 되고 쿠키 카드는 정확 일치만
|
||||||
|
되는 식으로 갈라집니다.
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 전역 진입점
|
## 전역 진입점
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
| `banner_enabled` | `boolean` | `true` | 쿠키 배너 노출 |
|
| `banner_enabled` | `boolean` | `true` | 쿠키 배너 노출 |
|
||||||
| `banner_position` | `string` | `bottom_bar` | 배너 위치 |
|
| `banner_position` | `string` | `bottom_bar` | 배너 위치 |
|
||||||
| `blocked_domains` | `json` | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` | 추적 도메인 차단 목록 |
|
| `blocked_domains` | `json` | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` | 추적 도메인 차단 목록 |
|
||||||
|
| `necessary_storage_allowlist` | `array` | `{"localStorage":["g7_locale","g7_color_scheme","g7_cache_version","g7_asset_url_mode*","g7_cart_key","g7-devtools-panel","g7_guest_order_token","g7_guest_order_number","g7_guest_order_expires_at","g7_devtools_*","g7_filters_*","g7_columns_*","g7_order_*","g7_admin_sidebar_collapsed","g7_filter_visibility_*","g7_dismissed_warnings","g7le.*","__sirsoftKginicisMobilePaymentReturnPending","g7.identity.redirectStash","sirsoft-verification_nhnkcp.formStash"],"sessionStorage":["g7:sirsoft-pay_kginicis:pendingClose","g7:sirsoft-pay_nhnkcp:pendingClose","g7:sirsoft-tosspayments:pendingClose","g7.identity.redirectStash","sirsoft-verification_nhnkcp.formStash","__sirsoftKginicisMobilePaymentReturnPending","g7le.*","g7_devtools_*","g7_filters_*","g7_columns_*","g7_order_*","g7_filter_visibility_*"],"cookie":["laravel_maintenance"]}` | 필수 저장 항목 허용목록 |
|
||||||
| `cookie_categories` | `json` | `[]` | 쿠키 카테고리 정의 |
|
| `cookie_categories` | `json` | `[]` | 쿠키 카테고리 정의 |
|
||||||
|
|
||||||
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
|
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
|
||||||
@@ -25,6 +26,22 @@
|
|||||||
동기화가 필요합니다, §AGENTS.md 수정 시 동반 의무). `cookie_categories` 가 기본값 `[]` 로
|
동기화가 필요합니다, §AGENTS.md 수정 시 동반 의무). `cookie_categories` 가 기본값 `[]` 로
|
||||||
비어 있는 것은 4대 표준 카테고리(필수/기능/분석/마케팅)가 이미 코드/Enum(`CookieCategory`)에
|
비어 있는 것은 4대 표준 카테고리(필수/기능/분석/마케팅)가 이미 코드/Enum(`CookieCategory`)에
|
||||||
고정돼 있어, 이 설정은 그 표준을 벗어나는 **추가** 카테고리를 위한 자리이기 때문입니다.
|
고정돼 있어, 이 설정은 그 표준을 벗어나는 **추가** 카테고리를 위한 자리이기 때문입니다.
|
||||||
|
|
||||||
|
`necessary_storage_allowlist` 는 기능 쿠키에 동의하지 않은 방문자에게도 저장이 허용되는 항목
|
||||||
|
목록입니다. 위 표의 기본값은 **신규 설치 시 시드되는 출하 카탈로그**일 뿐이고, 실제 판정에는
|
||||||
|
저장된 운영자 값이 쓰입니다 — 관리자 환경설정의 「필수 저장 항목」 카드에서 저장소 구분
|
||||||
|
(`localStorage` / `sessionStorage` / `cookie`)별로 편집합니다. 항목 끝의 `*` 는 앞부분 매칭이며
|
||||||
|
(`g7_filters_*` 는 `g7_filters_orders_1` 을 포함), `*` 는 끝에만 쓸 수 있습니다. 이 구조 덕분에
|
||||||
|
새로 설치한 확장이 저장하는 항목을 이 플러그인 수정 없이 운영자가 직접 추가할 수 있습니다.
|
||||||
|
|
||||||
|
목록 밖의 항목은 방문마다 파기되는데 그 파기는 예외도 로그도 남기지 않습니다 — 운영자에게는
|
||||||
|
"설정이 저장되지 않는다" 는 증상만 보입니다. 그래서 화면의 안내 박스가 이 사실과 항목 이름을
|
||||||
|
찾는 방법을 함께 설명합니다.
|
||||||
|
|
||||||
|
`necessary_storage_locked`(`auth_token` · `XSRF-TOKEN` · 세션 쿠키 · `gdpr_session`)는 **설정이
|
||||||
|
아닙니다.** 스키마에 없으므로 저장 요청에 담아도 반영되지 않고, 코드가 판정 시점에 운영자
|
||||||
|
목록과 합쳐 적용합니다. 없으면 사이트가 서지 못하는 항목이라 지울 수 있으면 안 되기 때문이며,
|
||||||
|
세션 쿠키 이름은 `session.cookie` 설정에서 런타임 해석됩니다.
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 권한
|
## 권한
|
||||||
|
|||||||
@@ -71,6 +71,32 @@
|
|||||||
"connect.facebook.net",
|
"connect.facebook.net",
|
||||||
"www.googleadservices.com"
|
"www.googleadservices.com"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"default_necessary_allowlist_preview": {
|
||||||
|
"localStorage": [
|
||||||
|
"g7_locale",
|
||||||
|
"g7_color_scheme",
|
||||||
|
"g7_filters_*"
|
||||||
|
],
|
||||||
|
"sessionStorage": [
|
||||||
|
"g7:sirsoft-pay_kginicis:pendingClose"
|
||||||
|
],
|
||||||
|
"cookie": [
|
||||||
|
"laravel_maintenance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"necessary_storage_locked": {
|
||||||
|
"localStorage": [
|
||||||
|
"auth_token"
|
||||||
|
],
|
||||||
|
"sessionStorage": [],
|
||||||
|
"cookie": [
|
||||||
|
"XSRF-TOKEN",
|
||||||
|
"laravel_session",
|
||||||
|
"gdpr_session"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -222,8 +248,8 @@
|
|||||||
"en": "Strictly Necessary"
|
"en": "Strictly Necessary"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
"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."
|
"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 +260,8 @@
|
|||||||
"en": "Functional"
|
"en": "Functional"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
||||||
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
|
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -397,7 +423,7 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"consent_key": "cookie_necessary",
|
"consent_key": "cookie_necessary",
|
||||||
"consent_label": "필수 쿠키",
|
"consent_label": "필수 쿠키",
|
||||||
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
||||||
"consent_category": "cookie",
|
"consent_category": "cookie",
|
||||||
"is_required": true,
|
"is_required": true,
|
||||||
"is_consented": true,
|
"is_consented": true,
|
||||||
@@ -416,7 +442,7 @@
|
|||||||
"id": 2,
|
"id": 2,
|
||||||
"consent_key": "cookie_functional",
|
"consent_key": "cookie_functional",
|
||||||
"consent_label": "기능 쿠키",
|
"consent_label": "기능 쿠키",
|
||||||
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
||||||
"consent_category": "cookie",
|
"consent_category": "cookie",
|
||||||
"is_required": false,
|
"is_required": false,
|
||||||
"is_consented": true,
|
"is_consented": true,
|
||||||
@@ -478,7 +504,7 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"consent_key": "cookie_necessary",
|
"consent_key": "cookie_necessary",
|
||||||
"consent_label": "필수 쿠키",
|
"consent_label": "필수 쿠키",
|
||||||
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
|
||||||
"consent_category": "cookie",
|
"consent_category": "cookie",
|
||||||
"is_required": true,
|
"is_required": true,
|
||||||
"is_consented": true,
|
"is_consented": true,
|
||||||
@@ -497,7 +523,7 @@
|
|||||||
"id": 2,
|
"id": 2,
|
||||||
"consent_key": "cookie_functional",
|
"consent_key": "cookie_functional",
|
||||||
"consent_label": "기능 쿠키",
|
"consent_label": "기능 쿠키",
|
||||||
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
|
||||||
"consent_category": "cookie",
|
"consent_category": "cookie",
|
||||||
"is_required": false,
|
"is_required": false,
|
||||||
"is_consented": true,
|
"is_consented": true,
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ return [
|
|||||||
'too_long_marketing' => 'Marketing — Domain cannot exceed 253 characters.',
|
'too_long_marketing' => 'Marketing — Domain cannot exceed 253 characters.',
|
||||||
'must_be_array' => 'Each category must be an array of domain strings.',
|
'must_be_array' => 'Each category must be an array of domain strings.',
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'invalid_format_local_storage' => 'Local storage — invalid item format. (Letters, digits and _ . : @ + - ; a single trailing * is allowed)',
|
||||||
|
'invalid_format_session_storage' => 'Session storage — invalid item format. (Letters, digits and _ . : @ + - ; a single trailing * is allowed)',
|
||||||
|
'invalid_format_cookie' => 'Cookie — invalid item format. (Letters, digits and _ . : @ + - ; a single trailing * is allowed)',
|
||||||
|
'too_long_local_storage' => 'Local storage — item cannot exceed 128 characters.',
|
||||||
|
'too_long_session_storage' => 'Session storage — item cannot exceed 128 characters.',
|
||||||
|
'too_long_cookie' => 'Cookie — item cannot exceed 128 characters.',
|
||||||
|
'must_be_array' => 'Each storage scope must be an array of item names.',
|
||||||
|
'invalid_scope' => 'Unknown storage scope: :scope (only local storage, session storage and cookies are allowed)',
|
||||||
|
],
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'saved' => 'Settings have been saved.',
|
'saved' => 'Settings have been saved.',
|
||||||
'title' => 'GDPR Settings',
|
'title' => 'GDPR Settings',
|
||||||
@@ -42,6 +52,7 @@ return [
|
|||||||
'cookie_categories' => 'Cookie Categories',
|
'cookie_categories' => 'Cookie Categories',
|
||||||
'cookie_policy_version' => 'Cookie Policy Version',
|
'cookie_policy_version' => 'Cookie Policy Version',
|
||||||
'auto_blocking' => 'Auto-blocking Policy',
|
'auto_blocking' => 'Auto-blocking Policy',
|
||||||
|
'necessary_storage' => 'Strictly Necessary Storage',
|
||||||
'auto_blocking_desc' => 'When the Cookie Banner is ON, external tracking resources matching the blocked-domain lists below are auto-blocked until the user consents. (No separate toggle — runs automatically with the banner.)',
|
'auto_blocking_desc' => 'When the Cookie Banner is ON, external tracking resources matching the blocked-domain lists below are auto-blocked until the user consents. (No separate toggle — runs automatically with the banner.)',
|
||||||
],
|
],
|
||||||
'nav' => [
|
'nav' => [
|
||||||
@@ -102,6 +113,9 @@ return [
|
|||||||
'marketing' => 'Marketing — Blocked Domains',
|
'marketing' => 'Marketing — Blocked Domains',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'label' => 'Strictly Necessary Storage Allowlist',
|
||||||
|
],
|
||||||
'cookie_categories' => [
|
'cookie_categories' => [
|
||||||
'description' => 'Open each card\'s [Show details] to see the purpose of the four cookie categories and the blocked-tool examples.',
|
'description' => 'Open each card\'s [Show details] to see the purpose of the four cookie categories and the blocked-tool examples.',
|
||||||
'category_required_badge' => 'Required',
|
'category_required_badge' => 'Required',
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ return [
|
|||||||
'too_long_marketing' => '마케팅 카테고리 — 도메인은 253자를 초과할 수 없습니다.',
|
'too_long_marketing' => '마케팅 카테고리 — 도메인은 253자를 초과할 수 없습니다.',
|
||||||
'must_be_array' => '카테고리별 도메인 목록은 배열이어야 합니다.',
|
'must_be_array' => '카테고리별 도메인 목록은 배열이어야 합니다.',
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'invalid_format_local_storage' => '브라우저 저장소 — 항목 형식이 올바르지 않습니다. (영문·숫자와 _ . : @ + - 사용, 끝에 * 하나만 가능)',
|
||||||
|
'invalid_format_session_storage' => '세션 저장소 — 항목 형식이 올바르지 않습니다. (영문·숫자와 _ . : @ + - 사용, 끝에 * 하나만 가능)',
|
||||||
|
'invalid_format_cookie' => '쿠키 — 항목 형식이 올바르지 않습니다. (영문·숫자와 _ . : @ + - 사용, 끝에 * 하나만 가능)',
|
||||||
|
'too_long_local_storage' => '브라우저 저장소 — 항목은 128자를 초과할 수 없습니다.',
|
||||||
|
'too_long_session_storage' => '세션 저장소 — 항목은 128자를 초과할 수 없습니다.',
|
||||||
|
'too_long_cookie' => '쿠키 — 항목은 128자를 초과할 수 없습니다.',
|
||||||
|
'must_be_array' => '저장소별 항목 목록은 배열이어야 합니다.',
|
||||||
|
'invalid_scope' => '알 수 없는 저장소 구분입니다: :scope (브라우저 저장소·세션 저장소·쿠키만 사용할 수 있습니다)',
|
||||||
|
],
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'saved' => '설정이 저장되었습니다.',
|
'saved' => '설정이 저장되었습니다.',
|
||||||
'title' => 'GDPR 설정',
|
'title' => 'GDPR 설정',
|
||||||
@@ -42,6 +52,7 @@ return [
|
|||||||
'cookie_categories' => '쿠키 카테고리',
|
'cookie_categories' => '쿠키 카테고리',
|
||||||
'cookie_policy_version' => '쿠키 정책 버전',
|
'cookie_policy_version' => '쿠키 정책 버전',
|
||||||
'auto_blocking' => '자동 차단 정책',
|
'auto_blocking' => '자동 차단 정책',
|
||||||
|
'necessary_storage' => '필수 저장 항목',
|
||||||
'auto_blocking_desc' => '쿠키 배너 노출이 ON 일 때 아래 카테고리별 차단 도메인 목록의 외부 추적 리소스가 사용자 동의 전까지 자동 차단됩니다. (별도 토글 없음 — 배너 노출과 함께 자동 작동)',
|
'auto_blocking_desc' => '쿠키 배너 노출이 ON 일 때 아래 카테고리별 차단 도메인 목록의 외부 추적 리소스가 사용자 동의 전까지 자동 차단됩니다. (별도 토글 없음 — 배너 노출과 함께 자동 작동)',
|
||||||
],
|
],
|
||||||
'nav' => [
|
'nav' => [
|
||||||
@@ -102,6 +113,9 @@ return [
|
|||||||
'marketing' => '마케팅 카테고리 차단 도메인',
|
'marketing' => '마케팅 카테고리 차단 도메인',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'label' => '필수 저장 항목 허용목록',
|
||||||
|
],
|
||||||
'cookie_categories' => [
|
'cookie_categories' => [
|
||||||
'description' => '쿠키 카테고리 4종의 용도와 차단 도구 예시는 각 카드의 [정보 펼치기] 에서 확인할 수 있습니다.',
|
'description' => '쿠키 카테고리 4종의 용도와 차단 도구 예시는 각 카드의 [정보 펼치기] 에서 확인할 수 있습니다.',
|
||||||
'category_required_badge' => '필수',
|
'category_required_badge' => '필수',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthConsentListener;
|
|||||||
use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener;
|
use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener;
|
||||||
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener;
|
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener;
|
||||||
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener;
|
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener;
|
||||||
|
use Plugins\Sirsoft\Gdpr\Support\NecessaryAllowlist;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GDPR (일반 데이터 보호 규정) 플러그인
|
* GDPR (일반 데이터 보호 규정) 플러그인
|
||||||
@@ -22,16 +23,102 @@ use Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener;
|
|||||||
*/
|
*/
|
||||||
class Plugin extends AbstractPlugin
|
class Plugin extends AbstractPlugin
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* strictly necessary 허용목록 출하 기본 카탈로그.
|
||||||
|
*
|
||||||
|
* 이 상수는 **판정 목록이 아니라 출하 기본값**입니다. 실제 판정은 운영자 설정
|
||||||
|
* `necessary_storage_allowlist` 가 하며, 이 카탈로그는 신규 설치 시 그 설정의 기본값과
|
||||||
|
* 관리자 화면 추천 목록(`default_necessary_allowlist_preview`)으로만 쓰입니다.
|
||||||
|
*
|
||||||
|
* 그래서 새 확장이 저장 키를 도입해도 이 플러그인을 고칠 필요가 없습니다 — 운영자가
|
||||||
|
* 관리자 화면에서 직접 추가합니다.
|
||||||
|
*
|
||||||
|
* 표기 규칙: 끝의 `*` 는 앞부분 매칭, 없으면 정확 일치.
|
||||||
|
*
|
||||||
|
* 잠금 항목(`auth_token` / `XSRF-TOKEN` / 세션 쿠키 / `gdpr_session`)은 여기 넣지
|
||||||
|
* 않습니다 — 설정에 담기면 API 로 지울 수 있어 잠금이 아니게 됩니다
|
||||||
|
* (Support\NecessaryAllowlist::locked()).
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public const DEFAULT_NECESSARY_ALLOWLIST_CATALOG = [
|
||||||
|
// 저장소(localStorage) — 사용자 명시 선택 · 구매 동선 · 관리자 화면 상태 · 복귀 기록
|
||||||
|
'localStorage' => [
|
||||||
|
// 사용자 명시 선택 (WP29 §3.6) — 다국어 설정과 화면 테마.
|
||||||
|
// 테마가 목록에서 빠져 있는 동안에는 테마를 바꿔도 새로고침하면 되돌아갔다 (dev-g7#640).
|
||||||
|
'g7_locale',
|
||||||
|
'g7_color_scheme',
|
||||||
|
// 코어 캐시 버전 — 운영자가 의도적 갱신 시 사용
|
||||||
|
'g7_cache_version',
|
||||||
|
// 자산 URL 형식 판정 캐시 — 사용자 정보가 아니라 서버 능력 판정 결과다.
|
||||||
|
// 파기되면 재방문마다 기본 형식으로 첫 자산 요청을 보내 404 를 겪는다. 키에 캐시
|
||||||
|
// 버전이 붙으므로 앞부분 매칭으로 등재한다.
|
||||||
|
'g7_asset_url_mode*',
|
||||||
|
// 장바구니 게스트 키 — 익명 카트 식별 (구매 동선 필수)
|
||||||
|
'g7_cart_key',
|
||||||
|
// devtools UI 상태
|
||||||
|
'g7-devtools-panel',
|
||||||
|
// 비회원 주문 조회 — 주문 이행에 필요 (Art.6(1)(b))
|
||||||
|
'g7_guest_order_token',
|
||||||
|
'g7_guest_order_number',
|
||||||
|
'g7_guest_order_expires_at',
|
||||||
|
// 관리자 화면 상태 (필터/정렬/컬럼/devtools) — 사용자 의사로 조작
|
||||||
|
'g7_devtools_*',
|
||||||
|
'g7_filters_*',
|
||||||
|
'g7_columns_*',
|
||||||
|
'g7_order_*',
|
||||||
|
// 관리자 화면 표시 설정 — 테마와 같은 범주. `g7_filters_` 는 필터 '값' 이고
|
||||||
|
// `g7_filter_visibility_` 는 필터 '표시 여부' 라 접두사가 서로 덮지 않는다.
|
||||||
|
'g7_admin_sidebar_collapsed',
|
||||||
|
'g7_filter_visibility_*',
|
||||||
|
'g7_dismissed_warnings',
|
||||||
|
// 레이아웃 편집기 작업 상태 (라우트 트리 접힘) — 운영자 편집 동선 유지
|
||||||
|
'g7le.*',
|
||||||
|
// 결제·본인인증 복귀 기록 — 실제 쓰기는 sessionStorage 지만, 이관 전 목록이 저장소
|
||||||
|
// 종류를 가리지 않았으므로 판정 범위를 좁히지 않는다.
|
||||||
|
'__sirsoftKginicisMobilePaymentReturnPending',
|
||||||
|
'g7.identity.redirectStash',
|
||||||
|
'sirsoft-verification_nhnkcp.formStash',
|
||||||
|
],
|
||||||
|
// 세션 저장소(sessionStorage) — 탭 수명 동안의 복귀·작업 상태
|
||||||
|
'sessionStorage' => [
|
||||||
|
// 결제 진행 기록 — 결제창 복귀 시 종료·실패 사유 보고에 필요 (Art.6(1)(b)).
|
||||||
|
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로, 부팅 시 파기되면 그 보고가 통째로 누락된다.
|
||||||
|
'g7:sirsoft-pay_kginicis:pendingClose',
|
||||||
|
'g7:sirsoft-pay_nhnkcp:pendingClose',
|
||||||
|
'g7:sirsoft-tosspayments:pendingClose',
|
||||||
|
// 본인인증 복귀 — 인증창에서 돌아왔을 때 원래 화면·입력 내용 복원에 필요
|
||||||
|
'g7.identity.redirectStash',
|
||||||
|
'sirsoft-verification_nhnkcp.formStash',
|
||||||
|
// 결제창 복귀 대기 표식
|
||||||
|
'__sirsoftKginicisMobilePaymentReturnPending',
|
||||||
|
// 레이아웃 편집기 클립보드 — 같은 탭에서 다른 레이아웃으로 붙여넣기
|
||||||
|
'g7le.*',
|
||||||
|
// 관리자 화면 상태 — 저장소 종류를 가리지 않던 이관 전 판정 범위를 그대로 옮긴다
|
||||||
|
'g7_devtools_*',
|
||||||
|
'g7_filters_*',
|
||||||
|
'g7_columns_*',
|
||||||
|
'g7_order_*',
|
||||||
|
'g7_filter_visibility_*',
|
||||||
|
],
|
||||||
|
// 쿠키 — 클라이언트 쓰기 + 서버 Set-Cookie 공통 목록
|
||||||
|
'cookie' => [
|
||||||
|
// 유지보수 모드 우회 (운영자 전용)
|
||||||
|
'laravel_maintenance',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 차단 도메인 카탈로그 (BE SSoT).
|
* 기본 차단 도메인 카탈로그 (BE SSoT).
|
||||||
*
|
*
|
||||||
* 신규 설치 시 `blocked_domains` 기본값 + `GdprSettingsController` 응답
|
* 신규 설치 시 `blocked_domains` 기본값 + `GdprSettingsController` 응답
|
||||||
* `default_blocked_domains_preview` 의 데이터 출처입니다.
|
* `default_blocked_domains_preview` 의 데이터 출처입니다.
|
||||||
*
|
*
|
||||||
* SSoT 동기 의무: 본 상수는 클라이언트 코드 상수 `resources/js/blocker-domains.ts`
|
* 본 상수가 유일한 정본입니다. `resources/js/blocker-domains.ts` 의
|
||||||
* 의 `DEFAULT_BLOCKED_DOMAINS` 와 동일하게 유지합니다. 한쪽 갱신 시 다른 쪽도
|
* `DEFAULT_BLOCKED_DOMAINS` 는 런타임 소비처가 없어 번들에서 tree-shake 되며
|
||||||
* 같은 PR 에서 갱신해야 하며, 어긋날 경우 신규 설치 사이트와 관리자 UI 추천
|
* (그 파일 헤더의 `audit:allow` 참조) 내용도 이 카탈로그와 다릅니다 — 두 목록을
|
||||||
* 도메인이 불일치합니다.
|
* "동일하게 유지" 하지 않습니다. 신규 설치 기본값과 관리자 UI 추천 도메인은
|
||||||
|
* 모두 이 상수에서 나옵니다.
|
||||||
*
|
*
|
||||||
* @var array<string, array<int, string>>
|
* @var array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
@@ -402,28 +489,39 @@ class Plugin extends AbstractPlugin
|
|||||||
// 가져오기)" 링크가 클라이언트 측 Set 합집합으로 처리.
|
// 가져오기)" 링크가 클라이언트 측 Set 합집합으로 처리.
|
||||||
'blocked_domains' => self::DEFAULT_BLOCKED_DOMAINS_CATALOG,
|
'blocked_domains' => self::DEFAULT_BLOCKED_DOMAINS_CATALOG,
|
||||||
|
|
||||||
|
// 필수 저장 항목 허용목록 — 운영자가 관리자 화면에서 편집하는 설정.
|
||||||
|
// 여기 값은 **신규 설치 시드용 기본값**이며, 판정에 실제로 쓰이는 값은 저장된 설정이다.
|
||||||
|
// 배열 그대로 둔다: 코어는 설정값을 json_decode 하지 않으므로 문자열로 선언하면
|
||||||
|
// 브라우저 측 판독기가 그 값을 조용히 버린다.
|
||||||
|
'necessary_storage_allowlist' => self::DEFAULT_NECESSARY_ALLOWLIST_CATALOG,
|
||||||
|
|
||||||
|
// 잠금 항목 — 설정이 아니라 코드가 정한다. 스키마에 넣지 않으므로 저장 요청에
|
||||||
|
// 섞여 와도 validated() 에서 배제되어 저장되지 않는다. 화면에는 읽기 전용으로
|
||||||
|
// 표시하고, 판정은 언제나 '운영자 목록 ∪ 이 집합' 이다.
|
||||||
|
'necessary_storage_locked' => NecessaryAllowlist::locked(),
|
||||||
|
|
||||||
'cookie_categories' => json_encode([
|
'cookie_categories' => json_encode([
|
||||||
[
|
[
|
||||||
'key' => 'necessary',
|
'key' => 'necessary',
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
|
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
|
||||||
'description' => [
|
'description' => [
|
||||||
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
|
// g7_locale·g7_color_scheme 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
|
||||||
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
|
// 예외 (사용자가 화면에서 직접 고른 표시 환경) 로 strictly necessary 분류. 사용자 안내에 명시.
|
||||||
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
|
'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.',
|
'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분류 체계 부합.
|
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
|
||||||
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
|
// 자체 functional 키 (표시 통화 등) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
|
||||||
// Phase 2 에서 실제 게이팅 (Storage.prototype 가로채기 + cookie 가로채기 + Set-Cookie 미들웨어) 구현 예정.
|
// Phase 2 에서 실제 게이팅 (Storage.prototype 가로채기 + cookie 가로채기 + Set-Cookie 미들웨어) 구현 예정.
|
||||||
'key' => 'functional',
|
'key' => 'functional',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
|
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
|
||||||
'description' => [
|
'description' => [
|
||||||
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
|
'ko' => '사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
|
||||||
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
|
'en' => 'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -526,6 +624,19 @@ class Plugin extends AbstractPlugin
|
|||||||
],
|
],
|
||||||
'required' => false,
|
'required' => false,
|
||||||
],
|
],
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
// json 이 아니라 array 로 선언한다 — 코어 설정 검증 규칙 생성기는 json 타입을
|
||||||
|
// 모르므로 nullable 만 붙고 항목 형식이 전혀 검사되지 않는다.
|
||||||
|
// 항목 형식·스코프 화이트리스트는 UpdateAdminSettingsRequest 가 검증한다.
|
||||||
|
'type' => 'array',
|
||||||
|
'default' => self::DEFAULT_NECESSARY_ALLOWLIST_CATALOG,
|
||||||
|
'label' => ['ko' => '필수 저장 항목 허용목록', 'en' => 'Strictly Necessary Storage Allowlist'],
|
||||||
|
'hint' => [
|
||||||
|
'ko' => '기능 쿠키 미동의 상태에서도 저장이 허용되는 항목(브라우저 저장소·세션 저장소·쿠키). 끝에 * 를 붙이면 앞부분이 같은 항목을 모두 포함합니다.',
|
||||||
|
'en' => 'Items allowed to persist even without functional consent (local storage, session storage, cookies). A trailing * matches every name with that prefix.',
|
||||||
|
],
|
||||||
|
'required' => false,
|
||||||
|
],
|
||||||
'cookie_categories' => [
|
'cookie_categories' => [
|
||||||
'type' => 'json',
|
'type' => 'json',
|
||||||
'default' => '[]',
|
'default' => '[]',
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* cookieInterceptor 단위 테스트 (Phase 2 단순화).
|
* cookieInterceptor 단위 테스트 (Phase 2 단순화).
|
||||||
*
|
*
|
||||||
* Document.prototype.cookie setter 가로채기 + 4단계 게이팅 규칙 검증:
|
* Document.prototype.cookie setter 가로채기 + 게이팅 규칙 검증:
|
||||||
* 1. cleared cookie (Max-Age=0 / expires 과거) → 항상 통과 (§117 충돌 회피)
|
* 1. cleared cookie (Max-Age=0 / expires 과거) → 항상 통과 (§117 충돌 회피)
|
||||||
* 2. strictly necessary allowlist → 통과
|
* 2. strictly necessary 허용목록 → 통과 (와일드카드 포함)
|
||||||
* 3. functional 동의 → 통과
|
* 3. functional 동의 → 통과
|
||||||
* 4. user-initiated (WP29 §3.6) 면제 → 사용자 인터랙션 직후 통과
|
* 4. user-initiated (WP29 §3.6) 면제 → 사용자 인터랙션 직후 통과
|
||||||
* 5. 그 외 → 차단
|
* 5. 그 외 → 차단
|
||||||
*
|
*
|
||||||
* "운영자 등록 표" 는 제거됨 (Phase 2 단순화).
|
* 허용목록은 운영자 설정이므로 목록을 주입해 검증한다.
|
||||||
*
|
*
|
||||||
* 주의: jsdom 의 document.cookie 는 navigation 별 상태 — 테스트마다 clean 처리.
|
* 주의: jsdom 의 document.cookie 는 navigation 별 상태 — 테스트마다 clean 처리.
|
||||||
|
*
|
||||||
|
* @scenario scope=cookie, notation=wildcard, locked=operator_item, settings_state=populated, request=valid_item
|
||||||
|
* @effects cookie_allows_item_listed_in_settings, cookie_blocks_item_removed_from_settings, cookie_wildcard_matches_prefix_only, cookie_scope_does_not_borrow_storage_scope, cookie_config_update_applies_new_allowlist
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
|
||||||
installCookieInterceptor,
|
installCookieInterceptor,
|
||||||
isCookieAllowed,
|
isCookieAllowed,
|
||||||
uninstallCookieInterceptor,
|
uninstallCookieInterceptor,
|
||||||
updateCookieInterceptorConfig,
|
updateCookieInterceptorConfig,
|
||||||
} from '../cookieInterceptor';
|
} from '../cookieInterceptor';
|
||||||
|
import { LOCKED_FALLBACK, type NecessaryAllowlist } from '../necessaryAllowlist';
|
||||||
import { __setLastInteractionForTest, installUserInitiatedTracker, uninstallUserInitiatedTracker } from '../userInitiatedTracker';
|
import { __setLastInteractionForTest, installUserInitiatedTracker, uninstallUserInitiatedTracker } from '../userInitiatedTracker';
|
||||||
|
|
||||||
/** document.cookie 에서 특정 이름의 값 추출 (없으면 null) */
|
/** document.cookie 에서 특정 이름의 값 추출 (없으면 null) */
|
||||||
@@ -42,6 +45,20 @@ function clearAllCookies(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자 설정 + 잠금 집합을 흉내 낸 허용목록.
|
||||||
|
*
|
||||||
|
* @param overrides 스코프별 덮어쓸 목록
|
||||||
|
* @return 허용목록
|
||||||
|
*/
|
||||||
|
function allowlist(overrides: Partial<NecessaryAllowlist> = {}): NecessaryAllowlist {
|
||||||
|
return {
|
||||||
|
localStorage: overrides.localStorage ?? [...LOCKED_FALLBACK.localStorage],
|
||||||
|
sessionStorage: overrides.sessionStorage ?? [...LOCKED_FALLBACK.sessionStorage],
|
||||||
|
cookie: overrides.cookie ?? [...LOCKED_FALLBACK.cookie],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('cookieInterceptor', () => {
|
describe('cookieInterceptor', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installUserInitiatedTracker();
|
installUserInitiatedTracker();
|
||||||
@@ -54,20 +71,72 @@ describe('cookieInterceptor', () => {
|
|||||||
clearAllCookies();
|
clearAllCookies();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strictly necessary cookie (XSRF-TOKEN) → 항상 통과', () => {
|
it('잠금 cookie (XSRF-TOKEN) → 항상 통과', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
document.cookie = 'XSRF-TOKEN=abc; path=/';
|
document.cookie = 'XSRF-TOKEN=abc; path=/';
|
||||||
expect(getCookieValue('XSRF-TOKEN')).toBe('abc');
|
expect(getCookieValue('XSRF-TOKEN')).toBe('abc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('설정에 등재한 cookie 는 통과한다', () => {
|
||||||
|
installCookieInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ cookie: ['laravel_maintenance'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.cookie = 'laravel_maintenance=on; path=/';
|
||||||
|
expect(getCookieValue('laravel_maintenance')).toBe('on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('설정에서 뺀 cookie 는 차단된다 (목록이 판정에 쓰인다는 증거)', () => {
|
||||||
|
installCookieInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ cookie: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.cookie = 'laravel_maintenance=on; path=/';
|
||||||
|
expect(getCookieValue('laravel_maintenance')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 발견 ②: 쿠키 목록만 `includes()` 정확 일치 전용이라 운영자가 쿠키 카드에 적은
|
||||||
|
// `myplugin_*` 이 저장소 카드와 달리 아무 효과가 없었다.
|
||||||
|
it('cookie 도 와일드카드로 통과한다 (저장소와 동일 규칙)', () => {
|
||||||
|
installCookieInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ cookie: ['myplugin_*'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.cookie = 'myplugin_state=value; path=/';
|
||||||
|
expect(getCookieValue('myplugin_state')).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('와일드카드는 앞부분만 매칭한다 — 접두사가 다르면 차단', () => {
|
||||||
|
installCookieInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ cookie: ['myplugin_*'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.cookie = 'other_myplugin_state=value; path=/';
|
||||||
|
expect(getCookieValue('other_myplugin_state')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('저장소 스코프에만 등재한 이름은 cookie 로 통과하지 않는다', () => {
|
||||||
|
installCookieInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: ['scoped_name'], cookie: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.cookie = 'scoped_name=value; path=/';
|
||||||
|
expect(getCookieValue('scoped_name')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('functional 동의 시 모든 cookie 통과', () => {
|
it('functional 동의 시 모든 cookie 통과', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: true,
|
functionalConsented: true,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
document.cookie = 'app_pref=value; path=/';
|
document.cookie = 'app_pref=value; path=/';
|
||||||
@@ -77,7 +146,7 @@ describe('cookieInterceptor', () => {
|
|||||||
it('미동의 + user-initiated (WP29 §3.6) → 통과', () => {
|
it('미동의 + user-initiated (WP29 §3.6) → 통과', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
__setLastInteractionForTest(Date.now());
|
__setLastInteractionForTest(Date.now());
|
||||||
@@ -88,7 +157,7 @@ describe('cookieInterceptor', () => {
|
|||||||
it('미동의 + 비-사용자 → 차단', () => {
|
it('미동의 + 비-사용자 → 차단', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// user-initiated 미발생
|
// user-initiated 미발생
|
||||||
@@ -99,7 +168,7 @@ describe('cookieInterceptor', () => {
|
|||||||
it('미동의 + 임의 cookie + 비-사용자 → 차단', () => {
|
it('미동의 + 임의 cookie + 비-사용자 → 차단', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
document.cookie = 'random_cookie=value; path=/';
|
document.cookie = 'random_cookie=value; path=/';
|
||||||
@@ -109,7 +178,7 @@ describe('cookieInterceptor', () => {
|
|||||||
it('파싱 불가 cookie 문자열 → 차단 (보수적)', () => {
|
it('파싱 불가 cookie 문자열 → 차단 (보수적)', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: true,
|
functionalConsented: true,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
document.cookie = 'malformed_no_equals';
|
document.cookie = 'malformed_no_equals';
|
||||||
@@ -120,7 +189,7 @@ describe('cookieInterceptor', () => {
|
|||||||
// 미동의 상태에서도 파기 cookie 발송은 허용
|
// 미동의 상태에서도 파기 cookie 발송은 허용
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 사전 저장된 cookie (uninstall 상태에서 미리 설정)
|
// 사전 저장된 cookie (uninstall 상태에서 미리 설정)
|
||||||
@@ -132,7 +201,7 @@ describe('cookieInterceptor', () => {
|
|||||||
it('updateCookieInterceptorConfig 으로 동의 갱신 → 후속 쓰기 통과', () => {
|
it('updateCookieInterceptorConfig 으로 동의 갱신 → 후속 쓰기 통과', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 미동의 + 비-사용자 → 차단
|
// 미동의 + 비-사용자 → 차단
|
||||||
@@ -142,16 +211,33 @@ describe('cookieInterceptor', () => {
|
|||||||
// 동의 갱신
|
// 동의 갱신
|
||||||
updateCookieInterceptorConfig({
|
updateCookieInterceptorConfig({
|
||||||
functionalConsented: true,
|
functionalConsented: true,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
document.cookie = 'app_pref=v2; path=/';
|
document.cookie = 'app_pref=v2; path=/';
|
||||||
expect(getCookieValue('app_pref')).toBe('v2');
|
expect(getCookieValue('app_pref')).toBe('v2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updateCookieInterceptorConfig 으로 허용목록 갱신이 판정에 즉시 반영된다', () => {
|
||||||
|
installCookieInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ cookie: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.cookie = 'operator_added=v1; path=/';
|
||||||
|
expect(getCookieValue('operator_added')).toBeNull();
|
||||||
|
|
||||||
|
updateCookieInterceptorConfig({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ cookie: ['operator_added'] }),
|
||||||
|
});
|
||||||
|
document.cookie = 'operator_added=v2; path=/';
|
||||||
|
expect(getCookieValue('operator_added')).toBe('v2');
|
||||||
|
});
|
||||||
|
|
||||||
it('uninstall 후 원본 setter 복원 — 모든 쓰기 통과', () => {
|
it('uninstall 후 원본 setter 복원 — 모든 쓰기 통과', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
document.cookie = 'random=v1; path=/';
|
document.cookie = 'random=v1; path=/';
|
||||||
@@ -165,7 +251,7 @@ describe('cookieInterceptor', () => {
|
|||||||
it('isCookieAllowed — 정책 평가 함수는 사이드 이펙트 없음', () => {
|
it('isCookieAllowed — 정책 평가 함수는 사이드 이펙트 없음', () => {
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
__setLastInteractionForTest(Date.now());
|
__setLastInteractionForTest(Date.now());
|
||||||
@@ -182,4 +268,4 @@ describe('cookieInterceptor', () => {
|
|||||||
// 함수 호출만으로 cookie 변경 X
|
// 함수 호출만으로 cookie 변경 X
|
||||||
expect(getCookieValue('app_pref')).toBeNull();
|
expect(getCookieValue('app_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* functionalCleaner 단위 테스트 (Phase 2 단순화).
|
* functionalCleaner 단위 테스트 (Phase 2 단순화).
|
||||||
*
|
*
|
||||||
* cleanup 동작 검증 — strictly necessary allowlist 외 모든 1st-party 저장소 파기:
|
* cleanup 동작 검증 — strictly necessary 허용목록 외 모든 1st-party 저장소 파기:
|
||||||
* - allowlist 외 모든 localStorage / sessionStorage 키 removeItem
|
* - 허용목록 외 모든 localStorage / sessionStorage 키 removeItem
|
||||||
* - allowlist 외 모든 cookie Max-Age=0 파기
|
* - 허용목록 외 모든 cookie Max-Age=0 파기
|
||||||
* - allowlist 키/cookie 는 보존
|
* - 허용목록 항목은 보존 (와일드카드 포함)
|
||||||
|
* - **허용목록을 못 읽어도 잠금 항목은 보존** (설정 미도달 시 안전 기본값)
|
||||||
|
*
|
||||||
|
* 허용목록은 운영자 설정이므로 이 테스트는 목록을 주입해 검증한다. 목록을 코드 상수에서
|
||||||
|
* 읽던 시절에는 "설정을 바꿔도 판정이 안 바뀌는" 회귀를 이 테스트가 잡지 못했다.
|
||||||
|
*
|
||||||
|
* @scenario scope=localStorage, notation=wildcard, locked=locked_item, settings_state=empty, request=valid_item
|
||||||
|
* @effects cleaner_preserves_items_listed_in_settings, cleaner_purges_items_removed_from_settings, cleaner_cookie_wildcard_preserves_prefix_match, cleaner_scope_does_not_leak_across_storages, cleaner_locked_items_survive_missing_settings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { cleanupFunctionalArtifacts } from '../functionalCleaner';
|
import { cleanupFunctionalArtifacts } from '../functionalCleaner';
|
||||||
|
import { LOCKED_FALLBACK, type NecessaryAllowlist } from '../necessaryAllowlist';
|
||||||
|
|
||||||
/** document.cookie 에서 특정 이름의 값 추출 */
|
/** document.cookie 에서 특정 이름의 값 추출 */
|
||||||
function getCookieValue(name: string): string | null {
|
function getCookieValue(name: string): string | null {
|
||||||
@@ -28,6 +36,20 @@ function clearAllCookies(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자 설정 + 잠금 집합을 흉내 낸 허용목록.
|
||||||
|
*
|
||||||
|
* @param overrides 스코프별 덮어쓸 목록
|
||||||
|
* @return 허용목록
|
||||||
|
*/
|
||||||
|
function allowlist(overrides: Partial<NecessaryAllowlist> = {}): NecessaryAllowlist {
|
||||||
|
return {
|
||||||
|
localStorage: overrides.localStorage ?? [...LOCKED_FALLBACK.localStorage],
|
||||||
|
sessionStorage: overrides.sessionStorage ?? [...LOCKED_FALLBACK.sessionStorage],
|
||||||
|
cookie: overrides.cookie ?? [...LOCKED_FALLBACK.cookie],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('functionalCleaner', () => {
|
describe('functionalCleaner', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@@ -41,68 +63,103 @@ describe('functionalCleaner', () => {
|
|||||||
clearAllCookies();
|
clearAllCookies();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allowlist 외 localStorage 키 파기', () => {
|
it('허용목록 외 localStorage 키 파기', () => {
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist() });
|
||||||
|
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allowlist 외 sessionStorage 키 파기', () => {
|
it('허용목록 외 sessionStorage 키 파기', () => {
|
||||||
window.sessionStorage.setItem('app_session_pref', 'value');
|
window.sessionStorage.setItem('app_session_pref', 'value');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist() });
|
||||||
|
|
||||||
expect(window.sessionStorage.getItem('app_session_pref')).toBeNull();
|
expect(window.sessionStorage.getItem('app_session_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strictly necessary 키 (g7_locale) 는 보존', () => {
|
it('설정에 등재된 키 (g7_locale) 는 보존', () => {
|
||||||
window.localStorage.setItem('g7_locale', 'ko');
|
window.localStorage.setItem('g7_locale', 'ko');
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist({ localStorage: ['g7_locale'] }) });
|
||||||
|
|
||||||
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
|
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefix 매칭 키 (g7_devtools_*) 는 보존', () => {
|
// dev-g7#640: 동의 철회 정리에서도 테마는 언어 설정과 같이 남아야 한다.
|
||||||
|
it('설정에 등재된 키 (g7_color_scheme) 는 보존', () => {
|
||||||
|
window.localStorage.setItem('g7_color_scheme', 'dark');
|
||||||
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
|
||||||
|
cleanupFunctionalArtifacts({
|
||||||
|
allowlist: allowlist({ localStorage: ['g7_color_scheme'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
|
||||||
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('와일드카드 항목 (g7_devtools_*) 은 보존', () => {
|
||||||
window.localStorage.setItem('g7_devtools_filter', 'enabled');
|
window.localStorage.setItem('g7_devtools_filter', 'enabled');
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist({ localStorage: ['g7_devtools_*'] }) });
|
||||||
|
|
||||||
expect(window.localStorage.getItem('g7_devtools_filter')).toBe('enabled');
|
expect(window.localStorage.getItem('g7_devtools_filter')).toBe('enabled');
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allowlist 외 cookie 파기', () => {
|
it('스코프는 서로 넘나들지 않는다 — localStorage 에만 등재한 키는 sessionStorage 에서 파기', () => {
|
||||||
|
window.localStorage.setItem('scoped_key', 'kept');
|
||||||
|
window.sessionStorage.setItem('scoped_key', 'purged');
|
||||||
|
|
||||||
|
cleanupFunctionalArtifacts({ allowlist: allowlist({ localStorage: ['scoped_key'] }) });
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem('scoped_key')).toBe('kept');
|
||||||
|
expect(window.sessionStorage.getItem('scoped_key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('허용목록 외 cookie 파기', () => {
|
||||||
document.cookie = 'app_pref_cookie=value; path=/';
|
document.cookie = 'app_pref_cookie=value; path=/';
|
||||||
expect(getCookieValue('app_pref_cookie')).toBe('value');
|
expect(getCookieValue('app_pref_cookie')).toBe('value');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist() });
|
||||||
|
|
||||||
expect(getCookieValue('app_pref_cookie')).toBeNull();
|
expect(getCookieValue('app_pref_cookie')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strictly necessary cookie (XSRF-TOKEN) 는 보존', () => {
|
it('잠금 cookie (XSRF-TOKEN) 는 보존', () => {
|
||||||
document.cookie = 'XSRF-TOKEN=safe; path=/';
|
document.cookie = 'XSRF-TOKEN=safe; path=/';
|
||||||
document.cookie = 'app_pref_cookie=value; path=/';
|
document.cookie = 'app_pref_cookie=value; path=/';
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist() });
|
||||||
|
|
||||||
expect(getCookieValue('XSRF-TOKEN')).toBe('safe');
|
expect(getCookieValue('XSRF-TOKEN')).toBe('safe');
|
||||||
expect(getCookieValue('app_pref_cookie')).toBeNull();
|
expect(getCookieValue('app_pref_cookie')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('storage + cookie 동시 파기 (allowlist 외 전체)', () => {
|
// 발견 ②: 쿠키 목록만 정확 일치 전용이던 시절에는 운영자가 쿠키 카드에 적은
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
// `myplugin_*` 이 저장소 카드와 달리 동작하지 않았다.
|
||||||
window.localStorage.setItem('g7_locale', 'ko'); // allowlist 보존
|
it('cookie 도 와일드카드로 보존된다 (저장소와 동일 규칙)', () => {
|
||||||
document.cookie = 'app_pref_cookie=value; path=/';
|
document.cookie = 'myplugin_state=value; path=/';
|
||||||
document.cookie = 'XSRF-TOKEN=safe; path=/'; // allowlist 보존
|
document.cookie = 'other_cookie=value; path=/';
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist({ cookie: ['myplugin_*'] }) });
|
||||||
|
|
||||||
|
expect(getCookieValue('myplugin_state')).toBe('value');
|
||||||
|
expect(getCookieValue('other_cookie')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('storage + cookie 동시 파기 (허용목록 외 전체)', () => {
|
||||||
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
window.localStorage.setItem('g7_locale', 'ko');
|
||||||
|
document.cookie = 'app_pref_cookie=value; path=/';
|
||||||
|
document.cookie = 'XSRF-TOKEN=safe; path=/';
|
||||||
|
|
||||||
|
cleanupFunctionalArtifacts({ allowlist: allowlist({ localStorage: ['g7_locale'] }) });
|
||||||
|
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
|
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
|
||||||
@@ -112,21 +169,38 @@ describe('functionalCleaner', () => {
|
|||||||
|
|
||||||
it('빈 storage 상태 — silent (예외 없음)', () => {
|
it('빈 storage 상태 — silent (예외 없음)', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: allowlist() });
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('커스텀 allowlist 옵션으로 보존 키 확장 가능', () => {
|
// 함정 4: 인라인 페이로드가 도달하지 않으면 허용목록이 통째로 비는데, 그 상태에서
|
||||||
window.localStorage.setItem('custom_protected', 'value');
|
// 로그인 토큰까지 파기되면 동의 없는 첫 방문자가 로그인을 유지하지 못한다.
|
||||||
|
it('허용목록을 주지 않아도 잠금 항목은 보존한다 (설정 미도달 폴백)', () => {
|
||||||
|
window.localStorage.setItem('auth_token', 'token');
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
document.cookie = 'XSRF-TOKEN=safe; path=/';
|
||||||
|
document.cookie = 'gdpr_session=sess; path=/';
|
||||||
|
document.cookie = 'app_pref_cookie=value; path=/';
|
||||||
|
|
||||||
|
cleanupFunctionalArtifacts();
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem('auth_token')).toBe('token');
|
||||||
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
|
expect(getCookieValue('XSRF-TOKEN')).toBe('safe');
|
||||||
|
expect(getCookieValue('gdpr_session')).toBe('sess');
|
||||||
|
expect(getCookieValue('app_pref_cookie')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('설정이 비어 있어도 잠금 항목은 보존한다 (빈 목록 주입)', () => {
|
||||||
|
window.localStorage.setItem('auth_token', 'token');
|
||||||
|
window.localStorage.setItem('g7_locale', 'ko');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts({
|
cleanupFunctionalArtifacts({
|
||||||
storageAllowlist: [
|
allowlist: { localStorage: ['auth_token'], sessionStorage: [], cookie: [] },
|
||||||
{ key: 'custom_protected', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(window.localStorage.getItem('custom_protected')).toBe('value');
|
expect(window.localStorage.getItem('auth_token')).toBe('token');
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
// 설정에서 뺀 항목은 실제로 파기된다 — 설정이 판정에 쓰인다는 증거.
|
||||||
|
expect(window.localStorage.getItem('g7_locale')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+144
-6
@@ -2,14 +2,18 @@
|
|||||||
/**
|
/**
|
||||||
* GDPR 플러그인 환경설정 레이아웃 구조 검증
|
* GDPR 플러그인 환경설정 레이아웃 구조 검증
|
||||||
*
|
*
|
||||||
* 3 카드 (운영자 작업 순서):
|
* 4 카드 (운영자 작업 순서):
|
||||||
* 1. card_operator (운영 주체 + 정책 페이지)
|
* 1. card_operator (운영 주체 + 정책 페이지)
|
||||||
* 2. card_cookie_banner (배너 + 카테고리 + 정책버전)
|
* 2. card_cookie_banner (배너 + 카테고리 + 정책버전)
|
||||||
* 3. card_auto_blocking_policy (자동 차단 정책 — 차단 도메인 관리)
|
* 3. card_auto_blocking_policy (자동 차단 정책 — 차단 도메인 관리)
|
||||||
|
* 4. card_necessary_storage (필수 저장 항목 허용목록 — 저장소·쿠키 3 스코프)
|
||||||
*
|
*
|
||||||
* 마이페이지 동의 관리 카드는 GDPR Art.7(3) 대칭성 의무에 따라 플러그인 활성 시 항상 노출
|
* 마이페이지 동의 관리 카드는 GDPR Art.7(3) 대칭성 의무에 따라 플러그인 활성 시 항상 노출
|
||||||
* — 운영자가 끌 수 있는 토글이 없으므로 관리자 설정 화면에 카드/필드가 존재하지 않는다.
|
* — 운영자가 끌 수 있는 토글이 없으므로 관리자 설정 화면에 카드/필드가 존재하지 않는다.
|
||||||
* auto_blocking 동작은 banner_enabled (쿠키 배너 노출) 와 단일 토글로 통합되어 별도 토글 없음.
|
* auto_blocking 동작은 banner_enabled (쿠키 배너 노출) 와 단일 토글로 통합되어 별도 토글 없음.
|
||||||
|
*
|
||||||
|
* @scenario scope=sessionStorage, notation=exact, locked=locked_item, settings_state=populated, request=valid_item
|
||||||
|
* @effects layout_renders_three_scope_cards, layout_uses_static_setstate_key_with_object_spread, layout_extracts_value_from_event_target_value, layout_error_key_prefix_matches_scope, layout_locked_chips_read_response_not_form_state, layout_card_not_gated_behind_banner_toggle, layout_i18n_keys_exist_in_ko_and_en
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
@@ -20,7 +24,7 @@ import koLang from '../../../lang/ko.json';
|
|||||||
import enLang from '../../../lang/en.json';
|
import enLang from '../../../lang/en.json';
|
||||||
import { findById, type AnyNode } from './helpers';
|
import { findById, type AnyNode } from './helpers';
|
||||||
|
|
||||||
describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / auto_blocking_policy)', () => {
|
describe('admin/plugin_settings.json — 4 카드 (operator / cookie_banner / auto_blocking_policy / necessary_storage)', () => {
|
||||||
const root = layout as unknown as AnyNode;
|
const root = layout as unknown as AnyNode;
|
||||||
|
|
||||||
describe('레이아웃 메타데이터', () => {
|
describe('레이아웃 메타데이터', () => {
|
||||||
@@ -51,10 +55,15 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
|||||||
expect(nav?.name).toBe('TabNavigationScroll');
|
expect(nav?.name).toBe('TabNavigationScroll');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('3 탭이 정의되어 있다 (operator / cookie_banner / auto_blocking_policy)', () => {
|
it('4 탭이 정의되어 있다 (operator / cookie_banner / auto_blocking_policy / necessary_storage)', () => {
|
||||||
const tabs = (nav?.props as { tabs?: { id: string }[] })?.tabs ?? [];
|
const tabs = (nav?.props as { tabs?: { id: string }[] })?.tabs ?? [];
|
||||||
const ids = tabs.map((t) => t.id);
|
const ids = tabs.map((t) => t.id);
|
||||||
expect(ids).toEqual(['card_operator', 'card_cookie_banner', 'card_auto_blocking_policy']);
|
expect(ids).toEqual([
|
||||||
|
'card_operator',
|
||||||
|
'card_cookie_banner',
|
||||||
|
'card_auto_blocking_policy',
|
||||||
|
'card_necessary_storage',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enableScrollSpy: true (스크롤 시 활성 탭 자동 갱신)', () => {
|
it('enableScrollSpy: true (스크롤 시 활성 탭 자동 갱신)', () => {
|
||||||
@@ -63,13 +72,18 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('카드 구조', () => {
|
describe('카드 구조', () => {
|
||||||
const expectedCards = ['card_operator', 'card_cookie_banner', 'card_auto_blocking_policy'];
|
const expectedCards = [
|
||||||
|
'card_operator',
|
||||||
|
'card_cookie_banner',
|
||||||
|
'card_auto_blocking_policy',
|
||||||
|
'card_necessary_storage',
|
||||||
|
];
|
||||||
|
|
||||||
it.each(expectedCards)('카드 %s 가 존재한다', (cardId) => {
|
it.each(expectedCards)('카드 %s 가 존재한다', (cardId) => {
|
||||||
expect(findById(root, cardId)).toBeTruthy();
|
expect(findById(root, cardId)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('카드는 settings_main_content 의 직계 children 으로 3개 모두 배치', () => {
|
it('카드는 settings_main_content 의 직계 children 으로 4개 모두 배치', () => {
|
||||||
const main = findById(root, 'settings_main_content');
|
const main = findById(root, 'settings_main_content');
|
||||||
const cardChildren = ((main?.children ?? []) as AnyNode[]).filter((c) =>
|
const cardChildren = ((main?.children ?? []) as AnyNode[]).filter((c) =>
|
||||||
(c.id ?? '').startsWith('card_')
|
(c.id ?? '').startsWith('card_')
|
||||||
@@ -800,6 +814,130 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 저장 항목 허용목록 카드 (card_necessary_storage) 회귀 가드.
|
||||||
|
*
|
||||||
|
* 이 화면이 없으면 허용목록은 다시 코드 상수로 돌아간다 — 새 확장이 저장 키를 도입할 때마다
|
||||||
|
* GDPR 플러그인을 함께 고쳐야 하고, 제3자 확장 개발자에게는 그 방법이 없다.
|
||||||
|
*
|
||||||
|
* 결함이 조용한 지점을 잠근다:
|
||||||
|
* - 스코프 카드 3개 존재 (하나라도 빠지면 그 저장소만 편집 불가)
|
||||||
|
* - setState 키가 **정적** — 동적 키는 엔진이 해석하지 않는다
|
||||||
|
* - TagInput 값 추출이 `$event?.target?.value` — `$event` 를 그대로 넣으면 칩이
|
||||||
|
* `[object Object]` 로 표시된다 (blocked_domains 에서 실제로 났던 회귀)
|
||||||
|
* - 한 카드 편집이 다른 카드 값을 덮지 않도록 객체 spread 로 재조립
|
||||||
|
* - 잠금 칩 행이 편집 대상 값(_local.form)이 아니라 응답의 잠금 집합을 읽는다
|
||||||
|
* - 카드가 banner_enabled 게이트 뒤에 있지 않다 (배너를 꺼도 저장 게이팅은 동작한다)
|
||||||
|
*/
|
||||||
|
describe('admin/plugin_settings.json — card_necessary_storage (필수 저장 항목 허용목록)', () => {
|
||||||
|
const root = layout as unknown as AnyNode;
|
||||||
|
const SCOPES = ['localStorage', 'sessionStorage', 'cookie'] as const;
|
||||||
|
|
||||||
|
it('카드와 안내 박스가 존재한다', () => {
|
||||||
|
expect(findById(root, 'card_necessary_storage')).toBeTruthy();
|
||||||
|
expect(findById(root, 'necessary_storage_warnings_box')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('저장소별 카드 3개가 순서대로 존재한다', () => {
|
||||||
|
const wrapper = findById(root, 'necessary_storage_scope_cards');
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
const ids = ((wrapper?.children ?? []) as AnyNode[]).map((c) => c.id);
|
||||||
|
expect(ids).toEqual(SCOPES.map((s) => `necessary_storage_card_${s}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(SCOPES)('%s 카드의 TagInput 이 그 스코프의 폼 값과 추천 목록을 읽는다', (scope) => {
|
||||||
|
const card = findById(root, `necessary_storage_card_${scope}`);
|
||||||
|
const serialized = JSON.stringify(card);
|
||||||
|
|
||||||
|
expect(serialized).toContain('"name":"TagInput"');
|
||||||
|
expect(serialized).toContain('"creatable":true');
|
||||||
|
expect(serialized).toContain(`_local.form?.necessary_storage_allowlist?.${scope} ?? []`);
|
||||||
|
// 추천 목록은 관리자 응답의 출하 카탈로그에서 온다 (발견 ③ — 이 키가 없으면
|
||||||
|
// 드롭다운에 이미 선택된 칩만 다시 나타나 추천이 동작하는 것처럼 보인다).
|
||||||
|
expect(serialized).toContain(`default_necessary_allowlist_preview?.${scope}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(SCOPES)('%s 카드의 change 액션이 정적 setState 키 + $event.target.value 를 쓴다', (scope) => {
|
||||||
|
const card = findById(root, `necessary_storage_card_${scope}`);
|
||||||
|
const serialized = JSON.stringify(card);
|
||||||
|
|
||||||
|
// 정적 setState 키 — 동적 키 금지
|
||||||
|
expect(serialized).toContain('"form.necessary_storage_allowlist"');
|
||||||
|
expect(serialized).not.toContain(`"form.necessary_storage_allowlist.${scope}"`);
|
||||||
|
// 다른 스코프 값을 덮지 않도록 객체 spread 로 재조립
|
||||||
|
expect(serialized).toContain('...(_local.form?.necessary_storage_allowlist ?? {})');
|
||||||
|
expect(serialized).toContain(`${scope}: ($event?.target?.value ?? [])`);
|
||||||
|
expect(serialized).toContain('"type":"change"');
|
||||||
|
expect(serialized).toContain('"hasChanges":true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(SCOPES)('%s 카드가 422 에러를 그 스코프 키 접두사로 판정한다', (scope) => {
|
||||||
|
const card = findById(root, `necessary_storage_card_${scope}`);
|
||||||
|
const serialized = JSON.stringify(card);
|
||||||
|
|
||||||
|
expect(serialized).toContain(
|
||||||
|
`Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.${scope}.'))`,
|
||||||
|
);
|
||||||
|
expect(serialized).toContain(
|
||||||
|
`Object.entries(_local.errors ?? {}).find(([k]) => k.startsWith('necessary_storage_allowlist.${scope}.'))`,
|
||||||
|
);
|
||||||
|
expect(serialized).toContain('border-red-500');
|
||||||
|
expect(serialized).toContain('"form-error"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(SCOPES)('%s 카드의 잠금 칩 행이 응답의 잠금 집합을 읽고 편집 값에는 넣지 않는다', (scope) => {
|
||||||
|
const lockedRow = findById(root, `necessary_storage_locked_${scope}`);
|
||||||
|
expect(lockedRow).toBeTruthy();
|
||||||
|
const serialized = JSON.stringify(lockedRow);
|
||||||
|
|
||||||
|
// 잠금 집합의 출처는 서버 응답 — _local.form 이 아니다 (폼에 담기면 저장 요청에 실린다).
|
||||||
|
expect(serialized).toContain(`gdprSettings?.data?.settings?.necessary_storage_locked?.${scope}`);
|
||||||
|
expect(serialized).not.toContain('_local.form?.necessary_storage_locked');
|
||||||
|
// 잠금 항목이 없는 스코프에서는 행 자체를 그리지 않는다.
|
||||||
|
expect(serialized).toContain(`(gdprSettings?.data?.settings?.necessary_storage_locked?.${scope} ?? []).length > 0`);
|
||||||
|
// 편집 가능한 칩과 시각적으로 구분되어야 한다 (속성만이 아니라 표현까지).
|
||||||
|
expect(serialized).toContain('cursor-not-allowed');
|
||||||
|
expect(serialized).toContain('opacity-70');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('카드 본문이 banner_enabled 게이트 뒤에 있지 않다 (배너를 꺼도 저장 게이팅은 동작한다)', () => {
|
||||||
|
const card = findById(root, 'card_necessary_storage');
|
||||||
|
const serialized = JSON.stringify(card);
|
||||||
|
|
||||||
|
expect(serialized).not.toContain('banner_enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('카드가 쓰는 모든 $t: 키가 ko/en 양쪽에 존재한다', () => {
|
||||||
|
const card = findById(root, 'card_necessary_storage');
|
||||||
|
const serialized = JSON.stringify(card);
|
||||||
|
const keys = [...serialized.matchAll(/\$t:sirsoft-gdpr\.([A-Za-z0-9_.]+)/g)].map((m) => m[1]);
|
||||||
|
|
||||||
|
expect(keys.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const resolve = (pack: unknown, key: string): unknown =>
|
||||||
|
key.split('.').reduce<unknown>(
|
||||||
|
(acc, part) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[part] : undefined),
|
||||||
|
pack,
|
||||||
|
);
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const key of new Set(keys)) {
|
||||||
|
if (typeof resolve(koLang, key) !== 'string') missing.push(`ko:${key}`);
|
||||||
|
if (typeof resolve(enLang, key) !== 'string') missing.push(`en:${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('탭 라벨 키도 ko/en 양쪽에 존재한다', () => {
|
||||||
|
const koNav = (koLang as { settings?: { nav?: Record<string, string> } }).settings?.nav ?? {};
|
||||||
|
const enNav = (enLang as { settings?: { nav?: Record<string, string> } }).settings?.nav ?? {};
|
||||||
|
|
||||||
|
expect(typeof koNav.necessary_storage).toBe('string');
|
||||||
|
expect(typeof enNav.necessary_storage).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이슈 #509 체크리스트 12번 회귀 테스트 — 정책 버전 snapshot 모달의 본문 페이지 링크가
|
* 이슈 #509 체크리스트 12번 회귀 테스트 — 정책 버전 snapshot 모달의 본문 페이지 링크가
|
||||||
* `/{{slug}}` 로 조합되어 페이지 모듈(sirsoft-page) 실제 URL 패턴 `/page/{slug}` 와
|
* `/{{slug}}` 로 조합되어 페이지 모듈(sirsoft-page) 실제 URL 패턴 `/page/{slug}` 와
|
||||||
|
|||||||
+622
@@ -0,0 +1,622 @@
|
|||||||
|
/**
|
||||||
|
* strictly necessary 허용목록 커버리지·정합성 테스트
|
||||||
|
*
|
||||||
|
* functionalCleaner 는 functional 미동의(기본 상태)에서 **부팅마다** 허용목록 밖의
|
||||||
|
* localStorage / sessionStorage 를 전량 파기한다. 그래서 코어·확장이 새로 저장 키를
|
||||||
|
* 도입했는데 출하 카탈로그에도 없고 운영자도 등재하지 않으면, 그 설정은 "저장은 되는데
|
||||||
|
* 새로고침하면 사라지는" 상태가 된다 — 예외도 콘솔 오류도 남지 않아 증상만으로는 원인을
|
||||||
|
* 특정할 수 없다.
|
||||||
|
*
|
||||||
|
* 이 테스트는 목록을 **손으로 열거하지 않는다.** 저장소의 소스를 훑어 `.setItem()` 이 쓰는
|
||||||
|
* 키를 기계 도출하고, 그 전량이 출하 카탈로그(`plugin.php`)에 등재되었거나 의도적 비필수
|
||||||
|
* (INTENTIONALLY_NON_NECESSARY)로 선언되었는지 검사한다.
|
||||||
|
*
|
||||||
|
* 허용목록이 코드 상수에서 운영자 설정으로 옮겨진 뒤로 대조 대상이 **PHP 출하 카탈로그**다.
|
||||||
|
* TS 상수를 계속 대조하면, 그 상수가 잠금 폴백만 남았으므로 모집단 대부분이 붉어지거나
|
||||||
|
* (반대로) 판정 대상이 사라져 아무것도 재지 않는 초록이 된다.
|
||||||
|
*
|
||||||
|
* 검사 축:
|
||||||
|
* 1. 모집단 하한 — 스캐너가 죽으면 붉어진다
|
||||||
|
* 2. 정적 해석 불가 지점이 선언 목록과 정확히 일치 (부분 누락 방지)
|
||||||
|
* 3. 도출된 키 전량이 카탈로그 등재 또는 의도적 비필수 선언
|
||||||
|
* 4. 의도적 비필수 선언 사문화 방지
|
||||||
|
* 5. 서버 쿠키 게이트가 하드코딩 목록이 아니라 설정을 읽는다 (발견 ①)
|
||||||
|
* 6. TS 폴백이 PHP 잠금 집합과 정확히 일치 (발견 ①)
|
||||||
|
* 7. TS 폴백이 카탈로그 전체 사본이 아니다 (드리프트 재발 차단)
|
||||||
|
* 8. 카탈로그가 잠금 항목을 담지 않는다 (담기면 API 로 지울 수 있게 된다)
|
||||||
|
* 9. 카탈로그 항목이 저장 검증 규칙을 통과한다 (통과 못 하면 시드값을 다시 저장할 수 없다)
|
||||||
|
* 10. 와일드카드 매칭 규칙이 PHP·TS 에서 동형이다 (발견 ②)
|
||||||
|
*
|
||||||
|
* @module sirsoft-gdpr/__tests__/necessaryAllowlistCoverage
|
||||||
|
*
|
||||||
|
* @scenario scope=cookie, notation=wildcard, locked=locked_item, settings_state=unreachable, request=valid_item
|
||||||
|
* @effects ts_fallback_matches_php_locked_set, ts_fallback_is_not_a_catalog_copy, wildcard_rule_is_isomorphic_across_php_and_ts, scope_vocabulary_matches_across_php_and_ts, server_cookie_gate_reads_settings_not_hardcoded_list, locked_set_absent_from_shipped_catalog, shipped_catalog_items_pass_save_validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ALLOWLIST_SCOPES,
|
||||||
|
LOCKED_FALLBACK,
|
||||||
|
matchesAllowlistPattern,
|
||||||
|
type AllowlistScope,
|
||||||
|
} from '../necessaryAllowlist';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장소 루트를 탐색합니다. (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();
|
||||||
|
const PLUGIN_ROOT = path.join(REPO_ROOT, 'plugins', '_bundled', 'sirsoft-gdpr');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스캔 대상 디렉토리 (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 파일 원문 (식별자 해석용)
|
||||||
|
* @param file 호출이 있는 파일 절대경로
|
||||||
|
* @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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP 원문에서 `이름 => [ ... ]` 형태의 스코프 그룹을 문자열 배열로 파싱합니다.
|
||||||
|
*
|
||||||
|
* 대상은 `plugin.php` 의 출하 카탈로그와 `Support/NecessaryAllowlist.php` 의 잠금 집합.
|
||||||
|
* PHP 를 실행하지 않고 원문을 읽는 이유는, 이 대조가 **두 언어의 목록이 어긋났는지** 를
|
||||||
|
* 보는 것이라 한쪽 런타임에 의존하면 안 되기 때문이다.
|
||||||
|
*
|
||||||
|
* @param source PHP 원문 (블록만 잘라낸 것)
|
||||||
|
* @return 스코프 => 문자열 배열
|
||||||
|
*/
|
||||||
|
function parsePhpScopeMap(source: string): Record<string, string[]> {
|
||||||
|
const stripped = source
|
||||||
|
.replace(/\/\/[^\n]*/g, '')
|
||||||
|
// 런타임 해석 항목(`(string) config('session.cookie', 'laravel_session')`)은 항목 이름이
|
||||||
|
// 아니라 조회식이다. 벗겨내지 않으면 설정 키와 폴백 문자열이 목록 항목으로 섞인다.
|
||||||
|
.replace(/(?:\(string\)\s*)?config\([^)]*\)/g, 'RUNTIME_RESOLVED');
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
const scopeRe = /'([A-Za-z]+)'\s*=>\s*\[/g;
|
||||||
|
let m: RegExpExecArray | null = scopeRe.exec(stripped);
|
||||||
|
while (m !== null) {
|
||||||
|
const scope = m[1];
|
||||||
|
let depth = 1;
|
||||||
|
let i = scopeRe.lastIndex;
|
||||||
|
while (i < stripped.length && depth > 0) {
|
||||||
|
if (stripped[i] === '[') depth += 1;
|
||||||
|
else if (stripped[i] === ']') depth -= 1;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
const body = stripped.slice(scopeRe.lastIndex, i - 1);
|
||||||
|
result[scope] = [...body.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((x) => x[1]);
|
||||||
|
m = scopeRe.exec(stripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP 원문에서 `const X = [ ... ];` 또는 `return [ ... ];` 블록을 잘라냅니다.
|
||||||
|
*
|
||||||
|
* @param source PHP 파일 원문
|
||||||
|
* @param anchor 블록 시작 앵커 (그 뒤 첫 `[` 부터 대응 `]` 까지)
|
||||||
|
* @return 블록 본문
|
||||||
|
*/
|
||||||
|
function sliceBracketBlock(source: string, anchor: string): string {
|
||||||
|
const at = source.indexOf(anchor);
|
||||||
|
if (at === -1) {
|
||||||
|
throw new Error(`PHP 앵커를 찾지 못했습니다: ${anchor}`);
|
||||||
|
}
|
||||||
|
const open = source.indexOf('[', at);
|
||||||
|
let depth = 1;
|
||||||
|
let i = open + 1;
|
||||||
|
while (i < source.length && depth > 0) {
|
||||||
|
if (source[i] === '[') depth += 1;
|
||||||
|
else if (source[i] === ']') depth -= 1;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return source.slice(open + 1, i - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGIN_PHP = fs.readFileSync(path.join(PLUGIN_ROOT, 'plugin.php'), 'utf-8');
|
||||||
|
const ALLOWLIST_PHP = fs.readFileSync(
|
||||||
|
path.join(PLUGIN_ROOT, 'src', 'Support', 'NecessaryAllowlist.php'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const MIDDLEWARE_PHP = fs.readFileSync(
|
||||||
|
path.join(PLUGIN_ROOT, 'src', 'Http', 'Middleware', 'CookieConsentMiddleware.php'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const REQUEST_PHP = fs.readFileSync(
|
||||||
|
path.join(PLUGIN_ROOT, 'src', 'Http', 'Requests', 'UpdateAdminSettingsRequest.php'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const SHIPPED_CATALOG = parsePhpScopeMap(
|
||||||
|
sliceBracketBlock(PLUGIN_PHP, 'DEFAULT_NECESSARY_ALLOWLIST_CATALOG ='),
|
||||||
|
);
|
||||||
|
const PHP_LOCKED = parsePhpScopeMap(
|
||||||
|
sliceBracketBlock(ALLOWLIST_PHP, 'public static function locked(): array'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키가 실행 시점에 정해져 **정적으로 해석할 수 없는** 쓰기 지점.
|
||||||
|
*
|
||||||
|
* 이 목록은 사각을 지우는 것이 아니라 드러내 고정한다. 정렬된 문자열로 비교하므로
|
||||||
|
* 새 동적 쓰기 지점이 생기면 그 즉시 붉어진다.
|
||||||
|
*/
|
||||||
|
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` 핸들러 — 위와 같은 이유 (레이아웃이 키를 정한다).',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수(strictly necessary) 가 **아니라고 의도적으로 판단한** 키.
|
||||||
|
*
|
||||||
|
* 여기 적힌 키는 functional 동의가 없으면 파기되는 것이 설계 의도다.
|
||||||
|
* 새 키를 여기 넣을 때는 사용자에게 그 상실이 수용 가능한지 근거를 함께 남긴다.
|
||||||
|
*/
|
||||||
|
const INTENTIONALLY_NON_NECESSARY: ReadonlyArray<{ key: string; reason: string }> = [
|
||||||
|
{
|
||||||
|
key: 'g7_preferred_currency',
|
||||||
|
reason: '표시 통화 — functional 카테고리 설명에 명시된 선호도. 미동의 시 기본 통화로 표시되는 것이 의도된 동작이다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출하 카탈로그(전 스코프 합집합)가 도출된 키를 덮는지 검사합니다.
|
||||||
|
*
|
||||||
|
* 저장소 종류는 개별 테스트가 잠그며, 여기서는 "출하 목록이 이 키를 알고 있는가" 만 본다.
|
||||||
|
*
|
||||||
|
* @param discovered 도출된 키
|
||||||
|
* @return 등재 여부
|
||||||
|
*/
|
||||||
|
function isInShippedCatalog(discovered: DiscoveredKey): boolean {
|
||||||
|
return Object.values(SHIPPED_CATALOG).some((patterns) =>
|
||||||
|
patterns.some((pattern) => (
|
||||||
|
// 도출된 키가 접두형이면 카탈로그의 접두 패턴과 같은 계열인지도 본다.
|
||||||
|
matchesAllowlistPattern(discovered.key, pattern)
|
||||||
|
|| (pattern.endsWith('*') && pattern.slice(0, -1) === discovered.key)
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('strictly necessary 허용목록 모집단 커버리지', () => {
|
||||||
|
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('출하 카탈로그 파싱이 비어 있지 않다 (파서가 죽으면 3번 축이 공허 통과한다)', () => {
|
||||||
|
expect(Object.keys(SHIPPED_CATALOG).sort()).toEqual([...ALLOWLIST_SCOPES].sort());
|
||||||
|
expect(SHIPPED_CATALOG.localStorage.length).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('도출된 모든 저장 키가 출하 카탈로그 등재 또는 의도적 비필수 선언 중 하나에 해당한다', () => {
|
||||||
|
const exempt = new Set(INTENTIONALLY_NON_NECESSARY.map((e) => e.key));
|
||||||
|
const lockedKeys = new Set([...PHP_LOCKED.localStorage, ...PHP_LOCKED.sessionStorage]);
|
||||||
|
|
||||||
|
const uncovered = discovered
|
||||||
|
.filter((d) => !exempt.has(d.key))
|
||||||
|
.filter((d) => !lockedKeys.has(d.key))
|
||||||
|
.filter((d) => !isInShippedCatalog(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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('서버·클라이언트 허용목록 출처 정합성', () => {
|
||||||
|
// 발견 ①: 서버는 `config('session.cookie')` 를 런타임 해석하는데 클라이언트는
|
||||||
|
// 'laravel_session' 을 하드코딩하고 있었다. SESSION_COOKIE 를 지정한 사이트에서는
|
||||||
|
// 클라이언트 목록의 그 항목이 죽어 있었고, 두 목록을 대조하는 테스트가 없었다.
|
||||||
|
it('서버 쿠키 게이트가 하드코딩 목록이 아니라 공용 해석기를 경유한다', () => {
|
||||||
|
const body = MIDDLEWARE_PHP.slice(MIDDLEWARE_PHP.indexOf('function isStrictlyNecessary'));
|
||||||
|
|
||||||
|
expect(body).toContain("NecessaryAllowlist::matches($name, 'cookie')");
|
||||||
|
expect(body).not.toContain('in_array(');
|
||||||
|
expect(body).not.toContain("'XSRF-TOKEN'");
|
||||||
|
expect(body).not.toContain("'gdpr_session'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('공용 해석기가 운영자 설정을 읽는다 (잠금 집합만 보고 끝내지 않는다)', () => {
|
||||||
|
expect(ALLOWLIST_PHP).toContain("g7_plugin_settings(self::PLUGIN_ID, 'necessary_storage_allowlist.'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('세션 쿠키 이름은 런타임 해석 값이다 (하드코딩 금지)', () => {
|
||||||
|
const locked = sliceBracketBlock(ALLOWLIST_PHP, 'public static function locked(): array');
|
||||||
|
|
||||||
|
expect(locked).toContain("config('session.cookie'");
|
||||||
|
// 폴백 인자로서의 등장 1회만 허용 — 목록에 직접 적힌 항목으로 남아서는 안 된다.
|
||||||
|
expect(locked.match(/'laravel_session'/g)?.length ?? 0).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TS 폴백이 PHP 잠금 집합과 정확히 일치한다 (런타임 세션 쿠키 제외)', () => {
|
||||||
|
// 세션 쿠키 이름은 파서가 이미 걷어냈다 — TS 폴백에 담을 수 없는 런타임 값이라
|
||||||
|
// 이 축의 대조 대상이 아니다 (그 항목의 존재는 바로 위 테스트가 잠근다).
|
||||||
|
expect([...LOCKED_FALLBACK.localStorage].sort()).toEqual([...PHP_LOCKED.localStorage].sort());
|
||||||
|
expect([...LOCKED_FALLBACK.sessionStorage].sort()).toEqual([...PHP_LOCKED.sessionStorage].sort());
|
||||||
|
expect([...LOCKED_FALLBACK.cookie].sort()).toEqual([...PHP_LOCKED.cookie].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TS 폴백은 카탈로그 전체 사본이 아니다 (드리프트 재발 차단)', () => {
|
||||||
|
for (const scope of ALLOWLIST_SCOPES) {
|
||||||
|
const shipped = SHIPPED_CATALOG[scope] ?? [];
|
||||||
|
const overlap = LOCKED_FALLBACK[scope].filter((k) => shipped.includes(k));
|
||||||
|
expect(overlap).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTotal = ALLOWLIST_SCOPES
|
||||||
|
.reduce((n, scope) => n + LOCKED_FALLBACK[scope].length, 0);
|
||||||
|
expect(fallbackTotal).toBeLessThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('출하 카탈로그는 잠금 항목을 담지 않는다 (담기면 API 로 지울 수 있게 된다)', () => {
|
||||||
|
const leaked: string[] = [];
|
||||||
|
for (const scope of ALLOWLIST_SCOPES) {
|
||||||
|
const locked = PHP_LOCKED[scope] ?? [];
|
||||||
|
for (const item of SHIPPED_CATALOG[scope] ?? []) {
|
||||||
|
if (locked.includes(item)) {
|
||||||
|
leaked.push(`${scope}:${item}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(leaked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('출하 카탈로그 항목이 저장 검증 규칙을 통과한다 (통과 못 하면 시드값을 다시 저장할 수 없다)', () => {
|
||||||
|
const m = /STORAGE_KEY_REGEX = '(.+)';/.exec(REQUEST_PHP);
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
|
||||||
|
// PHP 구분자(/.../)를 벗겨 JS 정규식으로 옮긴다.
|
||||||
|
const body = (m as RegExpExecArray)[1].replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
const re = new RegExp(body);
|
||||||
|
|
||||||
|
const rejected: string[] = [];
|
||||||
|
for (const scope of ALLOWLIST_SCOPES) {
|
||||||
|
for (const item of SHIPPED_CATALOG[scope] ?? []) {
|
||||||
|
if (!re.test(item) || item.length > 128) {
|
||||||
|
rejected.push(`${scope}:${item}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rejected).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 발견 ②: 저장소 목록은 접두사 매칭을 지원했는데 쿠키 목록은 `includes()` 정확 일치
|
||||||
|
// 전용이었다. 규칙이 갈라지면 운영자가 쿠키 카드에 적은 와일드카드가 조용히 무시된다.
|
||||||
|
it('와일드카드 매칭 규칙이 PHP·TS 에서 동형이다', () => {
|
||||||
|
// PHP 는 원문으로 규칙 3요소를 확인한다 (테스트에서 PHP 를 실행할 수 없으므로).
|
||||||
|
const phpBody = ALLOWLIST_PHP.slice(ALLOWLIST_PHP.indexOf('function matchesPattern'));
|
||||||
|
expect(phpBody).toContain("str_ends_with($pattern, '*')");
|
||||||
|
expect(phpBody).toContain("$prefix !== ''");
|
||||||
|
expect(phpBody).toContain('str_starts_with($name, $prefix)');
|
||||||
|
|
||||||
|
// TS 는 구현을 직접 호출해 규칙을 확인한다 (원문 검사보다 강한 증거).
|
||||||
|
expect(matchesAllowlistPattern('g7_filters_orders_1', 'g7_filters_*')).toBe(true);
|
||||||
|
expect(matchesAllowlistPattern('other_g7_filters_1', 'g7_filters_*')).toBe(false);
|
||||||
|
expect(matchesAllowlistPattern('g7_locale', 'g7_locale')).toBe(true);
|
||||||
|
expect(matchesAllowlistPattern('g7_locale_extra', 'g7_locale')).toBe(false);
|
||||||
|
// `*` 단독은 전체 개방이므로 매칭하지 않는다.
|
||||||
|
expect(matchesAllowlistPattern('anything', '*')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('스코프 어휘가 PHP·TS 에서 같다', () => {
|
||||||
|
const phpScopes = /public const SCOPES = \[([^\]]+)\]/.exec(ALLOWLIST_PHP);
|
||||||
|
expect(phpScopes).not.toBeNull();
|
||||||
|
const parsed = [...(phpScopes as RegExpExecArray)[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
|
||||||
|
|
||||||
|
expect(parsed).toEqual([...ALLOWLIST_SCOPES] as AllowlistScope[]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,18 +2,24 @@
|
|||||||
* storageInterceptor 단위 테스트 (Phase 2 단순화).
|
* storageInterceptor 단위 테스트 (Phase 2 단순화).
|
||||||
*
|
*
|
||||||
* Storage.prototype.setItem 가로채기 + 4단계 게이팅 규칙 검증:
|
* Storage.prototype.setItem 가로채기 + 4단계 게이팅 규칙 검증:
|
||||||
* 1. strictly necessary allowlist 항상 통과
|
* 1. strictly necessary 허용목록 항상 통과
|
||||||
* 2. functional 동의 시 모든 키 통과
|
* 2. functional 동의 시 모든 키 통과
|
||||||
* 3. user-initiated (WP29 §3.6) 면제 — 사용자 인터랙션 직후 통과
|
* 3. user-initiated (WP29 §3.6) 면제 — 사용자 인터랙션 직후 통과
|
||||||
* 4. 그 외 → 차단
|
* 4. 그 외 → 차단
|
||||||
*
|
*
|
||||||
|
* 허용목록은 **운영자 설정**이므로 이 테스트는 목록을 주입해 검증한다 — 주입한 목록대로
|
||||||
|
* 허용/차단이 갈리는지가 "설정이 실제로 판정에 쓰이는가" 의 증거다.
|
||||||
|
*
|
||||||
* "운영자 등록 표" 는 제거됨 (Phase 2 단순화).
|
* "운영자 등록 표" 는 제거됨 (Phase 2 단순화).
|
||||||
|
*
|
||||||
|
* @scenario scope=localStorage, notation=exact, locked=operator_item, settings_state=populated, request=valid_item
|
||||||
|
* @effects storage_allows_item_listed_in_settings, storage_blocks_item_removed_from_settings, storage_wildcard_matches_prefix_only, storage_scope_does_not_leak_across_storages, storage_locked_item_survives_empty_settings, storage_config_update_applies_new_allowlist
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { LOCKED_FALLBACK, type NecessaryAllowlist } from '../necessaryAllowlist';
|
||||||
import {
|
import {
|
||||||
DEFAULT_NECESSARY_ALLOWLIST,
|
|
||||||
installStorageInterceptor,
|
installStorageInterceptor,
|
||||||
isStorageAllowed,
|
isStorageAllowed,
|
||||||
uninstallStorageInterceptor,
|
uninstallStorageInterceptor,
|
||||||
@@ -21,6 +27,20 @@ import {
|
|||||||
} from '../storageInterceptor';
|
} from '../storageInterceptor';
|
||||||
import { __setLastInteractionForTest, installUserInitiatedTracker, uninstallUserInitiatedTracker } from '../userInitiatedTracker';
|
import { __setLastInteractionForTest, installUserInitiatedTracker, uninstallUserInitiatedTracker } from '../userInitiatedTracker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자 설정 + 잠금 집합을 흉내 낸 허용목록.
|
||||||
|
*
|
||||||
|
* @param overrides 스코프별 덮어쓸 목록
|
||||||
|
* @return 허용목록
|
||||||
|
*/
|
||||||
|
function allowlist(overrides: Partial<NecessaryAllowlist> = {}): NecessaryAllowlist {
|
||||||
|
return {
|
||||||
|
localStorage: overrides.localStorage ?? [...LOCKED_FALLBACK.localStorage],
|
||||||
|
sessionStorage: overrides.sessionStorage ?? [...LOCKED_FALLBACK.sessionStorage],
|
||||||
|
cookie: overrides.cookie ?? [...LOCKED_FALLBACK.cookie],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('storageInterceptor', () => {
|
describe('storageInterceptor', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installUserInitiatedTracker();
|
installUserInitiatedTracker();
|
||||||
@@ -33,30 +53,89 @@ describe('storageInterceptor', () => {
|
|||||||
uninstallUserInitiatedTracker();
|
uninstallUserInitiatedTracker();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strictly necessary 키 (g7_locale) → 항상 통과 (미동의여도)', () => {
|
it('설정에 등재된 키 (g7_locale) → 항상 통과 (미동의여도)', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist({ localStorage: ['g7_locale'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.localStorage.setItem('g7_locale', 'ko');
|
window.localStorage.setItem('g7_locale', 'ko');
|
||||||
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
|
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefix 매칭 (g7_devtools_*) → 통과', () => {
|
// dev-g7#640: 화면 테마가 목록에서 빠져 있어, 동의 전에는 테마를 바꿔도 저장이
|
||||||
|
// 조용히 버려졌다 (새로고침하면 원래대로). 미인증 화면뿐 아니라 관리자 화면 전체가 같았다.
|
||||||
|
it('설정에 등재된 키 (g7_color_scheme) → 항상 통과 (미동의여도)', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist({ localStorage: ['g7_color_scheme'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem('g7_color_scheme', 'dark');
|
||||||
|
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 설정이 실제로 판정에 쓰이는지의 대조군 — 목록에서 빼면 같은 키가 차단되어야 한다.
|
||||||
|
it('설정에서 뺀 키는 차단된다 (목록이 판정에 쓰인다는 증거)', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem('g7_color_scheme', 'dark');
|
||||||
|
expect(window.localStorage.getItem('g7_color_scheme')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('와일드카드 매칭 (g7_devtools_*) → 통과', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: ['g7_devtools_*'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.localStorage.setItem('g7_devtools_filter', 'enabled');
|
window.localStorage.setItem('g7_devtools_filter', 'enabled');
|
||||||
expect(window.localStorage.getItem('g7_devtools_filter')).toBe('enabled');
|
expect(window.localStorage.getItem('g7_devtools_filter')).toBe('enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('functional 동의 시 모든 키 통과 (allowlist 외 키도 통과)', () => {
|
it('와일드카드는 앞부분만 매칭한다 — 접두사가 다르면 차단', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: ['g7_filters_*'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem('g7_filters_orders_1', 'a');
|
||||||
|
window.localStorage.setItem('other_g7_filters_x', 'b');
|
||||||
|
expect(window.localStorage.getItem('g7_filters_orders_1')).toBe('a');
|
||||||
|
expect(window.localStorage.getItem('other_g7_filters_x')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('스코프가 다르면 통과하지 않는다 (localStorage 등재 ≠ sessionStorage 허용)', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: ['scoped_key'], sessionStorage: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem('scoped_key', 'kept');
|
||||||
|
window.sessionStorage.setItem('scoped_key', 'blocked');
|
||||||
|
expect(window.localStorage.getItem('scoped_key')).toBe('kept');
|
||||||
|
expect(window.sessionStorage.getItem('scoped_key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sessionStorage 전용 항목도 그 스코프에서 통과한다', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({
|
||||||
|
sessionStorage: ['g7:sirsoft-pay_kginicis:pendingClose'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.sessionStorage.setItem('g7:sirsoft-pay_kginicis:pendingClose', '1');
|
||||||
|
expect(window.sessionStorage.getItem('g7:sirsoft-pay_kginicis:pendingClose')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('functional 동의 시 모든 키 통과 (허용목록 외 키도 통과)', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: true,
|
functionalConsented: true,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
@@ -66,7 +145,7 @@ describe('storageInterceptor', () => {
|
|||||||
it('미동의 + user-initiated (WP29 §3.6) → 통과', () => {
|
it('미동의 + user-initiated (WP29 §3.6) → 통과', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
__setLastInteractionForTest(Date.now());
|
__setLastInteractionForTest(Date.now());
|
||||||
@@ -77,7 +156,7 @@ describe('storageInterceptor', () => {
|
|||||||
it('미동의 + 비-사용자 (background) → 차단', () => {
|
it('미동의 + 비-사용자 (background) → 차단', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// user-initiated 미발생 (timestamp=0)
|
// user-initiated 미발생 (timestamp=0)
|
||||||
@@ -88,17 +167,29 @@ describe('storageInterceptor', () => {
|
|||||||
it('미동의 + 임의 키 + 비-사용자 → 차단 (보수적)', () => {
|
it('미동의 + 임의 키 + 비-사용자 → 차단 (보수적)', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.localStorage.setItem('random_key', 'value');
|
window.localStorage.setItem('random_key', 'value');
|
||||||
expect(window.localStorage.getItem('random_key')).toBeNull();
|
expect(window.localStorage.getItem('random_key')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('허용목록이 통째로 비어도 잠금 항목(auth_token)은 통과한다', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: LOCKED_FALLBACK,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem('auth_token', 'token');
|
||||||
|
window.localStorage.setItem('g7_locale', 'ko');
|
||||||
|
expect(window.localStorage.getItem('auth_token')).toBe('token');
|
||||||
|
expect(window.localStorage.getItem('g7_locale')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('updateStorageInterceptorConfig 으로 동의 상태 갱신 가능', () => {
|
it('updateStorageInterceptorConfig 으로 동의 상태 갱신 가능', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 미동의 + 비-사용자 → 차단
|
// 미동의 + 비-사용자 → 차단
|
||||||
@@ -108,16 +199,33 @@ describe('storageInterceptor', () => {
|
|||||||
// 동의 갱신
|
// 동의 갱신
|
||||||
updateStorageInterceptorConfig({
|
updateStorageInterceptorConfig({
|
||||||
functionalConsented: true,
|
functionalConsented: true,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
window.localStorage.setItem('app_pref', 'value2');
|
window.localStorage.setItem('app_pref', 'value2');
|
||||||
expect(window.localStorage.getItem('app_pref')).toBe('value2');
|
expect(window.localStorage.getItem('app_pref')).toBe('value2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updateStorageInterceptorConfig 으로 허용목록 갱신이 판정에 즉시 반영된다', () => {
|
||||||
|
installStorageInterceptor({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.setItem('operator_added', 'v1');
|
||||||
|
expect(window.localStorage.getItem('operator_added')).toBeNull();
|
||||||
|
|
||||||
|
updateStorageInterceptorConfig({
|
||||||
|
functionalConsented: false,
|
||||||
|
necessaryAllowlist: allowlist({ localStorage: ['operator_added'] }),
|
||||||
|
});
|
||||||
|
window.localStorage.setItem('operator_added', 'v2');
|
||||||
|
expect(window.localStorage.getItem('operator_added')).toBe('v2');
|
||||||
|
});
|
||||||
|
|
||||||
it('uninstall 후 원본 setItem 복원 — 모든 쓰기 통과', () => {
|
it('uninstall 후 원본 setItem 복원 — 모든 쓰기 통과', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 인터셉터 활성 시 차단
|
// 인터셉터 활성 시 차단
|
||||||
@@ -133,19 +241,21 @@ describe('storageInterceptor', () => {
|
|||||||
it('isStorageAllowed — 정책 평가 함수는 사이드 이펙트 없음', () => {
|
it('isStorageAllowed — 정책 평가 함수는 사이드 이펙트 없음', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: allowlist({ localStorage: ['g7_locale', 'g7_color_scheme'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
__setLastInteractionForTest(Date.now());
|
__setLastInteractionForTest(Date.now());
|
||||||
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(true); // user-initiated 면제
|
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(true); // user-initiated 면제
|
||||||
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary
|
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary
|
||||||
|
expect(isStorageAllowed('g7_color_scheme', 'localStorage')).toBe(true); // necessary
|
||||||
|
|
||||||
// user-initiated 가 만료된 시점엔 차단
|
// user-initiated 가 만료된 시점엔 차단
|
||||||
__setLastInteractionForTest(0);
|
__setLastInteractionForTest(0);
|
||||||
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(false);
|
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(false);
|
||||||
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary 는 항상 통과
|
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary 는 항상 통과
|
||||||
|
expect(isStorageAllowed('g7_color_scheme', 'localStorage')).toBe(true); // necessary 는 항상 통과
|
||||||
|
|
||||||
// 함수 호출만으로 storage 변경 X
|
// 함수 호출만으로 storage 변경 X
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,37 +19,29 @@
|
|||||||
* @module sirsoft-gdpr/cookieInterceptor
|
* @module sirsoft-gdpr/cookieInterceptor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOCKED_FALLBACK,
|
||||||
|
isNecessary,
|
||||||
|
type NecessaryAllowlist,
|
||||||
|
} from './necessaryAllowlist';
|
||||||
import { isUserInitiated } from './userInitiatedTracker';
|
import { isUserInitiated } from './userInitiatedTracker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인터셉터 설정.
|
* 인터셉터 설정.
|
||||||
*
|
*
|
||||||
* @property functionalConsented functional 카테고리 동의 여부
|
* @property functionalConsented functional 카테고리 동의 여부
|
||||||
* @property necessaryAllowlist strictly necessary 쿠키 이름 (서버 cookie 와 별개 — 클라이언트 쓰기 화이트리스트)
|
* @property necessaryAllowlist strictly necessary 허용목록 (운영자 설정 ∪ 잠금 집합) — `cookie` 스코프만 사용
|
||||||
*/
|
*/
|
||||||
export interface CookieInterceptorConfig {
|
export interface CookieInterceptorConfig {
|
||||||
functionalConsented: boolean;
|
functionalConsented: boolean;
|
||||||
necessaryAllowlist: readonly string[];
|
necessaryAllowlist: NecessaryAllowlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 정적 strictly necessary 쿠키 화이트리스트 (클라이언트 쓰기).
|
|
||||||
*
|
|
||||||
* 일반적으로 코어가 쓰는 쿠키는 모두 서버 Set-Cookie 로 발급되므로 클라이언트 쓰기는
|
|
||||||
* 거의 없음. 폼 보호용 XSRF-TOKEN refresh, 세션 ID 등 일부만 화이트리스트.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_NECESSARY_COOKIE_ALLOWLIST: readonly string[] = [
|
|
||||||
'XSRF-TOKEN',
|
|
||||||
'laravel_session',
|
|
||||||
'laravel_maintenance',
|
|
||||||
'gdpr_session',
|
|
||||||
];
|
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
let originalCookieDescriptor: PropertyDescriptor | null = null;
|
let originalCookieDescriptor: PropertyDescriptor | null = null;
|
||||||
let config: CookieInterceptorConfig = {
|
let config: CookieInterceptorConfig = {
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: LOCKED_FALLBACK,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,13 +59,16 @@ function extractCookieName(raw: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이름이 strictly necessary 화이트리스트에 포함되는지 검사합니다.
|
* 이름이 strictly necessary 허용목록에 포함되는지 검사합니다.
|
||||||
|
*
|
||||||
|
* 판정은 저장소 목록과 **같은 함수**를 쓴다 — 쿠키만 정확 일치 전용이면 운영자가 쿠키 카드에
|
||||||
|
* 적은 `myplugin_*` 이 저장소 카드와 달리 동작하지 않는다.
|
||||||
*
|
*
|
||||||
* @param name 쿠키 이름
|
* @param name 쿠키 이름
|
||||||
* @return 매칭 여부
|
* @return 매칭 여부
|
||||||
*/
|
*/
|
||||||
function matchesNecessary(name: string): boolean {
|
function matchesNecessary(name: string): boolean {
|
||||||
return config.necessaryAllowlist.includes(name);
|
return isNecessary(name, 'cookie', config.necessaryAllowlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,7 +197,7 @@ export function uninstallCookieInterceptor(): void {
|
|||||||
originalCookieDescriptor = null;
|
originalCookieDescriptor = null;
|
||||||
config = {
|
config = {
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: LOCKED_FALLBACK,
|
||||||
};
|
};
|
||||||
installed = false;
|
installed = false;
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* cleanup 정책 (Phase 2 단순화):
|
* cleanup 정책 (Phase 2 단순화):
|
||||||
* - 운영자 등록 표 (functional_storage_keys / functional_cookies) 불필요
|
* - 운영자 등록 표 (functional_storage_keys / functional_cookies) 불필요
|
||||||
* - strictly necessary allowlist (코드 상수) 외 모든 키/이름을 자동 파기
|
* - strictly necessary 허용목록(운영자 설정 ∪ 잠금 집합) 외 모든 키/이름을 자동 파기
|
||||||
* - GDPR 원칙 "strictly necessary 외 비-필수는 동의 전 차단" 의 cleanup 측 적용
|
* - GDPR 원칙 "strictly necessary 외 비-필수는 동의 전 차단" 의 cleanup 측 적용
|
||||||
*
|
*
|
||||||
* 본 함수는 인터셉터 install 이후 호출되어도 안전 — removeItem 은 인터셉터가
|
* 본 함수는 인터셉터 install 이후 호출되어도 안전 — removeItem 은 인터셉터가
|
||||||
@@ -18,51 +18,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
LOCKED_FALLBACK,
|
||||||
} from './cookieInterceptor';
|
isNecessary,
|
||||||
import {
|
type NecessaryAllowlist,
|
||||||
DEFAULT_NECESSARY_ALLOWLIST,
|
} from './necessaryAllowlist';
|
||||||
type NecessaryAllowlistEntry,
|
import { type StorageKind } from './storageInterceptor';
|
||||||
type StorageKind,
|
|
||||||
} from './storageInterceptor';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* cleanup 옵션 (선택).
|
* cleanup 옵션 (선택).
|
||||||
*
|
*
|
||||||
* @property storageAllowlist strictly necessary storage allowlist (기본: DEFAULT_NECESSARY_ALLOWLIST)
|
* @property allowlist strictly necessary 허용목록 (운영자 설정 ∪ 잠금 집합).
|
||||||
* @property cookieAllowlist strictly necessary cookie allowlist (기본: DEFAULT_NECESSARY_COOKIE_ALLOWLIST)
|
* 생략 시 잠금 집합만 남는 최소 폴백 — 설정을 읽지 못한 상황에서도
|
||||||
|
* 로그인 토큰·CSRF·동의 쿠키는 파기하지 않는다.
|
||||||
*/
|
*/
|
||||||
export interface FunctionalCleanupOptions {
|
export interface FunctionalCleanupOptions {
|
||||||
storageAllowlist?: readonly NecessaryAllowlistEntry[];
|
allowlist?: NecessaryAllowlist;
|
||||||
cookieAllowlist?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 키가 strictly necessary storage allowlist 에 매칭되는지 검사합니다.
|
|
||||||
*
|
|
||||||
* @param key 스토리지 키
|
|
||||||
* @param storage 호출 스토리지 종류
|
|
||||||
* @param allowlist allowlist 항목 배열
|
|
||||||
* @return 매칭 여부
|
|
||||||
*/
|
|
||||||
function isNecessaryStorage(
|
|
||||||
key: string,
|
|
||||||
storage: StorageKind,
|
|
||||||
allowlist: readonly NecessaryAllowlistEntry[],
|
|
||||||
): boolean {
|
|
||||||
for (const entry of allowlist) {
|
|
||||||
if (entry.storage && entry.storage !== storage) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const matchType = entry.matchType ?? 'exact';
|
|
||||||
if (matchType === 'exact' && entry.key === key) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (matchType === 'prefix' && key.startsWith(entry.key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,7 +48,7 @@ function isNecessaryStorage(
|
|||||||
function purgeStorage(
|
function purgeStorage(
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
storageKind: StorageKind,
|
storageKind: StorageKind,
|
||||||
allowlist: readonly NecessaryAllowlistEntry[],
|
allowlist: NecessaryAllowlist,
|
||||||
): void {
|
): void {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (let i = 0; i < storage.length; i++) {
|
for (let i = 0; i < storage.length; i++) {
|
||||||
@@ -87,7 +57,7 @@ function purgeStorage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (isNecessaryStorage(key, storageKind, allowlist)) {
|
if (isNecessary(key, storageKind, allowlist)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -101,10 +71,10 @@ function purgeStorage(
|
|||||||
/**
|
/**
|
||||||
* document.cookie 에서 strictly necessary allowlist 외 모든 cookie 를 Max-Age=0 으로 파기합니다.
|
* document.cookie 에서 strictly necessary allowlist 외 모든 cookie 를 Max-Age=0 으로 파기합니다.
|
||||||
*
|
*
|
||||||
* @param allowlist necessary cookie allowlist (이름 배열)
|
* @param allowlist necessary 허용목록 (`cookie` 스코프만 사용)
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
function purgeCookies(allowlist: readonly string[]): void {
|
function purgeCookies(allowlist: NecessaryAllowlist): void {
|
||||||
const raw = document.cookie;
|
const raw = document.cookie;
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
@@ -113,7 +83,7 @@ function purgeCookies(allowlist: readonly string[]): void {
|
|||||||
const eq = cookie.indexOf('=');
|
const eq = cookie.indexOf('=');
|
||||||
const name = (eq > -1 ? cookie.substring(0, eq) : cookie).trim();
|
const name = (eq > -1 ? cookie.substring(0, eq) : cookie).trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
if (allowlist.includes(name)) continue;
|
if (isNecessary(name, 'cookie', allowlist)) continue;
|
||||||
|
|
||||||
// 표준 cookie 파기 패턴 — Max-Age=0 + 빈 값 + Path=/
|
// 표준 cookie 파기 패턴 — Max-Age=0 + 빈 값 + Path=/
|
||||||
// cookieInterceptor 의 isClearingCookie 가드로 통과.
|
// cookieInterceptor 의 isClearingCookie 가드로 통과.
|
||||||
@@ -134,10 +104,9 @@ function purgeCookies(allowlist: readonly string[]): void {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
export function cleanupFunctionalArtifacts(options: FunctionalCleanupOptions = {}): void {
|
export function cleanupFunctionalArtifacts(options: FunctionalCleanupOptions = {}): void {
|
||||||
const storageAllowlist = options.storageAllowlist ?? DEFAULT_NECESSARY_ALLOWLIST;
|
const allowlist = options.allowlist ?? LOCKED_FALLBACK;
|
||||||
const cookieAllowlist = options.cookieAllowlist ?? DEFAULT_NECESSARY_COOKIE_ALLOWLIST;
|
|
||||||
|
|
||||||
purgeStorage(window.localStorage, 'localStorage', storageAllowlist);
|
purgeStorage(window.localStorage, 'localStorage', allowlist);
|
||||||
purgeStorage(window.sessionStorage, 'sessionStorage', storageAllowlist);
|
purgeStorage(window.sessionStorage, 'sessionStorage', allowlist);
|
||||||
purgeCookies(cookieAllowlist);
|
purgeCookies(allowlist);
|
||||||
}
|
}
|
||||||
@@ -29,10 +29,16 @@ import { startBlocker, setCurrentConsent, setBlockedDomains } from './blocker';
|
|||||||
import {
|
import {
|
||||||
installCookieInterceptor,
|
installCookieInterceptor,
|
||||||
updateCookieInterceptorConfig,
|
updateCookieInterceptorConfig,
|
||||||
DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
|
||||||
} from './cookieInterceptor';
|
} from './cookieInterceptor';
|
||||||
import { cleanupFunctionalArtifacts } from './functionalCleaner';
|
import { cleanupFunctionalArtifacts } from './functionalCleaner';
|
||||||
import { fetchPublicSettings, fetchConsentSnapshot } from './layouts';
|
import { fetchPublicSettings, fetchConsentSnapshot } from './layouts';
|
||||||
|
import {
|
||||||
|
LOCKED_FALLBACK,
|
||||||
|
isEmptyAllowlist,
|
||||||
|
mergeAllowlists,
|
||||||
|
normalizeAllowlist,
|
||||||
|
type NecessaryAllowlist,
|
||||||
|
} from './necessaryAllowlist';
|
||||||
import {
|
import {
|
||||||
installPreblocker,
|
installPreblocker,
|
||||||
updatePreblockerConfig,
|
updatePreblockerConfig,
|
||||||
@@ -42,7 +48,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
installStorageInterceptor,
|
installStorageInterceptor,
|
||||||
updateStorageInterceptorConfig,
|
updateStorageInterceptorConfig,
|
||||||
DEFAULT_NECESSARY_ALLOWLIST,
|
|
||||||
} from './storageInterceptor';
|
} from './storageInterceptor';
|
||||||
import { installUserInitiatedTracker } from './userInitiatedTracker';
|
import { installUserInitiatedTracker } from './userInitiatedTracker';
|
||||||
|
|
||||||
@@ -77,6 +82,30 @@ function readInlineBlockedDomains(): Record<string, readonly string[]> {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* G7Config 인라인 페이로드에서 strictly necessary 허용목록 즉시 조회.
|
||||||
|
*
|
||||||
|
* 인터셉터는 `fetchPublicSettings()` **앞에서** 선다 — 동의 전 첫 저장을 막는 것이 이
|
||||||
|
* 기능의 목적이라 응답을 기다릴 수 없다. 그래서 허용목록은 인라인으로 와야 하고,
|
||||||
|
* `defaults.json` 의 `frontend_schema` 에 `expose: true` 가 없으면 여기서 빈 목록이 된다.
|
||||||
|
*
|
||||||
|
* 판정은 언제나 **운영자 목록 ∪ 잠금 집합**이다. 잠금 집합이 통째로 비어 오면 페이로드가
|
||||||
|
* 도달하지 않았다는 뜻이므로 코드 폴백으로 내려간다 — 그러지 않으면 인터셉터가 빈 목록으로
|
||||||
|
* 서서 동의 없는 첫 방문자의 로그인 토큰까지 차단한다.
|
||||||
|
*
|
||||||
|
* @return 스코프별 허용 패턴 목록
|
||||||
|
*/
|
||||||
|
function readInlineNecessaryAllowlist(): NecessaryAllowlist {
|
||||||
|
if (typeof window === 'undefined') return LOCKED_FALLBACK;
|
||||||
|
const config = (window as unknown as { G7Config?: G7Config }).G7Config;
|
||||||
|
const pluginConfig = config?.plugins?.[PLUGIN_IDENTIFIER];
|
||||||
|
|
||||||
|
const operator = normalizeAllowlist(pluginConfig?.necessary_storage_allowlist);
|
||||||
|
const locked = normalizeAllowlist(pluginConfig?.necessary_storage_locked);
|
||||||
|
|
||||||
|
return mergeAllowlists(operator, isEmptyAllowlist(locked) ? LOCKED_FALLBACK : locked);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* banner_enabled 도 G7Config 페이로드에 인라인 — 페이지 진입 즉시 사용 가능.
|
* banner_enabled 도 G7Config 페이로드에 인라인 — 페이지 진입 즉시 사용 가능.
|
||||||
*
|
*
|
||||||
@@ -124,18 +153,19 @@ function registerSyncHandler(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Phase 2: storage / cookie 인터셉터 동기화
|
// 3. Phase 2: storage / cookie 인터셉터 동기화
|
||||||
|
const necessaryAllowlist = readInlineNecessaryAllowlist();
|
||||||
updateStorageInterceptorConfig({
|
updateStorageInterceptorConfig({
|
||||||
functionalConsented,
|
functionalConsented,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist,
|
||||||
});
|
});
|
||||||
updateCookieInterceptorConfig({
|
updateCookieInterceptorConfig({
|
||||||
functionalConsented,
|
functionalConsented,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. EDPB §117: 동의 철회 즉시 파기 — strictly necessary allowlist 외 모든 storage/cookie cleanup.
|
// 4. EDPB §117: 동의 철회 즉시 파기 — strictly necessary allowlist 외 모든 storage/cookie cleanup.
|
||||||
if (!functionalConsented) {
|
if (!functionalConsented) {
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: necessaryAllowlist });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 동의 받은 카테고리의 preblocker 차단 요소 복원 (단, needs_renewal=true 면 복원 안 함)
|
// 5. 동의 받은 카테고리의 preblocker 차단 요소 복원 (단, needs_renewal=true 면 복원 안 함)
|
||||||
@@ -173,15 +203,16 @@ async function bootstrap(): Promise<void> {
|
|||||||
// - userInitiatedTracker: 사용자 인터랙션 시각 기록 (WP29 §3.6 면제 판정용, 항상 활성)
|
// - userInitiatedTracker: 사용자 인터랙션 시각 기록 (WP29 §3.6 면제 판정용, 항상 활성)
|
||||||
// - storageInterceptor: localStorage / sessionStorage 신규 쓰기 가로채기
|
// - storageInterceptor: localStorage / sessionStorage 신규 쓰기 가로채기
|
||||||
// - cookieInterceptor: document.cookie 신규 쓰기 가로채기
|
// - cookieInterceptor: document.cookie 신규 쓰기 가로채기
|
||||||
// 모두 strictly necessary allowlist (코드 상수) 외 미동의 + 비-사용자 쓰기는 차단.
|
// 모두 strictly necessary 허용목록(운영자 설정 ∪ 잠금 집합) 외 미동의 + 비-사용자 쓰기는 차단.
|
||||||
installUserInitiatedTracker();
|
installUserInitiatedTracker();
|
||||||
|
const inlineAllowlist = readInlineNecessaryAllowlist();
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: inlineAllowlist,
|
||||||
});
|
});
|
||||||
installCookieInterceptor({
|
installCookieInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist: inlineAllowlist,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,20 +248,21 @@ async function bootstrap(): Promise<void> {
|
|||||||
snapshot !== null && !snapshot.needs_renewal && snapshot.categories.functional === true;
|
snapshot !== null && !snapshot.needs_renewal && snapshot.categories.functional === true;
|
||||||
|
|
||||||
// Phase 2: storage / cookie 인터셉터 설정 갱신 (functional 동의 여부만 반영).
|
// Phase 2: storage / cookie 인터셉터 설정 갱신 (functional 동의 여부만 반영).
|
||||||
|
const necessaryAllowlist = readInlineNecessaryAllowlist();
|
||||||
updateStorageInterceptorConfig({
|
updateStorageInterceptorConfig({
|
||||||
functionalConsented,
|
functionalConsented,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist,
|
||||||
});
|
});
|
||||||
updateCookieInterceptorConfig({
|
updateCookieInterceptorConfig({
|
||||||
functionalConsented,
|
functionalConsented,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_COOKIE_ALLOWLIST,
|
necessaryAllowlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 2: 부팅 시점 functional 미동의면 allowlist 외 모든 storage/cookie 파기 (EDPB §117).
|
// Phase 2: 부팅 시점 functional 미동의면 allowlist 외 모든 storage/cookie 파기 (EDPB §117).
|
||||||
// - 재방문 시 이전 세션의 잔류 데이터 정리
|
// - 재방문 시 이전 세션의 잔류 데이터 정리
|
||||||
// - 신규 게스트 / 거부 후 재방문 모두 동일 처리
|
// - 신규 게스트 / 거부 후 재방문 모두 동일 처리
|
||||||
if (!functionalConsented) {
|
if (!functionalConsented) {
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({ allowlist: necessaryAllowlist });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot !== null) {
|
if (snapshot !== null) {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* strictly necessary 허용목록 해석기 (necessaryAllowlist)
|
||||||
|
*
|
||||||
|
* 허용목록은 **운영자 설정**이다 (`necessary_storage_allowlist`). 이 모듈은 그 설정을
|
||||||
|
* 인라인 페이로드에서 읽어 정규화하고, 세 소비자(storageInterceptor · cookieInterceptor ·
|
||||||
|
* functionalCleaner)가 **같은 함수**로 판정하도록 매칭 규칙을 한 곳에 모은다.
|
||||||
|
*
|
||||||
|
* 목록을 소비자마다 따로 해석하면 저장소 카드는 와일드카드가 되고 쿠키 카드는 정확 일치만
|
||||||
|
* 되는 식으로 갈라진다 — 그 어긋남은 예외도 로그도 남기지 않고 "그 항목만 안 되는" 상태로만
|
||||||
|
* 나타난다.
|
||||||
|
*
|
||||||
|
* 잠금 항목(`necessary_storage_locked`)은 설정이 아니라 코드가 정한다. 운영자가 지울 수
|
||||||
|
* 있으면 잠금이 아니기 때문이다. 판정은 언제나 **운영자 목록 ∪ 잠금 집합**이다.
|
||||||
|
*
|
||||||
|
* @module sirsoft-gdpr/necessaryAllowlist
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용목록 스코프 — 저장소 두 종류 + 쿠키.
|
||||||
|
*/
|
||||||
|
export type AllowlistScope = 'localStorage' | 'sessionStorage' | 'cookie';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스코프 순서 고정 목록 (정규화·순회 기준).
|
||||||
|
*/
|
||||||
|
export const ALLOWLIST_SCOPES: readonly AllowlistScope[] = ['localStorage', 'sessionStorage', 'cookie'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스코프별 허용 패턴 목록.
|
||||||
|
*
|
||||||
|
* 패턴은 운영자 표기 그대로다 — 끝에 `*` 가 붙으면 앞부분 매칭, 없으면 정확 일치.
|
||||||
|
*/
|
||||||
|
export interface NecessaryAllowlist {
|
||||||
|
localStorage: readonly string[];
|
||||||
|
sessionStorage: readonly string[];
|
||||||
|
cookie: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인라인 페이로드를 읽지 못했을 때의 최소 폴백 — **잠금 집합만** 담는다.
|
||||||
|
*
|
||||||
|
* 출하 카탈로그 전체를 여기 복사하면 PHP 카탈로그와 두 벌이 되어, 지금 없애려는 드리프트를
|
||||||
|
* 그대로 다시 만든다. 그래서 복사하지 않는다: 인라인이 오지 않은 극단 상황에서도 로그인
|
||||||
|
* 토큰과 CSRF·동의 쿠키만은 살아 있어야 사이트가 선다.
|
||||||
|
*
|
||||||
|
* 세션 쿠키 이름은 서버 설정(`session.cookie`)이 정하는 런타임 값이라 여기 담을 수 없다.
|
||||||
|
* 그 항목이 빠지는 상황에서는 세션 쿠키의 `httpOnly` 기본값이 방어한다.
|
||||||
|
*/
|
||||||
|
export const LOCKED_FALLBACK: NecessaryAllowlist = {
|
||||||
|
localStorage: ['auth_token'],
|
||||||
|
sessionStorage: [],
|
||||||
|
cookie: ['XSRF-TOKEN', 'gdpr_session'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 허용목록을 만듭니다.
|
||||||
|
*
|
||||||
|
* @return 세 스코프가 모두 빈 배열인 허용목록
|
||||||
|
*/
|
||||||
|
export function emptyAllowlist(): NecessaryAllowlist {
|
||||||
|
return { localStorage: [], sessionStorage: [], cookie: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임의 입력을 허용목록 구조로 정규화합니다.
|
||||||
|
*
|
||||||
|
* 객체가 아니거나 스코프가 배열이 아니면 그 스코프는 빈 배열이 된다 — 값이 문자열로 실려 온
|
||||||
|
* 경우(설정 기본값을 `json_encode` 로 선언한 경우)도 여기서 걸러진다.
|
||||||
|
*
|
||||||
|
* @param raw 인라인 페이로드에서 읽은 원시 값
|
||||||
|
* @return 정규화된 허용목록
|
||||||
|
*/
|
||||||
|
export function normalizeAllowlist(raw: unknown): NecessaryAllowlist {
|
||||||
|
const result = emptyAllowlist() as { -readonly [K in AllowlistScope]: string[] };
|
||||||
|
|
||||||
|
if (raw === null || typeof raw !== 'object') {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
for (const scope of ALLOWLIST_SCOPES) {
|
||||||
|
const value = source[scope];
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[scope] = value.filter((v): v is string => typeof v === 'string' && v !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 허용목록을 스코프별 합집합으로 병합합니다.
|
||||||
|
*
|
||||||
|
* @param a 왼쪽 허용목록
|
||||||
|
* @param b 오른쪽 허용목록
|
||||||
|
* @return 병합된 허용목록 (중복 제거)
|
||||||
|
*/
|
||||||
|
export function mergeAllowlists(a: NecessaryAllowlist, b: NecessaryAllowlist): NecessaryAllowlist {
|
||||||
|
const result = emptyAllowlist() as { -readonly [K in AllowlistScope]: string[] };
|
||||||
|
|
||||||
|
for (const scope of ALLOWLIST_SCOPES) {
|
||||||
|
result[scope] = [...new Set([...a[scope], ...b[scope]])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용목록이 한 항목도 없는지 판정합니다.
|
||||||
|
*
|
||||||
|
* @param allowlist 허용목록
|
||||||
|
* @return 세 스코프가 모두 비어 있으면 true
|
||||||
|
*/
|
||||||
|
export function isEmptyAllowlist(allowlist: NecessaryAllowlist): boolean {
|
||||||
|
return ALLOWLIST_SCOPES.every((scope) => allowlist[scope].length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한 패턴이 이름에 매칭되는지 판정합니다.
|
||||||
|
*
|
||||||
|
* 끝에 `*` 가 붙으면 앞부분 매칭, 아니면 정확 일치. `*` 는 끝에만 의미가 있으며
|
||||||
|
* (검증에서 그 외 위치를 거른다) 접두사가 빈 `*` 단독 표기는 전체 개방이 되므로 매칭하지 않는다.
|
||||||
|
*
|
||||||
|
* @param name 검사할 키 또는 쿠키 이름
|
||||||
|
* @param pattern 운영자 표기 패턴
|
||||||
|
* @return 매칭 여부
|
||||||
|
*/
|
||||||
|
export function matchesAllowlistPattern(name: string, pattern: string): boolean {
|
||||||
|
if (pattern.endsWith('*')) {
|
||||||
|
const prefix = pattern.slice(0, -1);
|
||||||
|
return prefix !== '' && name.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern === name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름이 해당 스코프의 허용목록에 있는지 판정합니다.
|
||||||
|
*
|
||||||
|
* @param name 검사할 키 또는 쿠키 이름
|
||||||
|
* @param scope 스코프
|
||||||
|
* @param allowlist 허용목록
|
||||||
|
* @return 허용 여부
|
||||||
|
*/
|
||||||
|
export function isNecessary(name: string, scope: AllowlistScope, allowlist: NecessaryAllowlist): boolean {
|
||||||
|
return allowlist[scope].some((pattern) => matchesAllowlistPattern(name, pattern));
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@
|
|||||||
*
|
*
|
||||||
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
|
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
|
||||||
* 1. strictly necessary allowlist 매칭 → 항상 허용
|
* 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_*)
|
|
||||||
* 2. functional 동의 → 허용
|
* 2. functional 동의 → 허용
|
||||||
* 3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
|
* 3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
|
||||||
* 4. 그 외 → 차단
|
* 4. 그 외 → 차단
|
||||||
*
|
*
|
||||||
|
* 허용목록은 **운영자 설정**(`necessary_storage_allowlist`)이며 이 파일에 사본을 두지
|
||||||
|
* 않는다. 사본을 두면 운영자가 화면에서 추가한 항목과 코드 상수가 갈라져, 화면에는 등재된
|
||||||
|
* 키가 실제로는 파기되는 상태가 된다. 판정 함수도 세 소비자가 공유한다
|
||||||
|
* (`necessaryAllowlist.ts`).
|
||||||
|
*
|
||||||
* "운영자 등록 표" 는 제거됨 — GDPR 원칙은 "strictly necessary 외 비-필수 저장은
|
* "운영자 등록 표" 는 제거됨 — GDPR 원칙은 "strictly necessary 외 비-필수 저장은
|
||||||
* 동의 전 차단" 이므로 등록 표 없이 동일하게 모두 게이팅.
|
* 동의 전 차단" 이므로 등록 표 없이 동일하게 모두 게이팅.
|
||||||
*
|
*
|
||||||
@@ -22,6 +25,11 @@
|
|||||||
* @module sirsoft-gdpr/storageInterceptor
|
* @module sirsoft-gdpr/storageInterceptor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOCKED_FALLBACK,
|
||||||
|
isNecessary,
|
||||||
|
type NecessaryAllowlist,
|
||||||
|
} from './necessaryAllowlist';
|
||||||
import { isUserInitiated } from './userInitiatedTracker';
|
import { isUserInitiated } from './userInitiatedTracker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,90 +37,24 @@ import { isUserInitiated } from './userInitiatedTracker';
|
|||||||
*/
|
*/
|
||||||
export type StorageKind = 'localStorage' | 'sessionStorage';
|
export type StorageKind = 'localStorage' | 'sessionStorage';
|
||||||
|
|
||||||
/**
|
|
||||||
* strictly necessary allowlist 항목.
|
|
||||||
*
|
|
||||||
* @property key 정확 매칭 또는 prefix
|
|
||||||
* @property storage 적용할 스토리지 (생략 시 둘 다)
|
|
||||||
* @property matchType 'exact' (기본) 또는 'prefix'
|
|
||||||
*/
|
|
||||||
export interface NecessaryAllowlistEntry {
|
|
||||||
key: string;
|
|
||||||
storage?: StorageKind;
|
|
||||||
matchType?: 'exact' | 'prefix';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인터셉터 설정.
|
* 인터셉터 설정.
|
||||||
*
|
*
|
||||||
* @property functionalConsented functional 카테고리 동의 여부
|
* @property functionalConsented functional 카테고리 동의 여부
|
||||||
* @property necessaryAllowlist strictly necessary 면제 키 (코어 + 정적)
|
* @property necessaryAllowlist strictly necessary 허용목록 (운영자 설정 ∪ 잠금 집합)
|
||||||
*/
|
*/
|
||||||
export interface StorageInterceptorConfig {
|
export interface StorageInterceptorConfig {
|
||||||
functionalConsented: boolean;
|
functionalConsented: boolean;
|
||||||
necessaryAllowlist: readonly NecessaryAllowlistEntry[];
|
necessaryAllowlist: NecessaryAllowlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 정적 strictly necessary allowlist — G7 코어 동작에 필수인 키 + WP29 §3.6 면제 키.
|
|
||||||
*
|
|
||||||
* 본 목록은 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
|
|
||||||
*/
|
|
||||||
export const DEFAULT_NECESSARY_ALLOWLIST: readonly NecessaryAllowlistEntry[] = [
|
|
||||||
// 사용자 명시 선택 (WP29 §3.6) — 다국어 설정
|
|
||||||
{ key: 'g7_locale', 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' },
|
|
||||||
// 장바구니 게스트 키 — 익명 카트 식별 (구매 동선 필수)
|
|
||||||
{ key: 'g7_cart_key', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// devtools UI 상태 — 개발자 환경 (strictly necessary 개발자 도구)
|
|
||||||
{ key: 'g7-devtools-panel', 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
let originalSetItem: ((key: string, value: string) => void) | null = null;
|
let originalSetItem: ((key: string, value: string) => void) | null = null;
|
||||||
let config: StorageInterceptorConfig = {
|
let config: StorageInterceptorConfig = {
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: LOCKED_FALLBACK,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 키가 strictly necessary allowlist 에 매칭되는지 검사합니다.
|
|
||||||
*
|
|
||||||
* storage 인자는 호출된 storage 종류 (localStorage / sessionStorage). allowlist 엔트리에
|
|
||||||
* storage 가 명시되어 있으면 정확히 일치할 때만 매칭. 미명시면 둘 다 허용.
|
|
||||||
*
|
|
||||||
* @param key 스토리지 키
|
|
||||||
* @param storage 호출 스토리지 종류
|
|
||||||
* @return 매칭 여부
|
|
||||||
*/
|
|
||||||
function matchesNecessary(key: string, storage: StorageKind): boolean {
|
|
||||||
for (const entry of config.necessaryAllowlist) {
|
|
||||||
if (entry.storage && entry.storage !== storage) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const matchType = entry.matchType ?? 'exact';
|
|
||||||
if (matchType === 'exact' && entry.key === key) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (matchType === 'prefix' && key.startsWith(entry.key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* setItem 호출이 허용되는지 판정합니다.
|
* setItem 호출이 허용되는지 판정합니다.
|
||||||
*
|
*
|
||||||
@@ -129,7 +71,7 @@ function matchesNecessary(key: string, storage: StorageKind): boolean {
|
|||||||
* @return 허용 여부
|
* @return 허용 여부
|
||||||
*/
|
*/
|
||||||
export function isStorageAllowed(key: string, storage: StorageKind): boolean {
|
export function isStorageAllowed(key: string, storage: StorageKind): boolean {
|
||||||
if (matchesNecessary(key, storage)) {
|
if (isNecessary(key, storage, config.necessaryAllowlist)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +144,7 @@ export function uninstallStorageInterceptor(): void {
|
|||||||
originalSetItem = null;
|
originalSetItem = null;
|
||||||
config = {
|
config = {
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
necessaryAllowlist: LOCKED_FALLBACK,
|
||||||
};
|
};
|
||||||
installed = false;
|
installed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*
|
*
|
||||||
* WP29 Opinion 04/2012 §3.6 + EDPB Guidelines 2/2023 §16 의 "user-initiated
|
* WP29 Opinion 04/2012 §3.6 + EDPB Guidelines 2/2023 §16 의 "user-initiated
|
||||||
* preference" 면제 적용 — functional 카테고리 미동의 상태에서도 사용자가 직접
|
* preference" 면제 적용 — functional 카테고리 미동의 상태에서도 사용자가 직접
|
||||||
* 트리거한 설정 저장 (다크모드 토글, 통화 변경 등) 은 허용해야 함. 그렇지 않으면
|
* 트리거한 설정 저장 (통화 변경 등) 은 허용해야 함. 그렇지 않으면
|
||||||
* 메뉴 클릭 시점에 setItem 차단되어 UX 가 무너짐.
|
* 메뉴 클릭 시점에 setItem 차단되어 UX 가 무너짐.
|
||||||
*
|
*
|
||||||
* 판정 기준:
|
* 판정 기준:
|
||||||
|
|||||||
@@ -40,6 +40,16 @@
|
|||||||
"too_long_marketing": "Marketing — Domain cannot exceed 253 characters.",
|
"too_long_marketing": "Marketing — Domain cannot exceed 253 characters.",
|
||||||
"must_be_array": "Each category must be an array of domain strings."
|
"must_be_array": "Each category must be an array of domain strings."
|
||||||
},
|
},
|
||||||
|
"necessary_storage_allowlist": {
|
||||||
|
"invalid_format_local_storage": "Local storage — invalid item format. (Letters, digits and _ . : @ + - ; a single trailing * is allowed)",
|
||||||
|
"invalid_format_session_storage": "Session storage — invalid item format. (Letters, digits and _ . : @ + - ; a single trailing * is allowed)",
|
||||||
|
"invalid_format_cookie": "Cookie — invalid item format. (Letters, digits and _ . : @ + - ; a single trailing * is allowed)",
|
||||||
|
"too_long_local_storage": "Local storage — item cannot exceed 128 characters.",
|
||||||
|
"too_long_session_storage": "Session storage — item cannot exceed 128 characters.",
|
||||||
|
"too_long_cookie": "Cookie — item cannot exceed 128 characters.",
|
||||||
|
"must_be_array": "Each storage scope must be an array of item names.",
|
||||||
|
"invalid_scope": "Unknown storage scope: :scope (only local storage, session storage and cookies are allowed)"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"saved": "Settings have been saved.",
|
"saved": "Settings have been saved.",
|
||||||
"title": "GDPR Settings",
|
"title": "GDPR Settings",
|
||||||
@@ -53,12 +63,15 @@
|
|||||||
"cookie_categories": "Cookie Categories",
|
"cookie_categories": "Cookie Categories",
|
||||||
"cookie_policy_version": "Cookie Policy Version",
|
"cookie_policy_version": "Cookie Policy Version",
|
||||||
"auto_blocking": "Auto-blocking Policy",
|
"auto_blocking": "Auto-blocking Policy",
|
||||||
"auto_blocking_desc": "When the cookie banner is shown, external tracking resources matching the blocked-domain lists below are auto-blocked until the user consents."
|
"auto_blocking_desc": "When the cookie banner is shown, external tracking resources matching the blocked-domain lists below are auto-blocked until the user consents.",
|
||||||
|
"necessary_storage": "Strictly Necessary Storage",
|
||||||
|
"necessary_storage_desc": "Manage the items allowed to persist for visitors who have not consented to functional cookies. Anything not listed is cleared on every visit."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"operator": "Operator",
|
"operator": "Operator",
|
||||||
"cookie_banner": "Cookie Banner",
|
"cookie_banner": "Cookie Banner",
|
||||||
"auto_blocking_policy": "Auto-blocking Policy"
|
"auto_blocking_policy": "Auto-blocking Policy",
|
||||||
|
"necessary_storage": "Necessary Storage"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
@@ -113,6 +126,27 @@
|
|||||||
"marketing": "Marketing — Blocked Domains"
|
"marketing": "Marketing — Blocked Domains"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"necessary_storage_allowlist": {
|
||||||
|
"warnings_title": "Notes",
|
||||||
|
"warning_purpose": "Only the items listed here survive without functional consent. Anything else is cleared on every visit, with no error or warning — it surfaces only as a setting that will not save.",
|
||||||
|
"warning_wildcard": "A trailing * matches every name with that prefix. For example g7_filters_* covers g7_filters_orders_1. The * is only allowed at the end.",
|
||||||
|
"warning_discovery": "The item names a newly installed extension writes are listed in that extension documentation, or visible in browser devtools (Application → Storage).",
|
||||||
|
"warning_necessary_only": "List only what the site genuinely needs to work. Adding tracking or analytics items defeats the pre-consent blocking principle.",
|
||||||
|
"locked_label": "Locked items (cannot be removed)",
|
||||||
|
"locked_hint": "The site cannot operate without these. They cannot be removed from the list.",
|
||||||
|
"tag_input_placeholder": "Type an item name and press Enter (e.g., g7_locale, myplugin_*)",
|
||||||
|
"tag_no_options": "No suggestions. Type to add.",
|
||||||
|
"scope": {
|
||||||
|
"local_storage": "Local storage (localStorage)",
|
||||||
|
"session_storage": "Session storage (sessionStorage)",
|
||||||
|
"cookie": "Cookies"
|
||||||
|
},
|
||||||
|
"scope_desc": {
|
||||||
|
"local_storage": "Persists after the browser is closed. User choices such as language and theme, and admin display preferences, live here.",
|
||||||
|
"session_storage": "Cleared when the tab closes. Used to restore the original screen when returning from a payment or identity verification window.",
|
||||||
|
"cookie": "Values the browser sends back to the server. Both server-set and client-written cookies are judged by this list."
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie_categories": {
|
"cookie_categories": {
|
||||||
"description": "Open each card's [Show details] to see the purpose of the four cookie categories and the blocked-tool examples.",
|
"description": "Open each card's [Show details] to see the purpose of the four cookie categories and the blocked-tool examples.",
|
||||||
"category_required_badge": "Required",
|
"category_required_badge": "Required",
|
||||||
@@ -124,11 +158,11 @@
|
|||||||
"scope_label": "Auto-blocking scope",
|
"scope_label": "Auto-blocking scope",
|
||||||
"tools_label": "Common tools",
|
"tools_label": "Common tools",
|
||||||
"necessary": {
|
"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.",
|
"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, etc. (no configuration required)"
|
"tools": "Session IDs, authentication tokens, CSRF tokens, shopping basket identifier, language preference, display theme, etc. (no configuration required)"
|
||||||
},
|
},
|
||||||
"functional": {
|
"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."
|
"tools": "Customer support chatbots (Crisp, Intercom, Tawk.to), translation widgets, user-preference synchronization services, etc."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
|
|||||||
@@ -40,6 +40,16 @@
|
|||||||
"too_long_marketing": "마케팅 카테고리 — 도메인은 253자를 초과할 수 없습니다.",
|
"too_long_marketing": "마케팅 카테고리 — 도메인은 253자를 초과할 수 없습니다.",
|
||||||
"must_be_array": "카테고리별 도메인 목록은 배열이어야 합니다."
|
"must_be_array": "카테고리별 도메인 목록은 배열이어야 합니다."
|
||||||
},
|
},
|
||||||
|
"necessary_storage_allowlist": {
|
||||||
|
"invalid_format_local_storage": "브라우저 저장소 — 항목 형식이 올바르지 않습니다. (영문·숫자와 _ . : @ + - 사용, 끝에 * 하나만 가능)",
|
||||||
|
"invalid_format_session_storage": "세션 저장소 — 항목 형식이 올바르지 않습니다. (영문·숫자와 _ . : @ + - 사용, 끝에 * 하나만 가능)",
|
||||||
|
"invalid_format_cookie": "쿠키 — 항목 형식이 올바르지 않습니다. (영문·숫자와 _ . : @ + - 사용, 끝에 * 하나만 가능)",
|
||||||
|
"too_long_local_storage": "브라우저 저장소 — 항목은 128자를 초과할 수 없습니다.",
|
||||||
|
"too_long_session_storage": "세션 저장소 — 항목은 128자를 초과할 수 없습니다.",
|
||||||
|
"too_long_cookie": "쿠키 — 항목은 128자를 초과할 수 없습니다.",
|
||||||
|
"must_be_array": "저장소별 항목 목록은 배열이어야 합니다.",
|
||||||
|
"invalid_scope": "알 수 없는 저장소 구분입니다: :scope (브라우저 저장소·세션 저장소·쿠키만 사용할 수 있습니다)"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"saved": "설정이 저장되었습니다.",
|
"saved": "설정이 저장되었습니다.",
|
||||||
"title": "GDPR 설정",
|
"title": "GDPR 설정",
|
||||||
@@ -53,12 +63,15 @@
|
|||||||
"cookie_categories": "쿠키 카테고리",
|
"cookie_categories": "쿠키 카테고리",
|
||||||
"cookie_policy_version": "쿠키 정책 버전",
|
"cookie_policy_version": "쿠키 정책 버전",
|
||||||
"auto_blocking": "자동 차단 정책",
|
"auto_blocking": "자동 차단 정책",
|
||||||
"auto_blocking_desc": "쿠키 배너가 노출되면 카테고리별 도메인 목록의 외부 추적 리소스가 사용자 동의 전까지 자동 차단됩니다."
|
"auto_blocking_desc": "쿠키 배너가 노출되면 카테고리별 도메인 목록의 외부 추적 리소스가 사용자 동의 전까지 자동 차단됩니다.",
|
||||||
|
"necessary_storage": "필수 저장 항목",
|
||||||
|
"necessary_storage_desc": "기능 쿠키에 동의하지 않은 방문자에게도 저장이 허용되는 항목을 관리합니다. 목록에 없는 항목은 방문할 때마다 지워집니다."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"operator": "운영 정보",
|
"operator": "운영 정보",
|
||||||
"cookie_banner": "쿠키 배너",
|
"cookie_banner": "쿠키 배너",
|
||||||
"auto_blocking_policy": "자동 차단 정책"
|
"auto_blocking_policy": "자동 차단 정책",
|
||||||
|
"necessary_storage": "필수 저장 항목"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enabled": "활성",
|
"enabled": "활성",
|
||||||
@@ -113,6 +126,27 @@
|
|||||||
"marketing": "마케팅 카테고리 차단 도메인"
|
"marketing": "마케팅 카테고리 차단 도메인"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"necessary_storage_allowlist": {
|
||||||
|
"warnings_title": "안내",
|
||||||
|
"warning_purpose": "여기 등록한 항목만 기능 쿠키 미동의 상태에서 저장이 유지됩니다. 목록에 없는 항목은 방문할 때마다 지워지며, 오류나 경고 없이 「설정이 저장되지 않는」 증상으로만 나타납니다.",
|
||||||
|
"warning_wildcard": "끝에 * 를 붙이면 앞부분이 같은 항목을 모두 포함합니다. 예: g7_filters_* 는 g7_filters_orders_1 을 포함합니다. * 는 끝에만 쓸 수 있습니다.",
|
||||||
|
"warning_discovery": "새로 설치한 확장이 저장하는 항목 이름은 그 확장의 문서나 브라우저 개발자 도구(Application → Storage)에서 확인할 수 있습니다.",
|
||||||
|
"warning_necessary_only": "사이트 운영에 반드시 필요한 항목만 등록하세요. 추적·분석 목적의 항목을 넣으면 동의 전 차단 원칙이 무너집니다.",
|
||||||
|
"locked_label": "잠금 항목 (삭제 불가)",
|
||||||
|
"locked_hint": "없으면 사이트가 동작하지 않는 항목입니다. 목록에서 뺄 수 없습니다.",
|
||||||
|
"tag_input_placeholder": "항목 이름 입력 후 Enter (예: g7_locale, myplugin_*)",
|
||||||
|
"tag_no_options": "추천 항목이 없습니다. 직접 입력하세요.",
|
||||||
|
"scope": {
|
||||||
|
"local_storage": "브라우저 저장소 (localStorage)",
|
||||||
|
"session_storage": "세션 저장소 (sessionStorage)",
|
||||||
|
"cookie": "쿠키"
|
||||||
|
},
|
||||||
|
"scope_desc": {
|
||||||
|
"local_storage": "브라우저를 닫아도 남는 저장 공간. 언어·테마 같은 사용자 선택과 관리자 화면 표시 설정이 여기 저장됩니다.",
|
||||||
|
"session_storage": "탭을 닫으면 사라지는 저장 공간. 결제창·본인인증 창에서 돌아올 때 원래 화면을 복원하는 데 쓰입니다.",
|
||||||
|
"cookie": "브라우저가 서버에 함께 보내는 값. 서버가 심는 쿠키와 화면이 쓰는 쿠키 모두 이 목록으로 판정합니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie_categories": {
|
"cookie_categories": {
|
||||||
"description": "쿠키 카테고리 4종의 용도와 차단 도구 예시는 각 카드의 [정보 펼치기] 에서 확인할 수 있습니다.",
|
"description": "쿠키 카테고리 4종의 용도와 차단 도구 예시는 각 카드의 [정보 펼치기] 에서 확인할 수 있습니다.",
|
||||||
"category_required_badge": "필수",
|
"category_required_badge": "필수",
|
||||||
@@ -124,11 +158,11 @@
|
|||||||
"scope_label": "자동 차단 대상",
|
"scope_label": "자동 차단 대상",
|
||||||
"tools_label": "대표 도구 예시",
|
"tools_label": "대표 도구 예시",
|
||||||
"necessary": {
|
"necessary": {
|
||||||
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
|
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
|
||||||
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정 등 (별도 설정 불필요)"
|
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정, 화면 테마 등 (별도 설정 불필요)"
|
||||||
},
|
},
|
||||||
"functional": {
|
"functional": {
|
||||||
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 다크모드·통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
|
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
|
||||||
"tools": "고객지원 챗봇 (Crisp, Intercom, Tawk.to), 다국어 자동 번역 위젯, 사용자 설정 동기화 서비스 등"
|
"tools": "고객지원 챗봇 (Crisp, Intercom, Tawk.to), 다국어 자동 번역 위젯, 사용자 설정 동기화 서비스 등"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
|
|||||||
@@ -152,6 +152,10 @@
|
|||||||
{
|
{
|
||||||
"id": "card_auto_blocking_policy",
|
"id": "card_auto_blocking_policy",
|
||||||
"label": "$t:sirsoft-gdpr.settings.nav.auto_blocking_policy"
|
"label": "$t:sirsoft-gdpr.settings.nav.auto_blocking_policy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_necessary_storage",
|
||||||
|
"label": "$t:sirsoft-gdpr.settings.nav.necessary_storage"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"className": "flex gap-1",
|
"className": "flex gap-1",
|
||||||
@@ -1885,6 +1889,580 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_necessary_storage",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "필수 저장 항목 허용목록 — 기능 쿠키 미동의 상태에서도 저장이 허용되는 항목을 운영자가 편집한다. 자동 차단 카드와 달리 쿠키 배너 노출 토글 뒤에 두지 않는다: 배너를 꺼도 저장 게이팅은 동작하므로 항상 보여야 한다.",
|
||||||
|
"props": {
|
||||||
|
"className": "mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 scroll-mt-32"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "card_necessary_storage_header",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "panel-header-row flex-between"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "H3",
|
||||||
|
"props": {
|
||||||
|
"className": "section-heading-md"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.section.necessary_storage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "form-hint"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.section.necessary_storage_desc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_necessary_storage_fields",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "p-6 space-y-6"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_warnings_box",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "안내 — 목적 / 와일드카드 표기 / 항목 이름 찾는 법 / 필수 항목만 등록.",
|
||||||
|
"props": {
|
||||||
|
"className": "bg-blue-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 border-l-4 border-l-blue-500 dark:border-l-blue-400 rounded-lg p-4"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2 mb-3"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "circle-info",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-blue-600 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "H4",
|
||||||
|
"props": {
|
||||||
|
"className": "text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.warnings_title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Ul",
|
||||||
|
"props": {
|
||||||
|
"className": "list-disc list-inside space-y-1.5 text-xs text-gray-800 dark:text-gray-100 leading-relaxed"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Li",
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.warning_purpose"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Li",
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.warning_wildcard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Li",
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.warning_discovery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Li",
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.warning_necessary_only"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_scope_cards",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "저장소별 카드 3개 — 스코프가 고정 3개이므로 iteration 대신 정적 카드로 둔다.",
|
||||||
|
"props": {
|
||||||
|
"className": "row-stack"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_card_localStorage",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "localStorage 스코프 카드. 이 스코프에 422 검증 에러가 있으면 외곽 테두리를 빨간색으로 강조한다. 항목 인덱스가 0이 아닐 수 있으므로 prefix 로 시작하는 키 어느 것이든 존재 여부를 검사한다(some).",
|
||||||
|
"props": {
|
||||||
|
"className": "{{Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.localStorage.')) ? 'border border-red-500 dark:border-red-500 rounded-lg p-4 space-y-3 bg-red-50/30 dark:bg-red-900/10' : 'border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3'}}"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "hard-drive",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-sky-600 dark:text-sky-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Label",
|
||||||
|
"props": {
|
||||||
|
"className": "form-label"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.scope.local_storage"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "form-hint"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.scope_desc.local_storage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_locked_localStorage",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "잠금 항목 읽기 전용 행. 잠금 항목은 설정이 아니라 코드가 정하므로 편집 대상 값(_local.form)에 넣지 않고 여기에만 표시한다 — TagInput 은 칩 단위 잠금을 지원하지 않아 분리 표시가 유일하게 확실한 방법이다.",
|
||||||
|
"if": "{{(gdprSettings?.data?.settings?.necessary_storage_locked?.localStorage ?? []).length > 0}}",
|
||||||
|
"props": {
|
||||||
|
"className": "rounded-md bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3 space-y-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "lock",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-gray-500 dark:text-gray-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"props": {
|
||||||
|
"className": "text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.locked_label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex flex-wrap gap-1.5"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"comment": "잠금 칩 — 편집 가능한 칩과 시각적으로 구분되도록 흐리게(opacity) + 금지 커서로 표시한다.",
|
||||||
|
"props": {
|
||||||
|
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-70 cursor-not-allowed"
|
||||||
|
},
|
||||||
|
"iteration": {
|
||||||
|
"source": "gdprSettings?.data?.settings?.necessary_storage_locked?.localStorage ?? []",
|
||||||
|
"item_var": "lockedKey",
|
||||||
|
"index_var": "lockedIdx"
|
||||||
|
},
|
||||||
|
"text": "{{lockedKey}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.locked_hint"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_input_localStorage",
|
||||||
|
"type": "composite",
|
||||||
|
"name": "TagInput",
|
||||||
|
"comment": "항목 칩 입력. TagInput onChange 는 createFakeEvent 로 { target: { value: string[] } } 형태의 가짜 이벤트를 보내므로 $event.target.value 로 실제 배열을 꺼낸다($event 를 그대로 넣으면 칩이 [object Object] 로 표시된다). options: 출하 카탈로그 추천 (자동완성)",
|
||||||
|
"props": {
|
||||||
|
"value": "{{_local.form?.necessary_storage_allowlist?.localStorage ?? []}}",
|
||||||
|
"options": "{{((gdprSettings?.data?.default_necessary_allowlist_preview?.localStorage) ?? []).map(k => ({ value: k, label: k }))}}",
|
||||||
|
"creatable": true,
|
||||||
|
"placeholder": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.tag_input_placeholder",
|
||||||
|
"noOptionsMessage": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.tag_no_options"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "change",
|
||||||
|
"handler": "setState",
|
||||||
|
"params": {
|
||||||
|
"target": "local",
|
||||||
|
"form.necessary_storage_allowlist": "{{({...(_local.form?.necessary_storage_allowlist ?? {}), localStorage: ($event?.target?.value ?? [])})}}",
|
||||||
|
"hasChanges": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"comment": "스코프 내 첫 잘못된 항목의 에러 메시지 (인덱스 무관). 운영자가 첫 에러를 정정하면 다음 에러가 자연스럽게 노출된다.",
|
||||||
|
"if": "{{Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.localStorage.'))}}",
|
||||||
|
"props": {
|
||||||
|
"className": "form-error"
|
||||||
|
},
|
||||||
|
"text": "{{(Object.entries(_local.errors ?? {}).find(([k]) => k.startsWith('necessary_storage_allowlist.localStorage.'))?.[1]?.[0]) ?? ''}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_card_sessionStorage",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "sessionStorage 스코프 카드. 이 스코프에 422 검증 에러가 있으면 외곽 테두리를 빨간색으로 강조한다. 항목 인덱스가 0이 아닐 수 있으므로 prefix 로 시작하는 키 어느 것이든 존재 여부를 검사한다(some).",
|
||||||
|
"props": {
|
||||||
|
"className": "{{Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.sessionStorage.')) ? 'border border-red-500 dark:border-red-500 rounded-lg p-4 space-y-3 bg-red-50/30 dark:bg-red-900/10' : 'border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3'}}"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "clock-rotate-left",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-amber-600 dark:text-amber-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Label",
|
||||||
|
"props": {
|
||||||
|
"className": "form-label"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.scope.session_storage"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "form-hint"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.scope_desc.session_storage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_locked_sessionStorage",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "잠금 항목 읽기 전용 행. 잠금 항목은 설정이 아니라 코드가 정하므로 편집 대상 값(_local.form)에 넣지 않고 여기에만 표시한다 — TagInput 은 칩 단위 잠금을 지원하지 않아 분리 표시가 유일하게 확실한 방법이다.",
|
||||||
|
"if": "{{(gdprSettings?.data?.settings?.necessary_storage_locked?.sessionStorage ?? []).length > 0}}",
|
||||||
|
"props": {
|
||||||
|
"className": "rounded-md bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3 space-y-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "lock",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-gray-500 dark:text-gray-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"props": {
|
||||||
|
"className": "text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.locked_label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex flex-wrap gap-1.5"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"comment": "잠금 칩 — 편집 가능한 칩과 시각적으로 구분되도록 흐리게(opacity) + 금지 커서로 표시한다.",
|
||||||
|
"props": {
|
||||||
|
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-70 cursor-not-allowed"
|
||||||
|
},
|
||||||
|
"iteration": {
|
||||||
|
"source": "gdprSettings?.data?.settings?.necessary_storage_locked?.sessionStorage ?? []",
|
||||||
|
"item_var": "lockedKey",
|
||||||
|
"index_var": "lockedIdx"
|
||||||
|
},
|
||||||
|
"text": "{{lockedKey}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.locked_hint"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_input_sessionStorage",
|
||||||
|
"type": "composite",
|
||||||
|
"name": "TagInput",
|
||||||
|
"comment": "항목 칩 입력. TagInput onChange 는 createFakeEvent 로 { target: { value: string[] } } 형태의 가짜 이벤트를 보내므로 $event.target.value 로 실제 배열을 꺼낸다($event 를 그대로 넣으면 칩이 [object Object] 로 표시된다). options: 출하 카탈로그 추천 (자동완성)",
|
||||||
|
"props": {
|
||||||
|
"value": "{{_local.form?.necessary_storage_allowlist?.sessionStorage ?? []}}",
|
||||||
|
"options": "{{((gdprSettings?.data?.default_necessary_allowlist_preview?.sessionStorage) ?? []).map(k => ({ value: k, label: k }))}}",
|
||||||
|
"creatable": true,
|
||||||
|
"placeholder": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.tag_input_placeholder",
|
||||||
|
"noOptionsMessage": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.tag_no_options"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "change",
|
||||||
|
"handler": "setState",
|
||||||
|
"params": {
|
||||||
|
"target": "local",
|
||||||
|
"form.necessary_storage_allowlist": "{{({...(_local.form?.necessary_storage_allowlist ?? {}), sessionStorage: ($event?.target?.value ?? [])})}}",
|
||||||
|
"hasChanges": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"comment": "스코프 내 첫 잘못된 항목의 에러 메시지 (인덱스 무관). 운영자가 첫 에러를 정정하면 다음 에러가 자연스럽게 노출된다.",
|
||||||
|
"if": "{{Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.sessionStorage.'))}}",
|
||||||
|
"props": {
|
||||||
|
"className": "form-error"
|
||||||
|
},
|
||||||
|
"text": "{{(Object.entries(_local.errors ?? {}).find(([k]) => k.startsWith('necessary_storage_allowlist.sessionStorage.'))?.[1]?.[0]) ?? ''}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_card_cookie",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "cookie 스코프 카드. 이 스코프에 422 검증 에러가 있으면 외곽 테두리를 빨간색으로 강조한다. 항목 인덱스가 0이 아닐 수 있으므로 prefix 로 시작하는 키 어느 것이든 존재 여부를 검사한다(some).",
|
||||||
|
"props": {
|
||||||
|
"className": "{{Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.cookie.')) ? 'border border-red-500 dark:border-red-500 rounded-lg p-4 space-y-3 bg-red-50/30 dark:bg-red-900/10' : 'border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3'}}"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "cookie-bite",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-rose-600 dark:text-rose-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Label",
|
||||||
|
"props": {
|
||||||
|
"className": "form-label"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.scope.cookie"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "form-hint"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.scope_desc.cookie"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_locked_cookie",
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"comment": "잠금 항목 읽기 전용 행. 잠금 항목은 설정이 아니라 코드가 정하므로 편집 대상 값(_local.form)에 넣지 않고 여기에만 표시한다 — TagInput 은 칩 단위 잠금을 지원하지 않아 분리 표시가 유일하게 확실한 방법이다.",
|
||||||
|
"if": "{{(gdprSettings?.data?.settings?.necessary_storage_locked?.cookie ?? []).length > 0}}",
|
||||||
|
"props": {
|
||||||
|
"className": "rounded-md bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3 space-y-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex-center gap-2"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Icon",
|
||||||
|
"props": {
|
||||||
|
"name": "lock",
|
||||||
|
"size": "sm",
|
||||||
|
"className": "text-gray-500 dark:text-gray-400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"props": {
|
||||||
|
"className": "text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.locked_label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Div",
|
||||||
|
"props": {
|
||||||
|
"className": "flex flex-wrap gap-1.5"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"comment": "잠금 칩 — 편집 가능한 칩과 시각적으로 구분되도록 흐리게(opacity) + 금지 커서로 표시한다.",
|
||||||
|
"props": {
|
||||||
|
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-70 cursor-not-allowed"
|
||||||
|
},
|
||||||
|
"iteration": {
|
||||||
|
"source": "gdprSettings?.data?.settings?.necessary_storage_locked?.cookie ?? []",
|
||||||
|
"item_var": "lockedKey",
|
||||||
|
"index_var": "lockedIdx"
|
||||||
|
},
|
||||||
|
"text": "{{lockedKey}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "P",
|
||||||
|
"props": {
|
||||||
|
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
},
|
||||||
|
"text": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.locked_hint"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "necessary_storage_input_cookie",
|
||||||
|
"type": "composite",
|
||||||
|
"name": "TagInput",
|
||||||
|
"comment": "항목 칩 입력. TagInput onChange 는 createFakeEvent 로 { target: { value: string[] } } 형태의 가짜 이벤트를 보내므로 $event.target.value 로 실제 배열을 꺼낸다($event 를 그대로 넣으면 칩이 [object Object] 로 표시된다). options: 출하 카탈로그 추천 (자동완성)",
|
||||||
|
"props": {
|
||||||
|
"value": "{{_local.form?.necessary_storage_allowlist?.cookie ?? []}}",
|
||||||
|
"options": "{{((gdprSettings?.data?.default_necessary_allowlist_preview?.cookie) ?? []).map(k => ({ value: k, label: k }))}}",
|
||||||
|
"creatable": true,
|
||||||
|
"placeholder": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.tag_input_placeholder",
|
||||||
|
"noOptionsMessage": "$t:sirsoft-gdpr.settings.fields.necessary_storage_allowlist.tag_no_options"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "change",
|
||||||
|
"handler": "setState",
|
||||||
|
"params": {
|
||||||
|
"target": "local",
|
||||||
|
"form.necessary_storage_allowlist": "{{({...(_local.form?.necessary_storage_allowlist ?? {}), cookie: ($event?.target?.value ?? [])})}}",
|
||||||
|
"hasChanges": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "basic",
|
||||||
|
"name": "Span",
|
||||||
|
"comment": "스코프 내 첫 잘못된 항목의 에러 메시지 (인덱스 무관). 운영자가 첫 에러를 정정하면 다음 에러가 자연스럽게 노출된다.",
|
||||||
|
"if": "{{Object.keys(_local.errors ?? {}).some(k => k.startsWith('necessary_storage_allowlist.cookie.'))}}",
|
||||||
|
"props": {
|
||||||
|
"className": "form-error"
|
||||||
|
},
|
||||||
|
"text": "{{(Object.entries(_local.errors ?? {}).find(([k]) => k.startsWith('necessary_storage_allowlist.cookie.'))?.[1]?.[0]) ?? ''}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+7
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\AdminBaseController;
|
|||||||
use App\Services\PluginSettingsService;
|
use App\Services\PluginSettingsService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Plugins\Sirsoft\Gdpr\Http\Requests\UpdateAdminSettingsRequest;
|
use Plugins\Sirsoft\Gdpr\Http\Requests\UpdateAdminSettingsRequest;
|
||||||
|
use Plugins\Sirsoft\Gdpr\Plugin;
|
||||||
use Plugins\Sirsoft\Gdpr\Services\GdprSettingsService;
|
use Plugins\Sirsoft\Gdpr\Services\GdprSettingsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,10 @@ class GdprAdminSettingsController extends AdminBaseController
|
|||||||
/**
|
/**
|
||||||
* 현재 GDPR 플러그인 설정 전체를 반환합니다 (관리자 화면 폼 바인딩용).
|
* 현재 GDPR 플러그인 설정 전체를 반환합니다 (관리자 화면 폼 바인딩용).
|
||||||
*
|
*
|
||||||
|
* 출하 기본 카탈로그 두 벌을 함께 싣습니다. 관리자 화면의 TagInput 이 이 값을 자동완성
|
||||||
|
* 추천으로 쓰는데, 응답에 없으면 드롭다운에 **이미 선택된 칩만** 다시 나타나 추천이
|
||||||
|
* 동작하는 것처럼 보인다 — 오류도 빈 목록도 남지 않아 증상만으로는 알 수 없다.
|
||||||
|
*
|
||||||
* @return JsonResponse
|
* @return JsonResponse
|
||||||
*/
|
*/
|
||||||
public function show(): JsonResponse
|
public function show(): JsonResponse
|
||||||
@@ -53,6 +58,8 @@ class GdprAdminSettingsController extends AdminBaseController
|
|||||||
|
|
||||||
return ResponseHelper::success('common.success', [
|
return ResponseHelper::success('common.success', [
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
|
'default_blocked_domains_preview' => Plugin::DEFAULT_BLOCKED_DOMAINS_CATALOG,
|
||||||
|
'default_necessary_allowlist_preview' => Plugin::DEFAULT_NECESSARY_ALLOWLIST_CATALOG,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,26 @@ use Illuminate\Contracts\Container\BindingResolutionException;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
|
use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie;
|
||||||
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
|
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
|
||||||
|
use Plugins\Sirsoft\Gdpr\Support\NecessaryAllowlist;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Functional Cookie 동의 게이팅 미들웨어 (Phase 2 단순화 버전)
|
* Functional Cookie 동의 게이팅 미들웨어 (Phase 2 단순화 버전)
|
||||||
*
|
*
|
||||||
* EDPB Guidelines 2/2023 §16 (사전 차단) 충족:
|
* EDPB Guidelines 2/2023 §16 (사전 차단) 충족:
|
||||||
* - functional 미동의 시 응답 Set-Cookie 헤더에서 strictly necessary allowlist 외 모든 cookie 제거
|
* - functional 미동의 시 응답 Set-Cookie 헤더에서 strictly necessary 허용목록 외 모든 cookie 제거
|
||||||
* - 운영자가 등록한 functional cookie 목록은 사용하지 않음 — GDPR 원칙은
|
* - 운영자가 등록한 "허용" functional cookie 목록은 사용하지 않음 — GDPR 원칙은
|
||||||
* "strictly necessary 외 비-필수는 동의 전 차단" 이므로 등록 표 불필요
|
* "strictly necessary 외 비-필수는 동의 전 차단" 이므로 등록 표 불필요
|
||||||
*
|
*
|
||||||
|
* strictly necessary 판정 목록은 운영자 설정(`necessary_storage_allowlist.cookie`)과 잠금
|
||||||
|
* 집합의 합집합이며, 클라이언트 인터셉터와 같은 출처·같은 매칭 규칙을 씁니다
|
||||||
|
* (`Support\NecessaryAllowlist`). 서버와 클라이언트가 각자 목록을 들고 있으면 한쪽에서만
|
||||||
|
* 살아 있는 항목이 생기는데, 그 어긋남은 예외도 로그도 남기지 않습니다.
|
||||||
|
*
|
||||||
* 파기 cookie (cleared) 는 항상 통과 — EDPB §117 (철회 즉시 파기) 와 본 §16 차단이 충돌하지 않도록.
|
* 파기 cookie (cleared) 는 항상 통과 — EDPB §117 (철회 즉시 파기) 와 본 §16 차단이 충돌하지 않도록.
|
||||||
*
|
*
|
||||||
* 등록 시점: `GdprServiceProvider::boot()` 에서 Laravel 11+ Kernel::prependMiddlewareToGroup('web'|'api') 호출.
|
* 등록 시점: `plugin.php::getMiddleware()` 선언 (web·api 그룹, `before_core`) — 코어가 그 선언을
|
||||||
* Kernel.php:347 `array_search` 중복 방지로 매 요청 호출되어도 1회만 등록됨.
|
* 읽어 부착합니다. 확장이 Kernel 미들웨어 그룹을 직접 조작하지 않습니다.
|
||||||
*
|
*
|
||||||
* @since 1.0.0-beta.1 (Phase 2)
|
* @since 1.0.0-beta.1 (Phase 2)
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +48,7 @@ class CookieConsentMiddleware
|
|||||||
*
|
*
|
||||||
* @param Request $request HTTP 요청
|
* @param Request $request HTTP 요청
|
||||||
* @param Closure $next 다음 미들웨어
|
* @param Closure $next 다음 미들웨어
|
||||||
* @return Response HTTP 응답 (functional cookie 게이팅 적용)
|
* @return Response HTTP 응답 (functional cookie 게이팅 적용)
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
@@ -52,8 +58,8 @@ class CookieConsentMiddleware
|
|||||||
// 1. functional 동의 여부 조회
|
// 1. functional 동의 여부 조회
|
||||||
//
|
//
|
||||||
// 자기 자신 (sirsoft-gdpr) 제거 응답 race condition 안전 통과:
|
// 자기 자신 (sirsoft-gdpr) 제거 응답 race condition 안전 통과:
|
||||||
// 본 미들웨어는 라우트 진입 시점에 GdprServiceProvider::boot() 가 web/api 그룹에
|
// 본 미들웨어는 plugin.php::getMiddleware() 선언에 따라 web/api 그룹에 부착된다.
|
||||||
// prepend 한다. 운영자가 본 플러그인을 제거하는 요청이면 컨트롤러 단계에서 autoload
|
// 운영자가 본 플러그인을 제거하는 요청이면 컨트롤러 단계에서 autoload
|
||||||
// 갱신·활성 디렉토리 삭제가 발생하므로, 응답이 본 미들웨어로 돌아올 때 의존 클래스
|
// 갱신·활성 디렉토리 삭제가 발생하므로, 응답이 본 미들웨어로 돌아올 때 의존 클래스
|
||||||
// (GdprPolicyVersionService 등) 로딩이 실패할 수 있다. 이 경우 cookie 게이팅을
|
// (GdprPolicyVersionService 등) 로딩이 실패할 수 있다. 이 경우 cookie 게이팅을
|
||||||
// 포기하고 응답을 그대로 통과시킨다 — 운영자의 "제거 성공" 흐름이 500 으로 깨지지
|
// 포기하고 응답을 그대로 통과시킨다 — 운영자의 "제거 성공" 흐름이 500 으로 깨지지
|
||||||
@@ -68,11 +74,11 @@ class CookieConsentMiddleware
|
|||||||
return $response; // 동의 — 통과
|
return $response; // 동의 — 통과
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. functional 미동의: 응답 Set-Cookie 중 strictly necessary allowlist 외 모두 제거
|
// 2. functional 미동의: 응답 Set-Cookie 중 strictly necessary 허용목록 외 모두 제거
|
||||||
foreach ($response->headers->getCookies() as $cookie) {
|
foreach ($response->headers->getCookies() as $cookie) {
|
||||||
$name = $cookie->getName();
|
$name = $cookie->getName();
|
||||||
|
|
||||||
// strictly necessary allowlist — 통과 (백엔드 cookie 4종)
|
// strictly necessary 허용목록 — 통과 (운영자 설정 ∪ 잠금 집합)
|
||||||
if ($this->isStrictlyNecessary($name)) {
|
if ($this->isStrictlyNecessary($name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -94,7 +100,7 @@ class CookieConsentMiddleware
|
|||||||
* 현재 방문자 (회원/게스트) 의 functional 동의 상태를 반환합니다.
|
* 현재 방문자 (회원/게스트) 의 functional 동의 상태를 반환합니다.
|
||||||
*
|
*
|
||||||
* @param Request $request HTTP 요청
|
* @param Request $request HTTP 요청
|
||||||
* @return bool functional 동의 시 true
|
* @return bool functional 동의 시 true
|
||||||
*/
|
*/
|
||||||
private function hasFunctionalConsent(Request $request): bool
|
private function hasFunctionalConsent(Request $request): bool
|
||||||
{
|
{
|
||||||
@@ -113,7 +119,7 @@ class CookieConsentMiddleware
|
|||||||
* 취급하고 Laravel session ID로 폴백합니다.
|
* 취급하고 Laravel session ID로 폴백합니다.
|
||||||
*
|
*
|
||||||
* @param Request $request HTTP 요청
|
* @param Request $request HTTP 요청
|
||||||
* @return string|null 세션 식별자 (회원이거나 식별 불가 시 null)
|
* @return string|null 세션 식별자 (회원이거나 식별 불가 시 null)
|
||||||
*/
|
*/
|
||||||
private function resolveGuestSessionId(Request $request): ?string
|
private function resolveGuestSessionId(Request $request): ?string
|
||||||
{
|
{
|
||||||
@@ -136,18 +142,15 @@ class CookieConsentMiddleware
|
|||||||
/**
|
/**
|
||||||
* Strictly Necessary cookie 인지 판정합니다.
|
* Strictly Necessary cookie 인지 판정합니다.
|
||||||
*
|
*
|
||||||
* ePrivacy Art.5(3) 면제 항목 — XSRF/세션/유지보수/GDPR 동의 관리 cookie 는 게이팅 대상 외.
|
* ePrivacy Art.5(3) 면제 항목 — 운영자가 관리자 화면에서 관리하는 목록과, 지울 수 없는
|
||||||
|
* 잠금 집합(XSRF / 세션 / GDPR 동의 관리 cookie)의 합집합으로 판정합니다. 앞부분 매칭
|
||||||
|
* (`name_*`) 도 저장소 목록과 동일하게 적용됩니다.
|
||||||
*
|
*
|
||||||
* @param string $name cookie 이름
|
* @param string $name cookie 이름
|
||||||
* @return bool strictly necessary 시 true
|
* @return bool strictly necessary 시 true
|
||||||
*/
|
*/
|
||||||
private function isStrictlyNecessary(string $name): bool
|
private function isStrictlyNecessary(string $name): bool
|
||||||
{
|
{
|
||||||
return in_array($name, [
|
return NecessaryAllowlist::matches($name, 'cookie');
|
||||||
'XSRF-TOKEN',
|
|
||||||
(string) config('session.cookie', 'laravel_session'),
|
|
||||||
'laravel_maintenance',
|
|
||||||
'gdpr_session',
|
|
||||||
], true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
|
namespace Plugins\Sirsoft\Gdpr\Http\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 GDPR 설정 저장 요청 검증
|
* 관리자 GDPR 설정 저장 요청 검증
|
||||||
@@ -36,6 +37,26 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
private const DOMAIN_REGEX = '/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i';
|
private const DOMAIN_REGEX = '/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 저장 항목(저장소 키 / 쿠키 이름) 정규식
|
||||||
|
*
|
||||||
|
* - 이름 문자: 영숫자와 `_ . : @ + -` (코어·확장이 실제로 쓰는 키 형태)
|
||||||
|
* - 와일드카드 `*` 는 **끝에만** 1개 허용 (`g7_filters_*`)
|
||||||
|
* - `*` 단독 표기는 허용하지 않는다 — 접두사가 비면 전체 개방이 되어 게이트가 무력화된다
|
||||||
|
*/
|
||||||
|
private const STORAGE_KEY_REGEX = '/^[A-Za-z0-9_.:@+\-]+\*?$/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 저장 항목 허용목록의 스코프 화이트리스트
|
||||||
|
*
|
||||||
|
* blocked_domains 는 카테고리 키를 검사하지 않고 남긴 빈틈이 있는데, 여기서는 남기지
|
||||||
|
* 않는다 — 스코프가 오타나면 그 항목은 어떤 판정에도 쓰이지 않은 채 저장되고,
|
||||||
|
* 운영자에게는 "등록했는데 안 되는" 상태로만 보인다.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
private const ALLOWLIST_SCOPES = ['localStorage', 'sessionStorage', 'cookie'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 확인 (permission 미들웨어에서 처리)
|
* 권한 확인 (permission 미들웨어에서 처리)
|
||||||
*
|
*
|
||||||
@@ -86,6 +107,14 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
'blocked_domains' => ['nullable', 'array'],
|
'blocked_domains' => ['nullable', 'array'],
|
||||||
'blocked_domains.*' => ['array'],
|
'blocked_domains.*' => ['array'],
|
||||||
'blocked_domains.*.*' => ['string', 'max:253', 'regex:'.self::DOMAIN_REGEX],
|
'blocked_domains.*.*' => ['string', 'max:253', 'regex:'.self::DOMAIN_REGEX],
|
||||||
|
|
||||||
|
// 필수 저장 항목 허용목록 — 스코프(localStorage/sessionStorage/cookie) 별 문자열 배열.
|
||||||
|
// 스코프 키 화이트리스트는 withValidator() 에서 검사한다 (규칙 문법으로는 키를 못 건다).
|
||||||
|
// necessary_storage_locked 는 규칙에 넣지 않는다 → validated() 에서 자동 배제되어
|
||||||
|
// 운영자가 그 키를 보내도 저장되지 않는다 (잠금 항목은 코드가 정한다).
|
||||||
|
'necessary_storage_allowlist' => ['nullable', 'array'],
|
||||||
|
'necessary_storage_allowlist.*' => ['array'],
|
||||||
|
'necessary_storage_allowlist.*.*' => ['string', 'max:128', 'regex:'.self::STORAGE_KEY_REGEX],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +137,14 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
'blocked_domains.marketing.*.regex' => __('sirsoft-gdpr::messages.blocked_domains.invalid_format_marketing'),
|
'blocked_domains.marketing.*.regex' => __('sirsoft-gdpr::messages.blocked_domains.invalid_format_marketing'),
|
||||||
'blocked_domains.marketing.*.max' => __('sirsoft-gdpr::messages.blocked_domains.too_long_marketing'),
|
'blocked_domains.marketing.*.max' => __('sirsoft-gdpr::messages.blocked_domains.too_long_marketing'),
|
||||||
'blocked_domains.*.array' => __('sirsoft-gdpr::messages.blocked_domains.must_be_array'),
|
'blocked_domains.*.array' => __('sirsoft-gdpr::messages.blocked_domains.must_be_array'),
|
||||||
|
// 스코프별 메시지 — 운영자가 어느 카드의 어느 항목이 잘못됐는지 즉시 식별 가능.
|
||||||
|
'necessary_storage_allowlist.localStorage.*.regex' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.invalid_format_local_storage'),
|
||||||
|
'necessary_storage_allowlist.localStorage.*.max' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.too_long_local_storage'),
|
||||||
|
'necessary_storage_allowlist.sessionStorage.*.regex' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.invalid_format_session_storage'),
|
||||||
|
'necessary_storage_allowlist.sessionStorage.*.max' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.too_long_session_storage'),
|
||||||
|
'necessary_storage_allowlist.cookie.*.regex' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.invalid_format_cookie'),
|
||||||
|
'necessary_storage_allowlist.cookie.*.max' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.too_long_cookie'),
|
||||||
|
'necessary_storage_allowlist.*.array' => __('sirsoft-gdpr::messages.necessary_storage_allowlist.must_be_array'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +169,7 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
'banner_position' => __('sirsoft-gdpr::messages.settings.fields.banner_position.label'),
|
'banner_position' => __('sirsoft-gdpr::messages.settings.fields.banner_position.label'),
|
||||||
'cookie_categories' => __('sirsoft-gdpr::messages.settings.section.cookie_categories'),
|
'cookie_categories' => __('sirsoft-gdpr::messages.settings.section.cookie_categories'),
|
||||||
'blocked_domains' => __('sirsoft-gdpr::messages.settings.section.auto_blocking'),
|
'blocked_domains' => __('sirsoft-gdpr::messages.settings.section.auto_blocking'),
|
||||||
|
'necessary_storage_allowlist' => __('sirsoft-gdpr::messages.settings.section.necessary_storage'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +181,8 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
* 3. cookie_categories 의 키 중 necessary 제외한 모든 키가 blocked_domains 에
|
* 3. cookie_categories 의 키 중 necessary 제외한 모든 키가 blocked_domains 에
|
||||||
* 존재하도록 빈 배열로 자동 보충 (운영자가 새 카테고리 추가 시 UI iteration
|
* 존재하도록 빈 배열로 자동 보충 (운영자가 새 카테고리 추가 시 UI iteration
|
||||||
* 미렌더·검증 실패 방지)
|
* 미렌더·검증 실패 방지)
|
||||||
|
* 4. necessary_storage_allowlist 도 같은 방식으로 정규화 (줄바꿈 문자열 분해 · trim ·
|
||||||
|
* 빈 항목 제거 · 세 스코프 빈 배열 보충)
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -160,9 +200,61 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
$merge['blocked_domains'] = $blockedDomains;
|
$merge['blocked_domains'] = $blockedDomains;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$necessaryAllowlist = $this->normalizeNecessaryAllowlist($this->input('necessary_storage_allowlist'));
|
||||||
|
if ($necessaryAllowlist !== null) {
|
||||||
|
$merge['necessary_storage_allowlist'] = $necessaryAllowlist;
|
||||||
|
}
|
||||||
|
|
||||||
$this->merge($merge);
|
$this->merge($merge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* necessary_storage_allowlist 입력을 정규화합니다.
|
||||||
|
*
|
||||||
|
* 스코프별 값이 string 이면 줄바꿈으로 split → trim → 빈 항목 제거. 이미 array 면 항목만
|
||||||
|
* trim 합니다. 세 스코프 중 없는 것은 빈 배열로 보충해 UI 가 항상 세 카드를 그리도록
|
||||||
|
* 합니다. **키를 아예 보내지 않았으면 null 을 돌려** 기존 저장값을 보존합니다 — 빈 배열로
|
||||||
|
* 보충해 버리면 이 화면을 모르는 클라이언트의 저장 한 번이 허용목록을 통째로 비운다.
|
||||||
|
*
|
||||||
|
* 스코프 키 자체는 여기서 거르지 않습니다 — 오타 스코프를 조용히 버리면 운영자에게는
|
||||||
|
* "저장했는데 사라지는" 상태가 되므로, withValidator() 가 422 로 알립니다.
|
||||||
|
*
|
||||||
|
* @param mixed $input necessary_storage_allowlist 입력값
|
||||||
|
* @return array<string, array<int, string>>|null
|
||||||
|
*/
|
||||||
|
private function normalizeNecessaryAllowlist(mixed $input): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($input as $scope => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalized[$scope] = collect(preg_split('/\R/', $value))
|
||||||
|
->map(fn ($line) => is_string($line) ? trim($line) : '')
|
||||||
|
->filter(fn ($line) => $line !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
} elseif (is_array($value)) {
|
||||||
|
$normalized[$scope] = array_values(array_filter(
|
||||||
|
array_map(fn ($line) => is_string($line) ? trim($line) : '', $value),
|
||||||
|
fn ($line) => $line !== '',
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$normalized[$scope] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::ALLOWLIST_SCOPES as $scope) {
|
||||||
|
if (! array_key_exists($scope, $normalized)) {
|
||||||
|
$normalized[$scope] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* blocked_domains 입력을 정규화합니다.
|
* blocked_domains 입력을 정규화합니다.
|
||||||
*
|
*
|
||||||
@@ -217,14 +309,16 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검증 후 cookie_categories 내 key 중복 차단.
|
* 검증 후 추가 검사:
|
||||||
|
* 1. cookie_categories 내 key 중복 차단
|
||||||
|
* 2. necessary_storage_allowlist 의 스코프 키가 화이트리스트에 있는지 검사
|
||||||
*
|
*
|
||||||
* @param \Illuminate\Validation\Validator $validator
|
* @param Validator $validator
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function withValidator(\Illuminate\Validation\Validator $validator): void
|
public function withValidator(Validator $validator): void
|
||||||
{
|
{
|
||||||
$validator->after(function (\Illuminate\Validation\Validator $validator) {
|
$validator->after(function (Validator $validator) {
|
||||||
$categories = (array) $this->input('cookie_categories', []);
|
$categories = (array) $this->input('cookie_categories', []);
|
||||||
$keys = array_column($categories, 'key');
|
$keys = array_column($categories, 'key');
|
||||||
if (count($keys) !== count(array_unique($keys))) {
|
if (count($keys) !== count(array_unique($keys))) {
|
||||||
@@ -234,6 +328,21 @@ class UpdateAdminSettingsRequest extends FormRequest
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$allowlist = $this->input('necessary_storage_allowlist');
|
||||||
|
if (is_array($allowlist)) {
|
||||||
|
foreach (array_keys($allowlist) as $scope) {
|
||||||
|
if (in_array($scope, self::ALLOWLIST_SCOPES, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator->errors()->add(
|
||||||
|
'necessary_storage_allowlist.'.$scope,
|
||||||
|
__('sirsoft-gdpr::messages.necessary_storage_allowlist.invalid_scope', [
|
||||||
|
'scope' => is_scalar($scope) ? (string) $scope : '',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,21 +202,21 @@ class CookieCategoryService
|
|||||||
'required' => true,
|
'required' => true,
|
||||||
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
|
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
|
||||||
'description' => [
|
'description' => [
|
||||||
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
|
// g7_locale·g7_color_scheme 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
|
||||||
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
|
// 예외 (사용자가 화면에서 직접 고른 표시 환경) 로 strictly necessary 분류. 사용자 안내에 명시.
|
||||||
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
|
'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.',
|
'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분류 체계 부합.
|
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
|
||||||
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
|
// 자체 functional 키 (표시 통화 등) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
|
||||||
'key' => 'functional',
|
'key' => 'functional',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
|
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
|
||||||
'description' => [
|
'description' => [
|
||||||
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
|
'ko' => '사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
|
||||||
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
|
'en' => 'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\Gdpr\Support;
|
||||||
|
|
||||||
|
use Plugins\Sirsoft\Gdpr\Plugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* strictly necessary 허용목록 해석기
|
||||||
|
*
|
||||||
|
* 허용목록은 운영자 설정(`necessary_storage_allowlist`)이고, 잠금 항목은 코드가 정한다.
|
||||||
|
* 판정은 언제나 **운영자 목록 ∪ 잠금 집합**이며, 이 클래스가 그 합집합과 매칭 규칙의
|
||||||
|
* 단일 출처다 — 서버(미들웨어)와 클라이언트(인터셉터 3종)가 같은 규칙을 쓰지 않으면
|
||||||
|
* 한쪽에서만 살아 있는 항목이 생기고, 그 어긋남은 예외도 로그도 남기지 않는다.
|
||||||
|
*
|
||||||
|
* 클라이언트 측 대응 구현은 `resources/js/necessaryAllowlist.ts` 이며 두 구현의 일치는
|
||||||
|
* `resources/js/__tests__/necessaryAllowlistCoverage.test.ts` 가 대조한다.
|
||||||
|
*/
|
||||||
|
class NecessaryAllowlist
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 플러그인 식별자
|
||||||
|
*/
|
||||||
|
private const PLUGIN_ID = 'sirsoft-gdpr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용목록 스코프 (저장소 두 종류 + 쿠키)
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public const SCOPES = ['localStorage', 'sessionStorage', 'cookie'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 항목 — 운영자가 지울 수 없는 최소 집합.
|
||||||
|
*
|
||||||
|
* 설정에 넣지 않는다. 설정에 넣으면 API 로 지울 수 있어 잠금이 아니게 된다.
|
||||||
|
* 세션 쿠키 이름은 `session.cookie` 가 정하는 런타임 값이라 상수로 둘 수 없다 —
|
||||||
|
* 이름을 하드코딩하면 `SESSION_COOKIE` 를 지정한 사이트에서 그 항목이 죽는다.
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, string>> 스코프 => 항목 배열
|
||||||
|
*/
|
||||||
|
public static function locked(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'localStorage' => ['auth_token'],
|
||||||
|
'sessionStorage' => [],
|
||||||
|
'cookie' => ['XSRF-TOKEN', (string) config('session.cookie', 'laravel_session'), 'gdpr_session'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한 스코프의 판정 목록을 반환합니다 (운영자 설정 ∪ 잠금 집합).
|
||||||
|
*
|
||||||
|
* 설정 조회는 config 미러라 요청당 비용이 낮습니다. 조회가 비어도 잠금 집합은
|
||||||
|
* 코드에서 합치므로 남습니다.
|
||||||
|
*
|
||||||
|
* @param string $scope 스코프 (localStorage / sessionStorage / cookie)
|
||||||
|
* @return array<int, string> 허용 패턴 배열
|
||||||
|
*/
|
||||||
|
public static function forScope(string $scope): array
|
||||||
|
{
|
||||||
|
$operator = g7_plugin_settings(self::PLUGIN_ID, 'necessary_storage_allowlist.'.$scope, []);
|
||||||
|
|
||||||
|
if (! is_array($operator)) {
|
||||||
|
$operator = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = array_values(array_filter(
|
||||||
|
$operator,
|
||||||
|
fn ($pattern) => is_string($pattern) && $pattern !== '',
|
||||||
|
));
|
||||||
|
|
||||||
|
$locked = self::locked()[$scope] ?? [];
|
||||||
|
|
||||||
|
return array_values(array_unique(array_merge($operator, $locked)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한 패턴이 이름에 매칭되는지 판정합니다.
|
||||||
|
*
|
||||||
|
* 끝에 `*` 가 붙으면 앞부분 매칭, 없으면 정확 일치. 접두사가 빈 `*` 단독 표기는
|
||||||
|
* 전체 개방이 되므로 매칭하지 않습니다 (검증에서도 거릅니다).
|
||||||
|
*
|
||||||
|
* @param string $name 검사할 키 또는 쿠키 이름
|
||||||
|
* @param string $pattern 운영자 표기 패턴
|
||||||
|
* @return bool 매칭 여부
|
||||||
|
*/
|
||||||
|
public static function matchesPattern(string $name, string $pattern): bool
|
||||||
|
{
|
||||||
|
if (str_ends_with($pattern, '*')) {
|
||||||
|
$prefix = substr($pattern, 0, -1);
|
||||||
|
|
||||||
|
return $prefix !== '' && str_starts_with($name, $prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pattern === $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름이 해당 스코프의 허용목록에 있는지 판정합니다.
|
||||||
|
*
|
||||||
|
* @param string $name 검사할 키 또는 쿠키 이름
|
||||||
|
* @param string $scope 스코프
|
||||||
|
* @return bool 허용 여부
|
||||||
|
*/
|
||||||
|
public static function matches(string $name, string $scope): bool
|
||||||
|
{
|
||||||
|
foreach (self::forScope($scope) as $pattern) {
|
||||||
|
if (self::matchesPattern($name, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출하 기본 카탈로그를 반환합니다 (신규 설치 시드 + 관리자 화면 추천 목록).
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, string>> 스코프 => 항목 배열
|
||||||
|
*/
|
||||||
|
public static function catalog(): array
|
||||||
|
{
|
||||||
|
return Plugin::DEFAULT_NECESSARY_ALLOWLIST_CATALOG;
|
||||||
|
}
|
||||||
|
}
|
||||||
+271
-1
@@ -11,6 +11,10 @@ use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
|
|||||||
*
|
*
|
||||||
* - GET /api/plugins/sirsoft-gdpr/admin/settings
|
* - GET /api/plugins/sirsoft-gdpr/admin/settings
|
||||||
* - PUT /api/plugins/sirsoft-gdpr/admin/settings
|
* - PUT /api/plugins/sirsoft-gdpr/admin/settings
|
||||||
|
*
|
||||||
|
* @scenario scope=localStorage, notation=exact, locked=operator_item, settings_state=populated, request=invalid_format
|
||||||
|
*
|
||||||
|
* @effects update_persists_allowlist_per_scope, update_normalizes_textarea_string_to_array, update_without_key_leaves_existing_value_untouched, update_rejects_invalid_item_format_with_scope_indexed_error_key, update_rejects_unknown_scope, update_ignores_locked_list_from_request, show_includes_default_catalog_previews
|
||||||
*/
|
*/
|
||||||
class GdprAdminSettingsControllerTest extends PluginTestCase
|
class GdprAdminSettingsControllerTest extends PluginTestCase
|
||||||
{
|
{
|
||||||
@@ -503,7 +507,7 @@ class GdprAdminSettingsControllerTest extends PluginTestCase
|
|||||||
/**
|
/**
|
||||||
* PluginSettingsService::get 을 통제 가능한 Mock 으로 교체.
|
* PluginSettingsService::get 을 통제 가능한 Mock 으로 교체.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $values
|
* @param array<string, mixed> $values
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private function mockPluginSettings(array $values): void
|
private function mockPluginSettings(array $values): void
|
||||||
@@ -529,4 +533,270 @@ class GdprAdminSettingsControllerTest extends PluginTestCase
|
|||||||
$this->app->instance(PluginSettingsService::class, $mock);
|
$this->app->instance(PluginSettingsService::class, $mock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 응답에 출하 카탈로그 두 벌이 실린다 (추천 목록 결함 해소).
|
||||||
|
*
|
||||||
|
* 관리자 레이아웃의 TagInput 은 이 값을 자동완성 추천으로 쓴다. 응답에 없으면 드롭다운에
|
||||||
|
* **이미 선택된 칩만** 다시 나타나므로 추천이 동작하는 것처럼 보이고, 오류도 빈 목록도
|
||||||
|
* 남지 않아 증상만으로는 알 수 없다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_show_includes_default_catalog_previews(): void
|
||||||
|
{
|
||||||
|
$mock = $this->createMock(PluginSettingsService::class);
|
||||||
|
$mock->method('get')->willReturnCallback(
|
||||||
|
fn (string $id, ?string $key = null, mixed $default = null) => $key === null ? [] : $default
|
||||||
|
);
|
||||||
|
$this->app->instance(PluginSettingsService::class, $mock);
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->getJson('/api/plugins/sirsoft-gdpr/admin/settings');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'default_blocked_domains_preview' => ['functional', 'analytics', 'marketing'],
|
||||||
|
'default_necessary_allowlist_preview' => ['localStorage', 'sessionStorage', 'cookie'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertContains(
|
||||||
|
'g7_color_scheme',
|
||||||
|
$response->json('data.default_necessary_allowlist_preview.localStorage'),
|
||||||
|
'출하 카탈로그가 실제 항목을 담아야 추천이 의미를 가진다'
|
||||||
|
);
|
||||||
|
$this->assertContains(
|
||||||
|
'google-analytics.com',
|
||||||
|
$response->json('data.default_blocked_domains_preview.analytics')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 항목은 출하 카탈로그(운영자 편집 대상)에 실리지 않는다.
|
||||||
|
*
|
||||||
|
* 실리면 운영자가 화면에서 지울 수 있고, 그 순간 잠금이 잠금이 아니게 된다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_default_catalog_preview_excludes_locked_items(): void
|
||||||
|
{
|
||||||
|
$mock = $this->createMock(PluginSettingsService::class);
|
||||||
|
$mock->method('get')->willReturnCallback(
|
||||||
|
fn (string $id, ?string $key = null, mixed $default = null) => $key === null ? [] : $default
|
||||||
|
);
|
||||||
|
$this->app->instance(PluginSettingsService::class, $mock);
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$preview = $this->actingAs($user)
|
||||||
|
->getJson('/api/plugins/sirsoft-gdpr/admin/settings')
|
||||||
|
->json('data.default_necessary_allowlist_preview');
|
||||||
|
|
||||||
|
$this->assertNotContains('auth_token', $preview['localStorage']);
|
||||||
|
$this->assertNotContains('XSRF-TOKEN', $preview['cookie']);
|
||||||
|
$this->assertNotContains('gdpr_session', $preview['cookie']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 저장 항목 허용목록이 스코프별로 저장된다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_persists_necessary_storage_allowlist(): void
|
||||||
|
{
|
||||||
|
$captured = $this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'localStorage' => ['g7_locale', 'g7_filters_*'],
|
||||||
|
'sessionStorage' => ['g7:sirsoft-pay_kginicis:pendingClose'],
|
||||||
|
'cookie' => ['laravel_maintenance', 'myplugin_*'],
|
||||||
|
],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$saved = $captured->value['necessary_storage_allowlist'] ?? null;
|
||||||
|
$this->assertIsArray($saved);
|
||||||
|
$this->assertSame(['g7_locale', 'g7_filters_*'], $saved['localStorage']);
|
||||||
|
$this->assertSame(['g7:sirsoft-pay_kginicis:pendingClose'], $saved['sessionStorage']);
|
||||||
|
$this->assertSame(['laravel_maintenance', 'myplugin_*'], $saved['cookie']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줄바꿈 문자열로 들어와도 배열로 정규화된다 (blocked_domains 와 동일 규약).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_normalizes_necessary_storage_allowlist_textarea_string(): void
|
||||||
|
{
|
||||||
|
$captured = $this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'localStorage' => "g7_locale\n g7_color_scheme \n\n",
|
||||||
|
],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$saved = $captured->value['necessary_storage_allowlist'];
|
||||||
|
$this->assertSame(['g7_locale', 'g7_color_scheme'], $saved['localStorage']);
|
||||||
|
// 전송하지 않은 스코프는 빈 배열로 보충되어 화면이 항상 세 카드를 그린다.
|
||||||
|
$this->assertSame([], $saved['sessionStorage']);
|
||||||
|
$this->assertSame([], $saved['cookie']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키를 아예 보내지 않으면 기존 저장값을 건드리지 않는다.
|
||||||
|
*
|
||||||
|
* 빈 배열로 보충해 버리면 이 화면을 모르는 클라이언트의 저장 한 번이 허용목록을 통째로
|
||||||
|
* 비우고, 그 사이트는 다음 방문부터 로그인 외 모든 설정을 잃는다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_without_allowlist_key_leaves_it_untouched(): void
|
||||||
|
{
|
||||||
|
$captured = $this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'banner_enabled' => true,
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('necessary_storage_allowlist', $captured->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항목 형식 위반은 스코프·인덱스가 드러나는 키로 422 를 낸다.
|
||||||
|
*
|
||||||
|
* 에러 키가 `necessary_storage_allowlist.{scope}.{index}` 여야 화면이 그 카드에
|
||||||
|
* 메시지를 붙일 수 있다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_rejects_invalid_allowlist_item_format(): void
|
||||||
|
{
|
||||||
|
$this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'localStorage' => ['bad key!'],
|
||||||
|
],
|
||||||
|
])->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['necessary_storage_allowlist.localStorage.0']);
|
||||||
|
|
||||||
|
// `*` 는 끝에만 허용 — 중간/앞 표기는 거른다 (앞에 두면 전체 개방이 된다).
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'cookie' => ['*_suffix'],
|
||||||
|
],
|
||||||
|
])->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['necessary_storage_allowlist.cookie.0']);
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'sessionStorage' => ['g7_*_middle'],
|
||||||
|
],
|
||||||
|
])->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['necessary_storage_allowlist.sessionStorage.0']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 와일드카드 표기와 실제 사용 키는 통과한다 (위 테스트의 대조군).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_accepts_valid_allowlist_items(): void
|
||||||
|
{
|
||||||
|
$this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'localStorage' => ['g7_locale', 'g7_filters_*', 'g7le.clipboard', 'g7_asset_url_mode*'],
|
||||||
|
'sessionStorage' => ['g7:sirsoft-pay_nhnkcp:pendingClose', '__sirsoftKginicisMobilePaymentReturnPending'],
|
||||||
|
'cookie' => ['laravel_maintenance', 'XSRF-TOKEN'],
|
||||||
|
],
|
||||||
|
])->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스코프 키 화이트리스트 — 알 수 없는 스코프는 422.
|
||||||
|
*
|
||||||
|
* 조용히 버리면 그 항목은 어떤 판정에도 쓰이지 않은 채 저장되고, 운영자에게는
|
||||||
|
* "등록했는데 안 되는" 상태로만 보인다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_rejects_unknown_allowlist_scope(): void
|
||||||
|
{
|
||||||
|
$this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_allowlist' => [
|
||||||
|
'localstorage' => ['g7_locale'],
|
||||||
|
],
|
||||||
|
])->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['necessary_storage_allowlist.localstorage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 항목 목록은 저장되지 않는다 (요청에 섞여 와도 무시).
|
||||||
|
*
|
||||||
|
* 저장 가능하면 API 한 번으로 잠금을 무력화할 수 있다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function test_update_ignores_locked_list_from_request(): void
|
||||||
|
{
|
||||||
|
$captured = $this->captureSave();
|
||||||
|
|
||||||
|
$user = $this->createPrivacyOperatorUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
|
||||||
|
'necessary_storage_locked' => [
|
||||||
|
'localStorage' => [],
|
||||||
|
'sessionStorage' => [],
|
||||||
|
'cookie' => [],
|
||||||
|
],
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('necessary_storage_locked', $captured->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 페이로드를 가로채는 PluginSettingsService mock 을 등록합니다.
|
||||||
|
*
|
||||||
|
* @return object 저장된 페이로드를 담는 컨테이너 (`value` 프로퍼티)
|
||||||
|
*/
|
||||||
|
private function captureSave(): object
|
||||||
|
{
|
||||||
|
$captured = new class
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
$mock = $this->createMock(PluginSettingsService::class);
|
||||||
|
$mock->method('save')->willReturnCallback(function (string $id, array $settings) use ($captured) {
|
||||||
|
$captured->value = $settings;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$mock->method('get')->willReturnCallback(
|
||||||
|
fn (string $id, ?string $key = null, mixed $default = null) => $key === null ? [] : $default
|
||||||
|
);
|
||||||
|
$this->app->instance(PluginSettingsService::class, $mock);
|
||||||
|
|
||||||
|
return $captured;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* E2E: 관리자 환경설정 > 필수 저장 항목 — 허용목록 운영자 편집
|
||||||
|
*
|
||||||
|
* @scenario admin_gdpr_necessary_storage_allowlist_edit
|
||||||
|
* @effects e2e_allowlist_cards_render_three_scopes, e2e_locked_chips_render_readonly, e2e_allowlist_tag_persists_after_reload, e2e_allowlist_reaches_inline_payload, e2e_allowlist_invalid_item_rejected
|
||||||
|
*
|
||||||
|
* 배경: 필수 저장 항목 허용목록이 플러그인 코드 상수에서 운영자 설정으로 옮겨졌다. 이 화면이
|
||||||
|
* 실제로 저장까지 도달하지 못하면 결함은 조용하다 — 화면에는 칩이 보이는데 판정에는 반영되지
|
||||||
|
* 않고, 그 사이트는 "설정이 저장되지 않는" 증상만 겪는다.
|
||||||
|
*
|
||||||
|
* 특히 인라인 페이로드(`G7Config.plugins['sirsoft-gdpr']`) 도달은 단위 테스트로 잡을 수 없다.
|
||||||
|
* `defaults.json` 의 `frontend_schema` 에 `expose: true` 가 빠지면 저장은 성공하고 화면도
|
||||||
|
* 정상인데 브라우저의 인터셉터만 빈 목록으로 서기 때문이다.
|
||||||
|
*
|
||||||
|
* 검증:
|
||||||
|
* 1. 저장소별 카드 3개가 렌더되고 잠금 칩 행이 읽기 전용으로 표시된다
|
||||||
|
* 2. 항목을 추가해 저장하면 200 이고, 새로고침 후에도 칩이 남는다
|
||||||
|
* 3. 저장된 목록이 인라인 페이로드에 실려 브라우저에 도달한다 (+ 잠금 집합 동반)
|
||||||
|
* 4. 형식 위반 항목은 422 로 거부되고 그 카드에 에러가 붙는다
|
||||||
|
*
|
||||||
|
* 종료 시 추가한 항목을 되돌린다 — PO 와 브라우저를 공유하므로 원상 복구는 의무다.
|
||||||
|
*/
|
||||||
|
import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const SETTINGS_PATH = '/admin/plugins/sirsoft-gdpr/settings';
|
||||||
|
const CARD = '#card_necessary_storage';
|
||||||
|
const SCOPES = ['localStorage', 'sessionStorage', 'cookie'] as const;
|
||||||
|
const PROBE_KEY = 'g7_e2e_allowlist_probe';
|
||||||
|
|
||||||
|
/** 관리자 GDPR 환경설정 진입 후 필수 저장 항목 카드까지 스크롤 */
|
||||||
|
async function gotoAllowlistCard(page: Page): Promise<void> {
|
||||||
|
await page.goto(SETTINGS_PATH);
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||||
|
await expect(page.locator('#settings_tab_navigation')).toBeAttached({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect(page.locator(CARD)).toBeAttached({ timeout: 20_000 });
|
||||||
|
await page.locator(CARD).scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TagInput 에 항목 하나를 입력하고 Enter 로 칩을 만든다 */
|
||||||
|
async function addTag(page: Page, scope: string, value: string): Promise<void> {
|
||||||
|
const input = page.locator(`#necessary_storage_card_${scope} input`).first();
|
||||||
|
await expect(input).toBeAttached({ timeout: 10_000 });
|
||||||
|
await input.click();
|
||||||
|
await input.fill(value);
|
||||||
|
await input.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 버튼을 누르고 PUT 응답 상태 코드를 돌려준다 */
|
||||||
|
async function save(page: Page): Promise<number> {
|
||||||
|
const pending = page.waitForResponse(
|
||||||
|
(r) => r.request().method() === 'PUT'
|
||||||
|
&& /\/api\/plugins\/sirsoft-gdpr\/admin\/settings$/.test(new URL(r.url()).pathname),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator('#footer_save_button').click();
|
||||||
|
|
||||||
|
return (await pending).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 저장된 허용목록을 API 로 읽는다 (원상 복구 판정용) */
|
||||||
|
async function readSavedAllowlist(page: Page): Promise<Record<string, string[]>> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const res = await fetch('/api/plugins/sirsoft-gdpr/admin/settings', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${window.localStorage.getItem('auth_token') ?? ''}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
return json?.data?.settings?.necessary_storage_allowlist ?? {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @scenario scope=all, permitted=yes
|
||||||
|
// @effects e2e_allowlist_cards_render_three_scopes, e2e_locked_chips_render_readonly
|
||||||
|
test('필수 저장 항목 카드가 저장소별로 3개 렌더되고 잠금 칩이 읽기 전용으로 표시된다', async ({ page, privacyManageToken }) => {
|
||||||
|
await authenticatePage(page, privacyManageToken);
|
||||||
|
await gotoAllowlistCard(page);
|
||||||
|
|
||||||
|
for (const scope of SCOPES) {
|
||||||
|
await expect(page.locator(`#necessary_storage_card_${scope}`)).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잠금 항목이 있는 스코프(localStorage / cookie)는 잠금 칩 행을 그린다.
|
||||||
|
const lockedLocal = page.locator('#necessary_storage_locked_localStorage');
|
||||||
|
await expect(lockedLocal).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(lockedLocal).toContainText('auth_token');
|
||||||
|
|
||||||
|
const lockedCookie = page.locator('#necessary_storage_locked_cookie');
|
||||||
|
await expect(lockedCookie).toBeVisible();
|
||||||
|
await expect(lockedCookie).toContainText('XSRF-TOKEN');
|
||||||
|
await expect(lockedCookie).toContainText('gdpr_session');
|
||||||
|
|
||||||
|
// 잠금 칩은 편집 가능한 칩과 시각적으로 구분되어야 한다 — 속성만이 아니라 표현까지.
|
||||||
|
const chip = lockedLocal.locator('span', { hasText: 'auth_token' }).last();
|
||||||
|
const style = await chip.evaluate((el) => {
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
|
||||||
|
return { cursor: s.cursor, opacity: Number(s.opacity) };
|
||||||
|
});
|
||||||
|
expect(style.cursor).toBe('not-allowed');
|
||||||
|
expect(style.opacity).toBeLessThan(1);
|
||||||
|
|
||||||
|
// 잠금 칩에는 삭제 버튼이 없다 (TagInput 칩의 X 버튼 부재).
|
||||||
|
await expect(lockedLocal.locator('button')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// @scenario scope=localStorage, item=valid, permitted=yes
|
||||||
|
// @effects e2e_allowlist_tag_persists_after_reload, e2e_allowlist_reaches_inline_payload, allowlist_exposed_in_inline_payload_via_frontend_schema, locked_set_exposed_in_inline_payload
|
||||||
|
test('허용목록에 항목을 추가해 저장하면 새로고침 후에도 유지되고 인라인 페이로드에 실린다', async ({ page, privacyManageToken }) => {
|
||||||
|
await authenticatePage(page, privacyManageToken);
|
||||||
|
await gotoAllowlistCard(page);
|
||||||
|
|
||||||
|
const before = await readSavedAllowlist(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addTag(page, 'localStorage', PROBE_KEY);
|
||||||
|
await expect(page.locator('#necessary_storage_card_localStorage')).toContainText(PROBE_KEY);
|
||||||
|
|
||||||
|
expect(await save(page)).toBe(200);
|
||||||
|
|
||||||
|
// 새로고침 — 저장이 서버에 도달했는지, 화면이 그 값을 다시 그리는지.
|
||||||
|
await gotoAllowlistCard(page);
|
||||||
|
await expect(page.locator('#necessary_storage_card_localStorage')).toContainText(PROBE_KEY, {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 인라인 페이로드 도달 — frontend_schema 의 expose 가 빠지면 저장·화면은 정상인데
|
||||||
|
// 브라우저의 인터셉터만 빈 목록으로 선다 (이 축이 그 유일한 관문이다).
|
||||||
|
const inline = await page.evaluate(() => {
|
||||||
|
const cfg = (window as unknown as {
|
||||||
|
G7Config?: { plugins?: Record<string, Record<string, unknown>> };
|
||||||
|
}).G7Config;
|
||||||
|
const plugin = cfg?.plugins?.['sirsoft-gdpr'] ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowlist: plugin.necessary_storage_allowlist as Record<string, string[]> | undefined,
|
||||||
|
locked: plugin.necessary_storage_locked as Record<string, string[]> | undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inline.allowlist?.localStorage).toContain(PROBE_KEY);
|
||||||
|
// 잠금 집합도 함께 실려야 판정이 '운영자 목록 ∪ 잠금' 으로 성립한다.
|
||||||
|
expect(inline.locked?.localStorage).toContain('auth_token');
|
||||||
|
expect(inline.locked?.cookie).toContain('XSRF-TOKEN');
|
||||||
|
} finally {
|
||||||
|
// 원상 복구 — 추가한 항목을 되돌린다.
|
||||||
|
await page.evaluate(async ([original]) => {
|
||||||
|
await fetch('/api/plugins/sirsoft-gdpr/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${window.localStorage.getItem('auth_token') ?? ''}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ necessary_storage_allowlist: original }),
|
||||||
|
});
|
||||||
|
}, [before]);
|
||||||
|
|
||||||
|
const restored = await readSavedAllowlist(page);
|
||||||
|
expect(restored.localStorage ?? []).not.toContain(PROBE_KEY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @scenario scope=cookie, item=invalid, permitted=yes
|
||||||
|
// @effects e2e_allowlist_invalid_item_rejected
|
||||||
|
test('형식 위반 항목은 422 로 거부되고 그 카드에 에러가 표시된다', async ({ page, privacyManageToken }) => {
|
||||||
|
await authenticatePage(page, privacyManageToken);
|
||||||
|
await gotoAllowlistCard(page);
|
||||||
|
|
||||||
|
await addTag(page, 'cookie', 'bad key!');
|
||||||
|
|
||||||
|
expect(await save(page)).toBe(422);
|
||||||
|
|
||||||
|
// 에러 키는 `necessary_storage_allowlist.cookie.{index}` 형태여야 그 카드에 붙는다.
|
||||||
|
const card = page.locator('#necessary_storage_card_cookie');
|
||||||
|
await expect(card).toHaveClass(/border-red-500/, { timeout: 10_000 });
|
||||||
|
await expect(card.locator('.form-error')).toBeVisible();
|
||||||
|
});
|
||||||
+166
-4
@@ -14,12 +14,20 @@ use Symfony\Component\HttpFoundation\Cookie;
|
|||||||
* CookieConsentMiddleware Test (Phase 2 단순화)
|
* CookieConsentMiddleware Test (Phase 2 단순화)
|
||||||
*
|
*
|
||||||
* EDPB Guidelines 2/2023 §16 (사전 차단) 검증:
|
* EDPB Guidelines 2/2023 §16 (사전 차단) 검증:
|
||||||
* - functional 미동의 시 응답의 strictly necessary allowlist 외 모든 cookie 가 제거되어야 함
|
* - functional 미동의 시 응답의 strictly necessary 허용목록 외 모든 cookie 가 제거되어야 함
|
||||||
* - functional 동의 시 모든 cookie 통과
|
* - functional 동의 시 모든 cookie 통과
|
||||||
* - strictly necessary (XSRF-TOKEN / laravel_session / laravel_maintenance / gdpr_session) 는 항상 통과
|
* - 잠금 항목 (XSRF-TOKEN / 세션 쿠키 / gdpr_session) 은 **설정이 비어도** 항상 통과
|
||||||
|
* - 운영자 설정에 등재한 cookie 는 통과하고, 뺀 cookie 는 제거된다 (설정이 판정에 쓰인다는 증거)
|
||||||
|
* - 와일드카드(`name_*`)가 저장소 목록과 동일하게 동작한다
|
||||||
* - 파기 cookie (cleared) 는 항상 통과 (§117 충돌 회피)
|
* - 파기 cookie (cleared) 는 항상 통과 (§117 충돌 회피)
|
||||||
*
|
*
|
||||||
* Phase 2 등록 표 (functional_cookies 설정) 제거에 따라 PluginSettingsService 의존성 없음.
|
* 허용목록이 운영자 설정으로 옮겨졌으므로 판정 목록은 config 미러(`g7_settings.plugins`)에서
|
||||||
|
* 온다. 그래서 이 테스트는 미러를 직접 세워 "설정대로 갈리는가" 를 본다 — 하드코딩 배열을
|
||||||
|
* 검증하던 시절에는 설정을 바꿔도 판정이 안 바뀌는 회귀를 잡을 수 없었다.
|
||||||
|
*
|
||||||
|
* @scenario scope=cookie, notation=wildcard, locked=locked_item, settings_state=empty, request=valid_item
|
||||||
|
*
|
||||||
|
* @effects middleware_passes_cookie_listed_in_operator_settings, middleware_removes_cookie_absent_from_settings, middleware_supports_wildcard_in_cookie_allowlist, middleware_locked_cookies_survive_empty_settings, middleware_locked_cookies_cannot_be_removed_via_settings, middleware_session_cookie_name_resolved_at_runtime
|
||||||
*/
|
*/
|
||||||
class CookieConsentMiddlewareTest extends PluginTestCase
|
class CookieConsentMiddlewareTest extends PluginTestCase
|
||||||
{
|
{
|
||||||
@@ -64,6 +72,9 @@ class CookieConsentMiddlewareTest extends PluginTestCase
|
|||||||
{
|
{
|
||||||
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
|
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
|
||||||
|
|
||||||
|
// laravel_maintenance 는 잠금 항목이 아니라 출하 카탈로그 항목이다 — 운영자 설정에서 온다.
|
||||||
|
$this->setAllowlist(['laravel_maintenance']);
|
||||||
|
|
||||||
$middleware = $this->buildMiddleware(functionalConsent: false);
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
$response = $this->runMiddleware($middleware, function (HttpResponse $r) use ($sessionCookieName) {
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) use ($sessionCookieName) {
|
||||||
$r->headers->setCookie(Cookie::create('XSRF-TOKEN', 'token', 0, '/'));
|
$r->headers->setCookie(Cookie::create('XSRF-TOKEN', 'token', 0, '/'));
|
||||||
@@ -155,7 +166,158 @@ class CookieConsentMiddlewareTest extends PluginTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미들웨어 인스턴스 생성 — GdprConsentService mock 만 주입 (Phase 2 단순화로 PluginSettingsService 의존성 제거).
|
* 운영자가 설정에 등재한 cookie 는 미동의여도 통과한다.
|
||||||
|
*
|
||||||
|
* 판정 목록이 설정에서 온다는 증거 — 하드코딩 배열이면 이 이름은 통과할 수 없다.
|
||||||
|
*/
|
||||||
|
public function test_passes_cookie_listed_in_operator_settings(): void
|
||||||
|
{
|
||||||
|
$this->setAllowlist(['operator_added_cookie']);
|
||||||
|
|
||||||
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) {
|
||||||
|
$r->headers->setCookie(Cookie::create('operator_added_cookie', 'v', 0, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$names = array_map(fn (Cookie $c) => $c->getName(), $response->headers->getCookies());
|
||||||
|
$this->assertContains('operator_added_cookie', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정에서 뺀 cookie 는 제거된다 (위 테스트의 대조군).
|
||||||
|
*
|
||||||
|
* 이 축이 없으면 "목록이 통째로 열려 있어도" 위 테스트는 통과한다.
|
||||||
|
*/
|
||||||
|
public function test_removes_cookie_absent_from_operator_settings(): void
|
||||||
|
{
|
||||||
|
$this->setAllowlist([]);
|
||||||
|
|
||||||
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) {
|
||||||
|
$r->headers->setCookie(Cookie::create('operator_added_cookie', 'v', 0, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$names = array_map(fn (Cookie $c) => $c->getName(), $response->headers->getCookies());
|
||||||
|
$this->assertNotContains('operator_added_cookie', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿠키 목록도 와일드카드를 지원한다 (발견 ② 회귀 차단).
|
||||||
|
*
|
||||||
|
* 저장소 목록만 접두사 매칭을 지원하던 시절, 운영자가 쿠키 카드에 적은 `myplugin_*` 은
|
||||||
|
* 아무 효과가 없었고 그 사실이 화면에 드러나지 않았다.
|
||||||
|
*/
|
||||||
|
public function test_supports_wildcard_in_cookie_allowlist(): void
|
||||||
|
{
|
||||||
|
$this->setAllowlist(['myplugin_*']);
|
||||||
|
|
||||||
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) {
|
||||||
|
$r->headers->setCookie(Cookie::create('myplugin_state', 'v', 0, '/'));
|
||||||
|
$r->headers->setCookie(Cookie::create('other_myplugin_state', 'v', 0, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$names = array_map(fn (Cookie $c) => $c->getName(), $response->headers->getCookies());
|
||||||
|
$this->assertContains('myplugin_state', $names, '접두사가 같으면 통과');
|
||||||
|
$this->assertNotContains('other_myplugin_state', $names, '접두사가 다르면 차단 — 와일드카드는 앞부분만 매칭한다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정이 비어 있어도 잠금 4항목은 통과한다.
|
||||||
|
*
|
||||||
|
* 잠금 항목은 설정이 아니라 코드가 정한다 — 설정 조회가 실패하거나 운영자가 목록을
|
||||||
|
* 통째로 비워도 사이트가 서야 한다.
|
||||||
|
*/
|
||||||
|
public function test_locked_cookies_survive_empty_settings(): void
|
||||||
|
{
|
||||||
|
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
|
||||||
|
|
||||||
|
$this->setAllowlist([]);
|
||||||
|
|
||||||
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) use ($sessionCookieName) {
|
||||||
|
$r->headers->setCookie(Cookie::create('XSRF-TOKEN', 'token', 0, '/'));
|
||||||
|
$r->headers->setCookie(Cookie::create($sessionCookieName, 'sess', 0, '/'));
|
||||||
|
$r->headers->setCookie(Cookie::create('gdpr_session', 'gdpr', 0, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$names = array_map(fn (Cookie $c) => $c->getName(), $response->headers->getCookies());
|
||||||
|
$this->assertContains('XSRF-TOKEN', $names);
|
||||||
|
$this->assertContains($sessionCookieName, $names);
|
||||||
|
$this->assertContains('gdpr_session', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자가 잠금 항목을 목록에서 지워도 판정에서는 사라지지 않는다.
|
||||||
|
*
|
||||||
|
* 잠금은 '설정 밖 합집합' 이므로 저장 요청으로 무력화할 수 없어야 한다.
|
||||||
|
*/
|
||||||
|
public function test_locked_cookies_cannot_be_removed_via_settings(): void
|
||||||
|
{
|
||||||
|
// 운영자 목록에 잠금 항목이 없는 상태 (API 로 지운 것과 동일한 상태)
|
||||||
|
$this->setAllowlist(['laravel_maintenance']);
|
||||||
|
|
||||||
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) {
|
||||||
|
$r->headers->setCookie(Cookie::create('XSRF-TOKEN', 'token', 0, '/'));
|
||||||
|
$r->headers->setCookie(Cookie::create('gdpr_session', 'gdpr', 0, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$names = array_map(fn (Cookie $c) => $c->getName(), $response->headers->getCookies());
|
||||||
|
$this->assertContains('XSRF-TOKEN', $names);
|
||||||
|
$this->assertContains('gdpr_session', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 쿠키 이름은 런타임 설정에서 해석된다.
|
||||||
|
*
|
||||||
|
* 서버는 `config('session.cookie')` 를 읽는데 클라이언트는 'laravel_session' 을
|
||||||
|
* 하드코딩하고 있었다 — `SESSION_COOKIE` 를 지정한 사이트에서는 클라이언트 목록의 그
|
||||||
|
* 항목이 죽어 있었고, 두 목록을 대조하는 테스트가 없었다 (발견 ①).
|
||||||
|
*/
|
||||||
|
public function test_session_cookie_name_is_resolved_at_runtime(): void
|
||||||
|
{
|
||||||
|
config(['session.cookie' => 'g7-session']);
|
||||||
|
$this->setAllowlist([]);
|
||||||
|
|
||||||
|
$middleware = $this->buildMiddleware(functionalConsent: false);
|
||||||
|
$response = $this->runMiddleware($middleware, function (HttpResponse $r) {
|
||||||
|
$r->headers->setCookie(Cookie::create('g7-session', 'sess', 0, '/'));
|
||||||
|
$r->headers->setCookie(Cookie::create('laravel_session', 'sess', 0, '/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$names = array_map(fn (Cookie $c) => $c->getName(), $response->headers->getCookies());
|
||||||
|
$this->assertContains('g7-session', $names, '설정된 세션 쿠키 이름이 통과해야 한다');
|
||||||
|
$this->assertNotContains(
|
||||||
|
'laravel_session',
|
||||||
|
$names,
|
||||||
|
'기본 이름이 하드코딩되어 있으면 설정과 무관하게 통과해 이 단언이 깨진다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿠키 허용목록 config 미러를 세웁니다.
|
||||||
|
*
|
||||||
|
* 운영에서는 `ExtensionSettingsMirror` 가 `PluginSettingsService` 를 경유해 채우는 값이다.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $cookies 운영자 설정의 cookie 스코프 목록
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function setAllowlist(array $cookies): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'g7_settings.plugins.sirsoft-gdpr.necessary_storage_allowlist' => [
|
||||||
|
'localStorage' => [],
|
||||||
|
'sessionStorage' => [],
|
||||||
|
'cookie' => $cookies,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미들웨어 인스턴스 생성 — GdprConsentService mock 만 주입.
|
||||||
|
*
|
||||||
|
* 허용목록은 config 미러에서 오므로 생성자 의존성이 아니다 (setAllowlist 로 세운다).
|
||||||
*
|
*
|
||||||
* @param bool $functionalConsent functional 동의 여부
|
* @param bool $functionalConsent functional 동의 여부
|
||||||
* @return CookieConsentMiddleware
|
* @return CookieConsentMiddleware
|
||||||
|
|||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\Gdpr\Tests\Unit\Upgrades;
|
||||||
|
|
||||||
|
use App\Extension\UpgradeContext;
|
||||||
|
use App\Support\ExtensionStoragePath;
|
||||||
|
use App\Upgrades\Data\Ext\Plugins\SirsoftGdpr\V1_0_4\Migrations\SeedNecessaryStorageAllowlist;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.0.4 업그레이드 스텝 — 필수 저장 항목 허용목록 백필 검증
|
||||||
|
*
|
||||||
|
* 이 스텝이 잘못되면 기설치본의 설정 파일이 손상되거나 운영자가 편집한 목록이 덮어써진다.
|
||||||
|
* 둘 다 오류를 남기지 않고 "설정이 되돌아갔다" 는 증상으로만 나타난다.
|
||||||
|
*
|
||||||
|
* 검사 축:
|
||||||
|
* - 키가 없으면 카탈로그를 넣는다
|
||||||
|
* - 키가 이미 있으면 값을 건드리지 않는다 (운영자 편집값 보존)
|
||||||
|
* - 재실행해도 결과가 같다 (멱등)
|
||||||
|
* - 설정 파일이 없으면 skip (설치 시 기본값이 시드한다)
|
||||||
|
* - JSON 이 깨져 있으면 skip (덮어쓰지 않는다)
|
||||||
|
* - 다른 설정 키를 건드리지 않는다
|
||||||
|
* - 잠금 항목을 시드하지 않는다 (담기면 API 로 지울 수 있게 된다)
|
||||||
|
*
|
||||||
|
* @scenario scope=localStorage, notation=exact, locked=operator_item, settings_state=empty, request=key_absent
|
||||||
|
*
|
||||||
|
* @effects upgrade_seeds_allowlist_when_key_absent, upgrade_preserves_operator_edited_allowlist, upgrade_is_idempotent, upgrade_skips_when_settings_file_missing, upgrade_skips_when_settings_json_malformed, upgrade_does_not_seed_locked_items, allowlist_default_seeded_from_shipped_catalog
|
||||||
|
*/
|
||||||
|
class SeedNecessaryStorageAllowlistTest extends PluginTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 설정 파일 절대 경로
|
||||||
|
*/
|
||||||
|
private string $path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트 환경 준비 — 설정 파일 경로 해석 및 잔여 파일 정리.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// 업그레이드 마이그레이션은 오토로드되지 않는다 — AbstractUpgradeStep 이 실행 시점에
|
||||||
|
// `data/{version}/migrations/*.php` 를 require_once 한다. 테스트도 같은 방식으로 싣는다.
|
||||||
|
require_once __DIR__.'/../../../upgrades/data/1.0.4/migrations/02_SeedNecessaryStorageAllowlist.php';
|
||||||
|
|
||||||
|
$this->path = ExtensionStoragePath::plugin('sirsoft-gdpr', 'settings').'/setting.json';
|
||||||
|
|
||||||
|
if (File::exists($this->path)) {
|
||||||
|
File::delete($this->path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트가 만든 설정 파일 정리.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (File::exists($this->path)) {
|
||||||
|
File::delete($this->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_seeds_allowlist_when_key_is_absent(): void
|
||||||
|
{
|
||||||
|
$this->writeSettings(['banner_enabled' => true]);
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
|
||||||
|
$settings = $this->readSettings();
|
||||||
|
$this->assertArrayHasKey('necessary_storage_allowlist', $settings);
|
||||||
|
$this->assertSame(
|
||||||
|
['localStorage', 'sessionStorage', 'cookie'],
|
||||||
|
array_keys($settings['necessary_storage_allowlist'])
|
||||||
|
);
|
||||||
|
$this->assertContains('g7_color_scheme', $settings['necessary_storage_allowlist']['localStorage']);
|
||||||
|
$this->assertContains('g7_filters_*', $settings['necessary_storage_allowlist']['localStorage']);
|
||||||
|
$this->assertContains('laravel_maintenance', $settings['necessary_storage_allowlist']['cookie']);
|
||||||
|
|
||||||
|
// 다른 키는 그대로 남는다.
|
||||||
|
$this->assertTrue($settings['banner_enabled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preserves_operator_edited_allowlist(): void
|
||||||
|
{
|
||||||
|
$edited = [
|
||||||
|
'localStorage' => ['only_this_one'],
|
||||||
|
'sessionStorage' => [],
|
||||||
|
'cookie' => [],
|
||||||
|
];
|
||||||
|
$this->writeSettings(['necessary_storage_allowlist' => $edited]);
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
|
||||||
|
$this->assertSame($edited, $this->readSettings()['necessary_storage_allowlist']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_idempotent_on_repeated_runs(): void
|
||||||
|
{
|
||||||
|
$this->writeSettings(['banner_enabled' => true]);
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
$first = $this->readSettings();
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
$second = $this->readSettings();
|
||||||
|
|
||||||
|
$this->assertSame($first, $second);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_skips_when_settings_file_missing(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(File::exists($this->path));
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
|
||||||
|
$this->assertFalse(File::exists($this->path), '파일이 없으면 새로 만들지 않는다');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_skips_when_settings_json_is_malformed(): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists(dirname($this->path));
|
||||||
|
File::put($this->path, '{ not json');
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
|
||||||
|
$this->assertSame('{ not json', File::get($this->path), '깨진 파일을 덮어쓰지 않는다');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_does_not_seed_locked_items(): void
|
||||||
|
{
|
||||||
|
$this->writeSettings([]);
|
||||||
|
|
||||||
|
$this->runStep();
|
||||||
|
|
||||||
|
$allowlist = $this->readSettings()['necessary_storage_allowlist'];
|
||||||
|
$this->assertNotContains('auth_token', $allowlist['localStorage']);
|
||||||
|
$this->assertNotContains('XSRF-TOKEN', $allowlist['cookie']);
|
||||||
|
$this->assertNotContains('gdpr_session', $allowlist['cookie']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업그레이드 스텝을 실행합니다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function runStep(): void
|
||||||
|
{
|
||||||
|
(new SeedNecessaryStorageAllowlist)->run(
|
||||||
|
new UpgradeContext(
|
||||||
|
fromVersion: '1.0.3',
|
||||||
|
toVersion: '1.0.4',
|
||||||
|
currentStep: '1.0.4',
|
||||||
|
logChannel: 'extension-upgrade',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 파일을 씁니다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $settings 설정 내용
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function writeSettings(array $settings): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists(dirname($this->path));
|
||||||
|
File::put($this->path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 파일을 읽습니다.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function readSettings(): array
|
||||||
|
{
|
||||||
|
return json_decode(File::get($this->path), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ description: |
|
|||||||
그 키만 게이팅 대상이 되도록 설계했으나, 이는 GDPR 원칙 ("strictly necessary 외 비-필수는
|
그 키만 게이팅 대상이 되도록 설계했으나, 이는 GDPR 원칙 ("strictly necessary 외 비-필수는
|
||||||
동의 전 차단") 과 충돌하여 등록 표를 제거하고 4단계 단순화 규칙으로 통합:
|
동의 전 차단") 과 충돌하여 등록 표를 제거하고 4단계 단순화 규칙으로 통합:
|
||||||
|
|
||||||
1. strictly necessary allowlist (코드 상수) → 통과
|
1. strictly necessary 허용목록 (운영자 설정 ∪ 잠금 집합) → 통과
|
||||||
2. functional 동의 → 통과
|
2. functional 동의 → 통과
|
||||||
3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 통과
|
3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 통과
|
||||||
4. 그 외 → 차단
|
4. 그 외 → 차단
|
||||||
@@ -109,7 +109,7 @@ test_files:
|
|||||||
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/functionalCleaner.test.ts
|
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/functionalCleaner.test.ts
|
||||||
|
|
||||||
limitations:
|
limitations:
|
||||||
- "strictly necessary allowlist 는 코드 상수 (운영자 미수정). 추가 시 본 플러그인 소스 수정 + PR 필요. 코어/번들 확장이 신규 1st-party 키 도입 시 allowlist 갱신 의무."
|
- "strictly necessary 허용목록은 **운영자 설정**이다 (necessary_storage_allowlist). 코드 상수는 신규 설치 시드용 출하 카탈로그일 뿐이며, 새 저장 키가 필요하면 운영자가 관리자 화면에서 추가한다 — 이 플러그인 소스 수정은 필요 없다. 상세: necessary-storage-allowlist.yaml"
|
||||||
- "인터셉터는 신규 쓰기만 차단. 인터셉터 install 이전에 동기 실행된 쓰기는 잡지 못함 — 부팅 시점 cleaner 가 보완."
|
- "인터셉터는 신규 쓰기만 차단. 인터셉터 install 이전에 동기 실행된 쓰기는 잡지 못함 — 부팅 시점 cleaner 가 보완."
|
||||||
- "user-initiated 면제 (WP29 §3.6) 의 500ms 임계값은 휴리스틱 — 일반 SPA 의 click → setState → effect → localStorage 체인 커버 목적."
|
- "user-initiated 면제 (WP29 §3.6) 의 500ms 임계값은 휴리스틱 — 일반 SPA 의 click → setState → effect → localStorage 체인 커버 목적."
|
||||||
- "외부 추적 도메인 (Crisp 등) 의 자동 차단은 별도 영역 — 본 시나리오는 1st-party storage 만 다룸 (blocker-domains.yaml / preblocker.yaml 참조)."
|
- "외부 추적 도메인 (Crisp 등) 의 자동 차단은 별도 영역 — 본 시나리오는 1st-party storage 만 다룸 (blocker-domains.yaml / preblocker.yaml 참조)."
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# audit:allow test-scenario-coverage reason: 판정 축(스코프 3 × 표기 2 × 잠금 2 × 설정상태 3)과 저장 축(요청 5)은 서로 독립이라 곱집합의 대부분이 성립하지 않는 조합이다 — 예컨대 "잠금 항목을 와일드카드로 적은 요청"은 입력 자체가 존재하지 않는다. 등록된 PHPUnit 45 + Vitest 178 + Playwright 3 = 226 케이스가 각 축의 경계값(스코프 넘나듦·와일드카드 앞부분 경계·설정 비었을 때 잠금 생존·설정 미도달 폴백·형식/스코프 위반·키 미전송)과 후속 효과 체인을 전부 덮는다.
|
||||||
|
|
||||||
|
feature: 필수 저장 항목 허용목록의 운영자 설정 이관
|
||||||
|
|
||||||
|
description: |
|
||||||
|
기능 쿠키 미동의 상태에서도 저장이 허용되는 항목(허용목록)을 플러그인 코드 상수에서
|
||||||
|
**운영자 설정**(`necessary_storage_allowlist`)으로 옮긴다. 코드 상수는 신규 설치 시
|
||||||
|
채워지는 출하 기본 카탈로그와 관리자 화면 추천 목록 역할만 남는다.
|
||||||
|
|
||||||
|
왜:
|
||||||
|
허용목록이 코드 상수이면, 새 확장이 저장 키를 도입할 때마다 GDPR 플러그인도 함께 고쳐야
|
||||||
|
한다. 제3자 확장 개발자에게는 그 방법이 없고, 운영자는 자기 사이트에서 무엇이 필수로
|
||||||
|
분류되어 있는지 보지도 바꾸지도 못한다. 누락은 예외도 로그도 남기지 않고 "설정이 저장되지
|
||||||
|
않는다" 는 증상만 남긴다 (dev-g7#640 에서 15건 / 6계열이 그 상태였다).
|
||||||
|
|
||||||
|
구조:
|
||||||
|
[출하 기본 카탈로그] plugin.php 상수
|
||||||
|
→ 신규 설치 시드 + 관리자 화면 추천 목록
|
||||||
|
[운영자 설정] necessary_storage_allowlist ← 관리자 화면에서 편집
|
||||||
|
→ G7Config 인라인 페이로드 (fetch 이전에 도달해야 한다)
|
||||||
|
[소비자] storageInterceptor · cookieInterceptor · functionalCleaner · CookieConsentMiddleware
|
||||||
|
|
||||||
|
잠금 항목(`auth_token` / `XSRF-TOKEN` / 세션 쿠키 / `gdpr_session`)은 설정에 넣지 않는다 —
|
||||||
|
설정에 담기면 API 로 지울 수 있어 잠금이 아니게 된다. 판정은 언제나 운영자 목록 ∪ 잠금 집합.
|
||||||
|
|
||||||
|
axes:
|
||||||
|
scope: [localStorage, sessionStorage, cookie]
|
||||||
|
notation: [exact, wildcard]
|
||||||
|
locked: [locked_item, operator_item]
|
||||||
|
settings_state: [populated, empty, unreachable]
|
||||||
|
request: [valid_item, invalid_format, unknown_scope, locked_key_in_body, key_absent]
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
- { locked: locked_item, settings_state: any, reason: "잠금 항목은 설정과 무관하게 항상 통과 — 설정 상태 축과 교차하지 않는다" }
|
||||||
|
- { locked: locked_item, notation: wildcard, reason: "잠금 항목은 코드가 정한 고정 이름이라 와일드카드 표기가 없다" }
|
||||||
|
- { request: key_absent, scope: any, reason: "키 미전송은 스코프와 무관하게 기존 값 보존 하나로 판정된다" }
|
||||||
|
- { request: unknown_scope, notation: any, reason: "스코프 키 자체가 거부 대상이라 항목 표기 축과 교차하지 않는다" }
|
||||||
|
- { request: locked_key_in_body, notation: any, reason: "잠금 키 전송은 항목 표기와 무관하게 validated() 에서 배제된다" }
|
||||||
|
- { settings_state: unreachable, request: any, reason: "설정이 도달하지 못한 상태는 저장 경로가 아니라 판정 경로의 축이다" }
|
||||||
|
|
||||||
|
effects:
|
||||||
|
# 설정 정의 · 노출
|
||||||
|
- allowlist_default_seeded_from_shipped_catalog
|
||||||
|
- allowlist_exposed_in_inline_payload_via_frontend_schema
|
||||||
|
- locked_set_exposed_in_inline_payload
|
||||||
|
- locked_set_absent_from_shipped_catalog
|
||||||
|
- shipped_catalog_items_pass_save_validation
|
||||||
|
|
||||||
|
# 클라이언트 소비 (storageInterceptor)
|
||||||
|
- storage_allows_item_listed_in_settings
|
||||||
|
- storage_blocks_item_removed_from_settings
|
||||||
|
- storage_wildcard_matches_prefix_only
|
||||||
|
- storage_scope_does_not_leak_across_storages
|
||||||
|
- storage_locked_item_survives_empty_settings
|
||||||
|
- storage_config_update_applies_new_allowlist
|
||||||
|
|
||||||
|
# 클라이언트 소비 (cookieInterceptor)
|
||||||
|
- cookie_allows_item_listed_in_settings
|
||||||
|
- cookie_blocks_item_removed_from_settings
|
||||||
|
- cookie_wildcard_matches_prefix_only
|
||||||
|
- cookie_scope_does_not_borrow_storage_scope
|
||||||
|
- cookie_config_update_applies_new_allowlist
|
||||||
|
|
||||||
|
# 클라이언트 소비 (functionalCleaner)
|
||||||
|
- cleaner_preserves_items_listed_in_settings
|
||||||
|
- cleaner_purges_items_removed_from_settings
|
||||||
|
- cleaner_cookie_wildcard_preserves_prefix_match
|
||||||
|
- cleaner_scope_does_not_leak_across_storages
|
||||||
|
- cleaner_locked_items_survive_missing_settings
|
||||||
|
|
||||||
|
# 서버 소비 (CookieConsentMiddleware)
|
||||||
|
- middleware_passes_cookie_listed_in_operator_settings
|
||||||
|
- middleware_removes_cookie_absent_from_settings
|
||||||
|
- middleware_supports_wildcard_in_cookie_allowlist
|
||||||
|
- middleware_locked_cookies_survive_empty_settings
|
||||||
|
- middleware_locked_cookies_cannot_be_removed_via_settings
|
||||||
|
- middleware_session_cookie_name_resolved_at_runtime
|
||||||
|
|
||||||
|
# 저장 · 검증
|
||||||
|
- update_persists_allowlist_per_scope
|
||||||
|
- update_normalizes_textarea_string_to_array
|
||||||
|
- update_without_key_leaves_existing_value_untouched
|
||||||
|
- update_rejects_invalid_item_format_with_scope_indexed_error_key
|
||||||
|
- update_rejects_unknown_scope
|
||||||
|
- update_ignores_locked_list_from_request
|
||||||
|
- show_includes_default_catalog_previews
|
||||||
|
|
||||||
|
# 기설치본 시드 (업그레이드 스텝)
|
||||||
|
- upgrade_seeds_allowlist_when_key_absent
|
||||||
|
- upgrade_preserves_operator_edited_allowlist
|
||||||
|
- upgrade_is_idempotent
|
||||||
|
- upgrade_skips_when_settings_file_missing
|
||||||
|
- upgrade_skips_when_settings_json_malformed
|
||||||
|
- upgrade_does_not_seed_locked_items
|
||||||
|
|
||||||
|
# 두 구현 정합 (PHP ↔ TS)
|
||||||
|
- ts_fallback_matches_php_locked_set
|
||||||
|
- ts_fallback_is_not_a_catalog_copy
|
||||||
|
- wildcard_rule_is_isomorphic_across_php_and_ts
|
||||||
|
- scope_vocabulary_matches_across_php_and_ts
|
||||||
|
- server_cookie_gate_reads_settings_not_hardcoded_list
|
||||||
|
|
||||||
|
# 관리자 화면 (레이아웃 구조)
|
||||||
|
- layout_renders_three_scope_cards
|
||||||
|
- layout_uses_static_setstate_key_with_object_spread
|
||||||
|
- layout_extracts_value_from_event_target_value
|
||||||
|
- layout_error_key_prefix_matches_scope
|
||||||
|
- layout_locked_chips_read_response_not_form_state
|
||||||
|
- layout_card_not_gated_behind_banner_toggle
|
||||||
|
- layout_i18n_keys_exist_in_ko_and_en
|
||||||
|
|
||||||
|
# 브라우저 종단 (E2E)
|
||||||
|
- e2e_allowlist_cards_render_three_scopes
|
||||||
|
- e2e_locked_chips_render_readonly
|
||||||
|
- e2e_allowlist_tag_persists_after_reload
|
||||||
|
- e2e_allowlist_reaches_inline_payload
|
||||||
|
- e2e_allowlist_invalid_item_rejected
|
||||||
|
|
||||||
|
test_files:
|
||||||
|
# PHPUnit (백엔드)
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Middleware/CookieConsentMiddlewareTest.php
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/tests/Unit/Upgrades/SeedNecessaryStorageAllowlistTest.php
|
||||||
|
|
||||||
|
# Vitest (프론트엔드)
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/storageInterceptor.test.ts
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/cookieInterceptor.test.ts
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/functionalCleaner.test.ts
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/necessaryAllowlistCoverage.test.ts
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx
|
||||||
|
|
||||||
|
# Playwright (브라우저 종단)
|
||||||
|
- plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts
|
||||||
|
|
||||||
|
limitations:
|
||||||
|
- "새 확장의 저장 키를 찾는 일은 운영자 몫이다 — 플러그인은 저장 키를 관측·수집하지 않는다 (관측하면 그 자체가 추적이 된다)."
|
||||||
|
- "`*` 는 항목 끝에만 쓸 수 있다. 앞이나 중간의 `*` 는 저장 시점에 거부된다 — 접두사가 비면 전체 개방이 되어 게이트가 무력화되기 때문이다."
|
||||||
|
- "인라인 페이로드가 도달하지 못하면 잠금 집합만으로 서고, 그 창에서는 운영자가 등재한 항목이 파기된다. 그 상태는 로그를 남기지 않으므로 E2E 의 인라인 페이로드 축이 유일한 관문이다."
|
||||||
|
- "세션 쿠키 이름은 서버 설정(`session.cookie`)이 정하는 런타임 값이라 클라이언트 폴백에 담을 수 없다. 인라인이 실패한 극단 상황에서는 세션 쿠키의 httpOnly 기본값이 방어한다."
|
||||||
|
- "출하 카탈로그를 나중에 줄여도 이미 시드된 사이트의 저장값은 그대로 남는다 (의도) — 운영자가 화면에서 직접 빼야 한다."
|
||||||
@@ -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 {}
|
||||||
+147
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Upgrades\Data\Ext\Plugins\SirsoftGdpr\V1_0_4\Migrations;
|
||||||
|
|
||||||
|
use App\Extension\Helpers\FilePermissionHelper;
|
||||||
|
use App\Extension\Upgrade\DataMigration;
|
||||||
|
use App\Extension\UpgradeContext;
|
||||||
|
use App\Support\ExtensionStoragePath;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기설치본의 설정 파일에 필수 저장 항목 허용목록을 백필합니다.
|
||||||
|
*
|
||||||
|
* 배경:
|
||||||
|
*
|
||||||
|
* 이 버전에서 허용목록이 코드 상수에서 운영자 설정(`necessary_storage_allowlist`)으로
|
||||||
|
* 옮겨졌습니다. `config/settings/defaults.json` 은 설치 시점에 설정 파일을 한 번 시드할 뿐
|
||||||
|
* 이후 조회 폴백에 참여하지 않으므로, 이미 설치된 사이트의 설정 파일에는 이 키가 생기지
|
||||||
|
* 않습니다. 그 상태에서도 조회는 플러그인 기본값으로 폴백해 동작하지만, 관리자 화면이
|
||||||
|
* "운영자가 편집한 값" 을 보여주는 자리에 저장된 값이 없으면 이후 카탈로그가 바뀔 때마다
|
||||||
|
* 운영자 모르게 목록이 함께 바뀝니다. 그래서 지금 시점의 카탈로그를 파일에 못박습니다.
|
||||||
|
*
|
||||||
|
* 멱등: 키가 이미 있으면 값을 덮어쓰지 않습니다 — 운영자가 이미 편집했으면 그대로 둡니다.
|
||||||
|
*
|
||||||
|
* 잠금 항목(`auth_token` / `XSRF-TOKEN` / 세션 쿠키 / `gdpr_session`)은 시드하지 않습니다.
|
||||||
|
* 설정에 담기면 API 로 지울 수 있어 잠금이 아니게 되기 때문이며, 판정에는 코드가 언제나
|
||||||
|
* 합집합으로 얹습니다.
|
||||||
|
*
|
||||||
|
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||||
|
* - 파일 시스템 + 코어 경로 해석기 + FilePermissionHelper 만 사용
|
||||||
|
* - Service / Manager / Repository 컨테이너 해석 없음 (플러그인 클래스도 해석하지 않는다 —
|
||||||
|
* 업그레이드 시점에는 그 클래스가 교체 중일 수 있으므로 값을 이 파일에 동결한다)
|
||||||
|
*/
|
||||||
|
final class SeedNecessaryStorageAllowlist implements DataMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 백필할 허용목록 (1.0.4 시점 카탈로그 동결).
|
||||||
|
*
|
||||||
|
* `plugin.php::DEFAULT_NECESSARY_ALLOWLIST_CATALOG` 의 사본이지만 의도적으로 동결한
|
||||||
|
* 값입니다 — 업그레이드 스텝은 "그 버전으로 올라오는 순간의 상태" 를 재현해야 하므로,
|
||||||
|
* 나중에 카탈로그가 바뀌어도 이 스텝의 결과는 달라지지 않아야 합니다.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private const CATALOG = [
|
||||||
|
'localStorage' => [
|
||||||
|
'g7_locale', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_color_scheme', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_cache_version', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_asset_url_mode*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_cart_key', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7-devtools-panel',
|
||||||
|
'g7_guest_order_token', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_guest_order_number', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_guest_order_expires_at', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_devtools_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_filters_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_columns_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_order_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_admin_sidebar_collapsed', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_filter_visibility_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_dismissed_warnings', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7le.*',
|
||||||
|
'__sirsoftKginicisMobilePaymentReturnPending',
|
||||||
|
'g7.identity.redirectStash',
|
||||||
|
'sirsoft-verification_nhnkcp.formStash',
|
||||||
|
],
|
||||||
|
'sessionStorage' => [
|
||||||
|
'g7:sirsoft-pay_kginicis:pendingClose',
|
||||||
|
'g7:sirsoft-pay_nhnkcp:pendingClose',
|
||||||
|
'g7:sirsoft-tosspayments:pendingClose',
|
||||||
|
'g7.identity.redirectStash',
|
||||||
|
'sirsoft-verification_nhnkcp.formStash',
|
||||||
|
'__sirsoftKginicisMobilePaymentReturnPending',
|
||||||
|
'g7le.*',
|
||||||
|
'g7_devtools_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_filters_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_columns_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_order_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
'g7_filter_visibility_*', // audit:allow raw-literal-db-prefix 브라우저 저장소 키 이름 (DB 테이블·색인명 아님)
|
||||||
|
],
|
||||||
|
'cookie' => [
|
||||||
|
'laravel_maintenance',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마이그레이션 식별자 (로그용).
|
||||||
|
*
|
||||||
|
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||||
|
*/
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'SeedNecessaryStorageAllowlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 파일에 허용목록 키를 추가합니다. 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)) {
|
||||||
|
$context->logger->warning('[sirsoft-gdpr] 설정 JSON 형식 비정상 — 허용목록 백필 skip');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('necessary_storage_allowlist', $settings)) {
|
||||||
|
$context->logger->info('[sirsoft-gdpr] 허용목록이 이미 존재 — 운영자 편집값 보존, 변경 없음');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings['necessary_storage_allowlist'] = self::CATALOG;
|
||||||
|
|
||||||
|
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||||
|
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||||
|
|
||||||
|
$context->logger->info('[sirsoft-gdpr] 필수 저장 항목 허용목록 백필 완료', [
|
||||||
|
'scopes' => array_keys(self::CATALOG),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2323,6 +2323,17 @@ hr {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 결과/안내 섹션으로 스크롤할 때 sticky 헤더에 가려지지 않도록 여백을 확보한다.
|
||||||
|
* 헤더 실높이는 데스크톱 70px / 모바일 60px (+ 하단 테두리 1px) 이므로 여유를 포함해 90px.
|
||||||
|
* 전역 `html { scroll-behavior: smooth }` 는 두지 않는다 — 로그 영역 등 내부 스크롤에
|
||||||
|
* 부작용을 준다. 스크롤 동작은 JS(scrollResultIntoView)가 개별 호출로 지정한다.
|
||||||
|
*/
|
||||||
|
.result-section,
|
||||||
|
#env-setup-section {
|
||||||
|
scroll-margin-top: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 성공 배경 */
|
/* 성공 배경 */
|
||||||
.result-section:has(.result-icon-success) {
|
.result-section:has(.result-icon-success) {
|
||||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||||
|
|||||||
@@ -3842,6 +3842,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결과/안내 섹션을 뷰포트로 부드럽게 스크롤합니다.
|
||||||
|
*
|
||||||
|
* 완료/실패/중단 안내는 페이지 최상단에 있는데, 설치 진행 중에는 사용자가 로그를 보느라
|
||||||
|
* 화면이 하단에 머물러 있는 경우가 많다. 그대로 두면 안내가 표시되어도 눈에 들어오지
|
||||||
|
* 않는다. 이미 최상단이면 스크롤은 no-op 이라 새로고침 복원 경로에서도 무해하다.
|
||||||
|
*
|
||||||
|
* 헤더 바가 sticky 이므로 섹션 상단이 가려지지 않도록 CSS 의 scroll-margin-top 이
|
||||||
|
* 여백을 확보한다 (installer.css 의 .result-section, #env-setup-section).
|
||||||
|
*
|
||||||
|
* @param {HTMLElement|null} sectionEl 스크롤 대상 섹션
|
||||||
|
*/
|
||||||
|
function scrollResultIntoView(sectionEl) {
|
||||||
|
if (!sectionEl) return;
|
||||||
|
|
||||||
|
// 모션 최소화를 선호하는 사용자는 즉시 이동
|
||||||
|
const reduceMotion = window.matchMedia
|
||||||
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
sectionEl.scrollIntoView({
|
||||||
|
behavior: reduceMotion ? 'auto' : 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 완료 섹션 표시
|
* 완료 섹션 표시
|
||||||
*/
|
*/
|
||||||
@@ -3924,6 +3949,9 @@
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// fetch 자체가 실패해도 무시 — runtime.php 가 보존되어 다음 부팅 시 앱 정상 동작
|
// fetch 자체가 실패해도 무시 — runtime.php 가 보존되어 다음 부팅 시 앱 정상 동작
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 완료 안내로 스크롤 (타이틀 숨김·카드 접기로 문서 높이가 바뀐 뒤에 호출)
|
||||||
|
scrollResultIntoView(completionSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4066,6 +4094,9 @@
|
|||||||
failureSection.style.opacity = '1';
|
failureSection.style.opacity = '1';
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 실패 안내로 스크롤 (카드 body 를 펼친 뒤에 호출)
|
||||||
|
scrollResultIntoView(failureSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4399,6 +4430,9 @@
|
|||||||
setTaskStatus(nextTask.id, 'aborted', nextTask.target || null);
|
setTaskStatus(nextTask.id, 'aborted', nextTask.target || null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 중단 안내로 스크롤 (로그 렌더링으로 문서 높이가 늘어난 뒤에 호출)
|
||||||
|
scrollResultIntoView(abortedSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5213,6 +5247,9 @@
|
|||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.classList.add('hidden');
|
statusEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 안내 섹션으로 스크롤 (목록·명령어를 채운 뒤에 호출)
|
||||||
|
scrollResultIntoView(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
|||||||
| 종류 | 개수 | 위치 |
|
| 종류 | 개수 | 위치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PHPUnit | 0개 | — |
|
| PHPUnit | 0개 | — |
|
||||||
| Vitest | 203개 | `vitest.config.ts` |
|
| Vitest | 205개 | `vitest.config.ts` |
|
||||||
| Playwright | 8개 | `tests/Playwright` |
|
| Playwright | 9개 | `tests/Playwright` |
|
||||||
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
||||||
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
|
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
|
||||||
- 알림이 여러 건일 때 서로 맞붙어 표시되던 문제를 수정했습니다. 이제 간격을 두고 차례로 쌓입니다.
|
- 알림이 여러 건일 때 서로 맞붙어 표시되던 문제를 수정했습니다. 이제 간격을 두고 차례로 쌓입니다.
|
||||||
|
- 로그인·비밀번호 찾기·비밀번호 재설정 화면의 테마(밝게/어둡게/시스템 설정) 버튼이 눌러도 아무 반응이 없던 문제를 수정했습니다. 로그인 이후 화면은 정상이었기 때문에 이 세 화면에서만 나타났습니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
|
||||||
|
- 레이아웃 편집기의 「액션 추가」로 만든 일부 동작이 만들자마자 아무 일도 하지 않던 문제를 수정했습니다. 화면 테마 바꾸기·화면 언어 바꾸기·화면 테마 초기화·특정 영역으로 스크롤·기간 빠르게 선택·필터 보이기/숨기기·필터 표시 초기화·다국어 태그 저장·화면 상태 바꾸기 아홉 가지가 대상이며, 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
|
||||||
|
- 필터 보이기/숨기기와 필터 표시 초기화 동작에 저장 키를 입력할 자리가 없어, 편집기로 만들면 필터 상태가 저장도 복원도 되지 않던 문제를 수정했습니다.
|
||||||
|
- 사이드바 하위 메뉴의 펼치기/접기 버튼에 번역되지 않은 원문(`common.expand`)이 그대로 붙어 있던 문제를 수정했습니다. 화면에는 보이지 않지만 화면 낭독기 사용자에게 그대로 읽혔습니다.
|
||||||
|
|
||||||
## [1.0.7] - 2026-08-24
|
## [1.0.7] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @file action-recipes-contract.test.ts
|
||||||
|
* @description 레이아웃 편집기 액션 레시피(actionRecipes.json) ↔ 실제 핸들러 계약 일치 회귀 테스트
|
||||||
|
*
|
||||||
|
* 배경: 편집기의 「액션 추가」 팔레트는 이 레시피의 `build` 를 그대로 레이아웃 JSON 으로 굽는다.
|
||||||
|
* 그래서 레시피가 핸들러 계약과 어긋나 있으면, 운영자가 편집기로 만든 액션이 **생성 즉시 no-op** 이
|
||||||
|
* 된다. 예외도 오류도 남지 않고 버튼만 반응하지 않으므로 만든 사람이 알아챌 방법이 없다.
|
||||||
|
*
|
||||||
|
* 아래 8종은 실제로 어긋나 있던 항목이다 (dev-g7#640 부수의무 전수 조사).
|
||||||
|
*
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const recipes = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../editor-spec/actionRecipes.json'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 레시피의 입력 필드 선언에서 key 목록을 뽑는다. */
|
||||||
|
function paramKeys(id: string): string[] {
|
||||||
|
return (recipes[id]?.params ?? []).map((p: any) => p.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('actionRecipes.json — 핸들러 계약 일치', () => {
|
||||||
|
describe('target 형 액션 (params 가 아니라 top-level target)', () => {
|
||||||
|
// setLocale 은 엔진 빌트인(ActionDispatcher), setTheme/initTheme 은 템플릿 핸들러.
|
||||||
|
// 셋 다 action.target 만 읽고 params 는 보지 않는다.
|
||||||
|
it.each(['setLocale', 'setTheme', 'initTheme'])('%s 는 top-level target 으로 굽는다', (id) => {
|
||||||
|
const build = recipes[id]?.build;
|
||||||
|
expect(build).toBeDefined();
|
||||||
|
expect(build.target).toBe('{{target}}');
|
||||||
|
expect(build.params).toBeUndefined();
|
||||||
|
expect(paramKeys(id)).toContain('target');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrollToSection 은 params.targetId 로 굽는다 (sectionId 아님)', () => {
|
||||||
|
const build = recipes.scrollToSection?.build;
|
||||||
|
expect(build.params).toEqual({ targetId: '{{targetId}}' });
|
||||||
|
expect(build.params.sectionId).toBeUndefined();
|
||||||
|
expect(paramKeys('scrollToSection')).toEqual(['targetId']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setDateRange 의 preset 선택지는 핸들러 유효값 안에 있다', () => {
|
||||||
|
// setDateRangeHandler 의 DatePreset 타입
|
||||||
|
const validPresets = ['today', 'week', 'month', '3months', '6months', '1year'];
|
||||||
|
const options = recipes.setDateRange?.params?.[0]?.options ?? [];
|
||||||
|
const values = options.map((o: any) => o.value);
|
||||||
|
|
||||||
|
expect(values.length).toBeGreaterThan(0);
|
||||||
|
for (const v of values) expect(validPresets).toContain(v);
|
||||||
|
// 'year' 는 핸들러가 모르는 값 — switch 어느 분기에도 걸리지 않는다
|
||||||
|
expect(values).not.toContain('year');
|
||||||
|
expect(values).toContain('1year');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleFilterVisibility 는 storageKey + filterId 를 모두 넘긴다', () => {
|
||||||
|
const build = recipes.toggleFilterVisibility?.build;
|
||||||
|
expect(build.params).toEqual({
|
||||||
|
storageKey: '{{storageKey}}',
|
||||||
|
filterId: '{{filterId}}',
|
||||||
|
});
|
||||||
|
expect(build.params.filterKey).toBeUndefined();
|
||||||
|
expect(paramKeys('toggleFilterVisibility').sort()).toEqual(['filterId', 'storageKey']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initFilterVisibility 는 storageKey 를 넘긴다 (없으면 핸들러가 조기 반환)', () => {
|
||||||
|
const build = recipes.initFilterVisibility?.build;
|
||||||
|
expect(build.params).toEqual({ storageKey: '{{storageKey}}' });
|
||||||
|
expect(paramKeys('initFilterVisibility')).toEqual(['storageKey']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveMultilingualTag 은 인자를 받지 않는다 (핸들러가 전역 편집 상태만 읽음)', () => {
|
||||||
|
const recipe = recipes.saveMultilingualTag;
|
||||||
|
expect(recipe.build.params).toBeUndefined();
|
||||||
|
expect(recipe.params).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changeState 는 setState 의 상태 범위를 params.target 으로 넘긴다', () => {
|
||||||
|
// handleSetState 는 resolvedParams 에서 target 을 읽는다 (루트 action.target 은 무시).
|
||||||
|
// 루트에 두면 기본값 'component' 로 떨어져, 나중에 global 을 고를 수 있게 되는 순간
|
||||||
|
// 전역 대신 _local 에 조용히 기록된다.
|
||||||
|
const build = recipes.changeState?.build;
|
||||||
|
expect(build.handler).toBe('setState');
|
||||||
|
expect(build.target).toBeUndefined();
|
||||||
|
expect(build.params?.target).toBe('local');
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @file admin-auth-settheme-target.test.tsx
|
||||||
|
* @description 비인증 화면(로그인·비밀번호찾기·비밀번호재설정) 테마 버튼의 setTheme 액션 형태 회귀 테스트
|
||||||
|
*
|
||||||
|
* 배경: 세 화면의 테마 버튼(밝게/어둡게/자동)이 `"params": { "theme": "dark" }` 형태로
|
||||||
|
* `setTheme` 을 호출했으나, 템플릿 핸들러(`src/handlers/setThemeHandler.ts`)는 **`action.target`
|
||||||
|
* 만** 읽는다. 엔진(ActionDispatcher)에도 `target` ↔ `params` 상호 폴백이 없으므로 클릭 시
|
||||||
|
* `[Handler:SetTheme] Invalid theme: undefined` 경고만 남기고 아무 일도 일어나지 않았다.
|
||||||
|
* 로그인 이후 화면은 핸들러를 경유하지 않는 `ThemeToggle` 컴포지트를 쓰므로 정상이었고,
|
||||||
|
* 그래서 이 결함은 미인증 3화면에서만 나타났다.
|
||||||
|
*
|
||||||
|
* 조치: 같은 디렉토리의 `admin-auth-setlocale-target.test.tsx` 가 잠근 setLocale 선례와 동형으로,
|
||||||
|
* 세 화면의 액션을 `params.theme` → top-level `target` 으로 옮겼다.
|
||||||
|
*
|
||||||
|
* 이 테스트를 `params.theme` 로 되돌리면 세 화면의 테마 전환이 조용히 죽는다.
|
||||||
|
*
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const baseDir = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
function loadJson(relPath: string): any {
|
||||||
|
return JSON.parse(fs.readFileSync(path.resolve(baseDir, relPath), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레이아웃 전체에서 handler === 'setTheme' 인 액션을 모두 수집한다. */
|
||||||
|
function collectSetThemeActions(node: any, acc: any[] = []): any[] {
|
||||||
|
if (!node || typeof node !== 'object') return acc;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const n of node) collectSetThemeActions(n, acc);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
for (const action of node.actions ?? []) {
|
||||||
|
if (action?.handler === 'setTheme') acc.push(action);
|
||||||
|
}
|
||||||
|
for (const k of ['children', 'components']) {
|
||||||
|
if (node[k]) collectSetThemeActions(node[k], acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_THEMES = ['light', 'dark', 'auto'];
|
||||||
|
|
||||||
|
const layouts: Array<[string, string]> = [
|
||||||
|
['admin_login', 'layouts/admin_login.json'],
|
||||||
|
['admin_forgot_password', 'layouts/admin_forgot_password.json'],
|
||||||
|
['admin_reset_password', 'layouts/admin_reset_password.json'],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('비인증 화면 테마 버튼 — setThemeHandler 규약', () => {
|
||||||
|
it.each(layouts)('%s 의 setTheme 3건은 target 으로 테마를 넘긴다', (_name, relPath) => {
|
||||||
|
const layout = loadJson(relPath);
|
||||||
|
const actions = collectSetThemeActions(layout.components ?? layout);
|
||||||
|
|
||||||
|
// 밝게 / 어둡게 / 자동 세 버튼
|
||||||
|
expect(actions.length).toBe(3);
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
expect(action.type).toBe('click');
|
||||||
|
expect(VALID_THEMES).toContain(action.target);
|
||||||
|
// 핸들러는 params 를 읽지 않는다. 남아 있으면 무시되어 테마 전환이 죽는다.
|
||||||
|
expect(action.params).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세 버튼이 서로 다른 테마를 지정한다
|
||||||
|
expect([...actions.map((a) => a.target)].sort()).toEqual(['auto', 'dark', 'light']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('세 화면 합계 9건이 모두 target 형식이다', () => {
|
||||||
|
const all = layouts.flatMap(([, relPath]) => {
|
||||||
|
const layout = loadJson(relPath);
|
||||||
|
return collectSetThemeActions(layout.components ?? layout);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(all.length).toBe(9);
|
||||||
|
expect(all.every((a) => VALID_THEMES.includes(a.target))).toBe(true);
|
||||||
|
expect(all.every((a) => a.params === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,46 +43,63 @@
|
|||||||
|
|
||||||
다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage
|
다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage
|
||||||
저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을,
|
저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을,
|
||||||
`initTheme` 은 params 없이 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
|
`initTheme` 은 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
|
||||||
|
|
||||||
|
두 핸들러 모두 테마 값을 액션 **top-level `target`** 으로 받습니다. `params.theme` 으로 넘기면
|
||||||
|
엔진에 상호 폴백이 없어 조용히 no-op 이 됩니다 — 콘솔 경고 한 줄 외에는 아무 흔적이 없습니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "click", "handler": "setTheme", "params": { "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" } }
|
{ "type": "click", "handler": "setTheme", "target": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`initTheme` 의 `target` 은 선택입니다 — 유효한 테마 값이면 그것을 적용하고, 없거나 유효하지
|
||||||
|
않으면 localStorage 저장값(없으면 `auto`)으로 복원합니다.
|
||||||
|
|
||||||
### scrollToSection
|
### scrollToSection
|
||||||
|
|
||||||
`params.selector`(CSS 선택자, 필수) 로 지정한 요소로 부드럽게 스크롤합니다. `params.offset`
|
`params.targetId`(엘리먼트 **ID**, 필수) 로 지정한 요소로 부드럽게 스크롤합니다 — CSS 선택자가
|
||||||
(기본 `0`, 음수면 위로)은 고정 헤더 높이를 보상할 때 씁니다.
|
아니라 `getElementById` 대상이므로 `#` 이나 클래스 선택자를 넣지 않습니다. `params.offset`
|
||||||
|
(기본 `120`)은 고정 헤더 높이를 보상하는 여백이고, `params.delay`(기본 `100`)는 조건부 렌더링
|
||||||
|
요소를 기다리는 재시도 간격, `params.scrollContainerId` 는 스크롤 컨테이너를 명시할 때 씁니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "click", "handler": "scrollToSection", "params": { "selector": "#features", "offset": -80 } }
|
{ "type": "click", "handler": "scrollToSection", "params": { "targetId": "features", "offset": 80 } }
|
||||||
```
|
```
|
||||||
|
|
||||||
### initMenuFromUrl
|
### initMenuFromUrl
|
||||||
|
|
||||||
현재 URL 경로를 사이드바 메뉴 항목과 매칭해 활성 메뉴(및 부모 메뉴의 펼침 상태)를 자동
|
URL **쿼리스트링**(`?menu=<slug>&mode=<모드>`)을 읽어 메뉴 관리 화면의 선택 메뉴와 편집 모드를
|
||||||
설정합니다. params 없이 `_admin_base.json` 의 `init_actions` 에서 호출합니다.
|
초기화합니다. `window.location.pathname` 을 사이드바 메뉴와 매칭하는 핸들러가 아닙니다 —
|
||||||
|
메뉴 관리 화면에 URL 로 직접 들어왔을 때 해당 메뉴를 선택 상태로 여는 용도입니다.
|
||||||
|
params 없이 그 화면의 `init_actions` 에서 호출합니다.
|
||||||
|
|
||||||
### 필터 가시성 핸들러 4종
|
### 필터 가시성 핸들러 4종
|
||||||
|
|
||||||
목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다.
|
목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다.
|
||||||
|
|
||||||
|
`storageKey` 는 네 핸들러 모두 **필수**입니다 — 빠지면 경고 한 줄을 남기고 조기 반환하므로
|
||||||
|
필터 상태가 복원도 저장도 되지 않습니다. localStorage 키는 `g7_filter_visibility_{storageKey}`
|
||||||
|
이고, 복원 대상 로컬 상태 경로는 `params.stateKey`(기본 `visibleFilters`)입니다.
|
||||||
|
|
||||||
| 핸들러 | params | 설명 |
|
| 핸들러 | params | 설명 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 (`init_actions`에서 호출) |
|
| `initFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | localStorage → `_local` 복원 (`init_actions`에서 호출) |
|
||||||
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
|
| `saveFilterVisibility` | `{ storageKey, filters }` | `_local` → localStorage 저장 |
|
||||||
| `toggleFilterVisibility` | `{ key }` | 특정 필터 키 가시성 토글 |
|
| `toggleFilterVisibility` | `{ storageKey, filterId, stateKey? }` | 특정 필터 가시성 토글 + 즉시 저장 |
|
||||||
| `resetFilterVisibility` | 없음 | 전체 초기화 |
|
| `resetFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | 기본값으로 초기화 |
|
||||||
|
|
||||||
### 다국어 태그 핸들러 3종
|
### 다국어 태그 핸들러 3종
|
||||||
|
|
||||||
`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다.
|
`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다.
|
||||||
|
|
||||||
|
편집 중인 값은 전역 상태 `_global.multilingualTagEdit` 에 있습니다 — 저장·취소 핸들러는 그
|
||||||
|
상태만 읽으므로 액션 인자를 받지 않습니다.
|
||||||
|
|
||||||
| 핸들러 | params | 설명 |
|
| 핸들러 | params | 설명 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
|
| `saveMultilingualTag` | 없음 | `_global.multilingualTagEdit` 을 부모 태그 배열에 반영 |
|
||||||
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
||||||
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
|
| `updateMultilingualTagValue` | `{ locale }` | 그 로케일 값 갱신 (값은 `context.event` 에서 읽음) |
|
||||||
|
|
||||||
### setDateRange
|
### setDateRange
|
||||||
|
|
||||||
@@ -105,7 +122,7 @@ JSON 이 `sequence` + `setState` 로 반환값(`$prev.startDate` 등)을 원하
|
|||||||
|
|
||||||
| 핸들러 | params | 설명 |
|
| 핸들러 | params | 설명 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (`init_actions`에서 호출) |
|
| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (레이아웃 `init_actions` 가 아니라 `src/index.ts` 부트스트랩이 1회 호출) |
|
||||||
| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 |
|
| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 |
|
||||||
|
|
||||||
### downloadAttachment
|
### downloadAttachment
|
||||||
@@ -168,28 +185,29 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
|||||||
### setLocale
|
### setLocale
|
||||||
|
|
||||||
> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher)
|
> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher)
|
||||||
> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, 동작·파라미터는
|
> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, **소유 주체가
|
||||||
> 같지만 **소유 주체가 템플릿이 아니라 엔진**입니다.
|
> 템플릿이 아니라 엔진**입니다.
|
||||||
|
>
|
||||||
|
> **정정(#640)**: 엔진 빌트인은 로케일을 액션 **top-level `target`** 으로만 읽습니다.
|
||||||
|
> `params.locale` 로 넘기면 무시되어 언어 전환이 조용히 죽습니다.
|
||||||
|
|
||||||
앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
|
앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
|
||||||
|
|
||||||
**소스**: `src/handlers/setLocaleHandler.ts`
|
**소스**: 엔진 빌트인 (`resources/js/core/template-engine/ActionDispatcher.ts`) — 이 템플릿에는 소스 파일이 없습니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setLocale",
|
"handler": "setLocale",
|
||||||
"params": {
|
"target": "en"
|
||||||
"locale": "en"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### params
|
#### 파라미터
|
||||||
|
|
||||||
| 필드 | 타입 | 필수 | 설명 |
|
| 위치 | 타입 | 필수 | 설명 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
|
| `target` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
|
||||||
|
|
||||||
#### 동작
|
#### 동작
|
||||||
|
|
||||||
@@ -213,9 +231,7 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setLocale",
|
"handler": "setLocale",
|
||||||
"params": {
|
"target": "en"
|
||||||
"locale": "en"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -235,17 +251,18 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "dark"
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### setTheme params
|
#### setTheme 파라미터
|
||||||
|
|
||||||
| 필드 | 타입 | 필수 | 설명 |
|
| 위치 | 타입 | 필수 | 설명 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
| `target` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||||
|
|
||||||
|
핸들러는 `action.target` 만 읽습니다. `params.theme` 으로 넘기면 콘솔에 `Invalid theme:
|
||||||
|
undefined` 경고만 남기고 아무 것도 하지 않습니다 (dev-g7#640).
|
||||||
|
|
||||||
#### 동작
|
#### 동작
|
||||||
|
|
||||||
@@ -269,7 +286,16 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
|
`target` 은 선택입니다. 유효한 테마 값(`light`/`dark`/`auto`)이면 그 값을 적용하고, 없거나
|
||||||
|
유효하지 않으면 localStorage 저장값(없으면 `auto`)으로 복원합니다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"init_actions": [
|
||||||
|
{ "handler": "initTheme", "target": "{{query.theme}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### 사용 예시
|
#### 사용 예시
|
||||||
|
|
||||||
@@ -282,9 +308,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
|
||||||
"theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -294,7 +318,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
### scrollToSection
|
### scrollToSection
|
||||||
|
|
||||||
특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
|
특정 섹션으로 스크롤합니다. 고정 헤더 보상 오프셋과 조건부 렌더링 대기에 특화되어 있습니다.
|
||||||
|
|
||||||
**소스**: `src/handlers/scrollToSectionHandler.ts`
|
**소스**: `src/handlers/scrollToSectionHandler.ts`
|
||||||
|
|
||||||
@@ -303,8 +327,8 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "scrollToSection",
|
"handler": "scrollToSection",
|
||||||
"params": {
|
"params": {
|
||||||
"selector": "#features",
|
"targetId": "features",
|
||||||
"offset": -80
|
"offset": 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -313,15 +337,18 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||||
|------|------|------|--------|------|
|
|------|------|------|--------|------|
|
||||||
| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
|
| `targetId` | string | ✅ | - | 대상 엘리먼트의 **ID** (`getElementById` 대상 — `#` 없이, CSS 선택자 아님) |
|
||||||
| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
|
| `offset` | number | ❌ | `120` | 고정 헤더 높이 보상 여백 |
|
||||||
|
| `delay` | number | ❌ | `100` | 요소가 아직 렌더되지 않았을 때의 재시도 간격(ms) |
|
||||||
|
| `scrollContainerId` | string | ❌ | - | 스크롤 컨테이너를 명시할 때 (미지정 시 자동 탐색 → window) |
|
||||||
|
|
||||||
#### 동작
|
#### 동작
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. document.querySelector(selector)로 대상 요소 검색
|
1. document.getElementById(targetId)로 대상 요소 검색 (미발견 시 delay 간격으로 재시도)
|
||||||
2. 요소의 위치 계산 + offset 적용
|
2. 스크롤 컨테이너 결정 (scrollContainerId → 자동 탐색 → window)
|
||||||
3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
|
3. 요소의 위치 계산 + offset 보상
|
||||||
|
4. 부드러운 스크롤 실행
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 사용 예시
|
#### 사용 예시
|
||||||
@@ -340,8 +367,8 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "scrollToSection",
|
"handler": "scrollToSection",
|
||||||
"params": {
|
"params": {
|
||||||
"selector": "#features",
|
"targetId": "features",
|
||||||
"offset": -80
|
"offset": 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -352,7 +379,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
### initMenuFromUrl
|
### initMenuFromUrl
|
||||||
|
|
||||||
현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
|
URL 쿼리스트링(`?menu=<slug>&mode=<모드>`)을 읽어 메뉴 관리 화면의 선택 메뉴와 편집 모드를 초기화합니다. 그 화면의 `init_actions`에서 사용합니다.
|
||||||
|
|
||||||
**소스**: `src/handlers/initMenuFromUrlHandler.ts`
|
**소스**: `src/handlers/initMenuFromUrlHandler.ts`
|
||||||
|
|
||||||
@@ -368,23 +395,23 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
#### params
|
#### params
|
||||||
|
|
||||||
없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
|
없음. 읽는 값은 액션 인자가 아니라 URL 쿼리 파라미터입니다.
|
||||||
|
|
||||||
#### 동작
|
#### 동작
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 현재 URL 경로 (window.location.pathname) 추출
|
1. URLSearchParams 로 ?menu= (메뉴 slug) 와 ?mode= 추출
|
||||||
2. 사이드바 메뉴 데이터에서 URL 매칭
|
2. 메뉴 데이터 소스에서 slug 로 해당 메뉴 검색 (자식 메뉴까지 재귀)
|
||||||
3. 매칭된 메뉴 항목의 is_active 상태 설정
|
3. 찾은 메뉴를 선택 상태로, mode 를 편집 모드로 설정
|
||||||
4. 부모 메뉴도 자동으로 펼침 상태 설정
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 사용 예시 (_admin_base.json)
|
`window.location.pathname` 을 사이드바 메뉴와 매칭하는 핸들러가 아닙니다.
|
||||||
|
|
||||||
|
#### 사용 예시 (메뉴 관리 화면)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"init_actions": [
|
"init_actions": [
|
||||||
{ "handler": "initTheme" },
|
|
||||||
{ "handler": "initMenuFromUrl" }
|
{ "handler": "initMenuFromUrl" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -400,13 +427,18 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
#### initFilterVisibility
|
#### initFilterVisibility
|
||||||
|
|
||||||
저장된 필터 가시성 상태를 `_local`에 복원합니다.
|
저장된 필터 가시성 상태를 `_local`에 복원합니다. `storageKey` 가 없으면 경고 후 조기 반환합니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"init_actions": [
|
"init_actions": [
|
||||||
{
|
{
|
||||||
"handler": "initFilterVisibility"
|
"handler": "initFilterVisibility",
|
||||||
|
"params": {
|
||||||
|
"storageKey": "product_index_filters",
|
||||||
|
"defaultFilters": ["category", "date"],
|
||||||
|
"stateKey": "visibleFilters"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -420,51 +452,65 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
{
|
{
|
||||||
"handler": "saveFilterVisibility",
|
"handler": "saveFilterVisibility",
|
||||||
"params": {
|
"params": {
|
||||||
"filters": "{{_local.filterVisibility}}"
|
"storageKey": "product_index_filters",
|
||||||
|
"filters": "{{_local.visibleFilters}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### toggleFilterVisibility
|
#### toggleFilterVisibility
|
||||||
|
|
||||||
특정 필터 키의 가시성을 토글합니다.
|
특정 필터의 가시성을 토글하고 즉시 localStorage 에 저장합니다. `storageKey` 와 `filterId` 가
|
||||||
|
모두 있어야 하며, 하나라도 없으면 경고 후 조기 반환합니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "toggleFilterVisibility",
|
"handler": "toggleFilterVisibility",
|
||||||
"params": {
|
"params": {
|
||||||
"key": "advancedFilters"
|
"storageKey": "product_index_filters",
|
||||||
|
"filterId": "category"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### resetFilterVisibility
|
#### resetFilterVisibility
|
||||||
|
|
||||||
모든 필터 가시성을 초기 상태로 리셋합니다.
|
모든 필터 가시성을 `defaultFilters` 로 되돌립니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "resetFilterVisibility"
|
"handler": "resetFilterVisibility",
|
||||||
|
"params": {
|
||||||
|
"storageKey": "product_index_filters",
|
||||||
|
"defaultFilters": ["category", "date"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 핸들러 params 요약
|
#### 핸들러 params 요약
|
||||||
|
|
||||||
|
`storageKey` 는 네 핸들러 모두 필수입니다. 실제 localStorage 키는
|
||||||
|
`g7_filter_visibility_{storageKey}` 이고, 복원 대상 로컬 상태 경로는 `stateKey`(기본
|
||||||
|
`visibleFilters`)입니다.
|
||||||
|
|
||||||
| 핸들러 | params | 설명 |
|
| 핸들러 | params | 설명 |
|
||||||
|--------|--------|------|
|
|--------|--------|------|
|
||||||
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
|
| `initFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | localStorage → `_local` 복원 |
|
||||||
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
|
| `saveFilterVisibility` | `{ storageKey, filters }` | `_local` → localStorage 저장 |
|
||||||
| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
|
| `toggleFilterVisibility` | `{ storageKey, filterId, stateKey? }` | 특정 필터 토글 + 즉시 저장 |
|
||||||
| `resetFilterVisibility` | 없음 | 전체 초기화 |
|
| `resetFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | 기본값으로 초기화 |
|
||||||
|
|
||||||
#### 사용 예시 (목록 페이지)
|
#### 사용 예시 (목록 페이지)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"init_actions": [
|
"init_actions": [
|
||||||
{ "handler": "initFilterVisibility" }
|
{
|
||||||
|
"handler": "initFilterVisibility",
|
||||||
|
"params": { "storageKey": "product_index_filters", "defaultFilters": ["advancedFilters"] }
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
@@ -478,7 +524,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "toggleFilterVisibility",
|
"handler": "toggleFilterVisibility",
|
||||||
"params": { "key": "advancedFilters" }
|
"params": { "storageKey": "product_index_filters", "filterId": "advancedFilters" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -486,7 +532,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
"id": "filter_section",
|
"id": "filter_section",
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Div",
|
"name": "Div",
|
||||||
"if": "{{_local.filterVisibility?.advancedFilters}}",
|
"if": "{{_local.visibleFilters?.includes('advancedFilters')}}",
|
||||||
"children": [
|
"children": [
|
||||||
{ "comment": "필터 컴포넌트들" }
|
{ "comment": "필터 컴포넌트들" }
|
||||||
]
|
]
|
||||||
@@ -505,15 +551,13 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
#### saveMultilingualTag
|
#### saveMultilingualTag
|
||||||
|
|
||||||
다국어 태그를 저장합니다.
|
편집 중인 다국어 태그를 부모 태그 배열에 반영하고 모달을 닫습니다. 액션 인자를 받지 않으며,
|
||||||
|
읽는 값은 전역 상태 `_global.multilingualTagEdit`(필드명·편집 인덱스·로케일별 값·상태 경로)
|
||||||
|
뿐입니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"handler": "saveMultilingualTag",
|
"handler": "saveMultilingualTag"
|
||||||
"params": {
|
|
||||||
"field": "tags",
|
|
||||||
"locale": "{{_global.locale}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -529,15 +573,15 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
#### updateMultilingualTagValue
|
#### updateMultilingualTagValue
|
||||||
|
|
||||||
다국어 태그 값을 업데이트합니다.
|
편집 중인 다국어 태그의 특정 로케일 값을 갱신합니다. 값은 액션 인자가 아니라
|
||||||
|
`context.event`(입력 이벤트)에서 읽으므로 `params` 에는 `locale` 만 넘깁니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"type": "change",
|
||||||
"handler": "updateMultilingualTagValue",
|
"handler": "updateMultilingualTagValue",
|
||||||
"params": {
|
"params": {
|
||||||
"field": "tags",
|
"locale": "ko"
|
||||||
"locale": "ko",
|
|
||||||
"value": "{{$event.target.value}}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -546,9 +590,9 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
| 핸들러 | params | 설명 |
|
| 핸들러 | params | 설명 |
|
||||||
|--------|--------|------|
|
|--------|--------|------|
|
||||||
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
|
| `saveMultilingualTag` | 없음 | `_global.multilingualTagEdit` 을 부모 태그 배열에 반영 |
|
||||||
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
||||||
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
|
| `updateMultilingualTagValue` | `{ locale }` | 그 로케일 값 갱신 (값은 `context.event` 에서 읽음) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -556,7 +600,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
|||||||
|
|
||||||
| 핸들러명 | 소스 파일 | 등록 함수 |
|
| 핸들러명 | 소스 파일 | 등록 함수 |
|
||||||
|---------|----------|----------|
|
|---------|----------|----------|
|
||||||
| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
|
| `setLocale` | 엔진 빌트인 (이 템플릿에 소스 없음) | — |
|
||||||
| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
|
| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
|
||||||
| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
|
| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
|
||||||
| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
|
| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setState",
|
"handler": "setState",
|
||||||
"target": "local",
|
|
||||||
"params": {
|
"params": {
|
||||||
|
"target": "local",
|
||||||
"{{key}}": "{{value}}"
|
"{{key}}": "{{value}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,9 +132,7 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setLocale",
|
"handler": "setLocale",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setTheme": {
|
"setTheme": {
|
||||||
@@ -153,24 +151,22 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToSection": {
|
"scrollToSection": {
|
||||||
"label": "$t:editor.action.scroll_to_section.label",
|
"label": "$t:editor.action.scroll_to_section.label",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "sectionId",
|
"key": "targetId",
|
||||||
"label": "$t:editor.action.scroll_to_section.param_section_id",
|
"label": "$t:editor.action.scroll_to_section.param_target_id",
|
||||||
"widget": "text"
|
"widget": "text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "scrollToSection",
|
"handler": "scrollToSection",
|
||||||
"params": {
|
"params": {
|
||||||
"sectionId": "{{sectionId}}"
|
"targetId": "{{targetId}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -185,7 +181,7 @@
|
|||||||
{ "value": "today", "label": "$t:editor.action.set_date_range.preset_today" },
|
{ "value": "today", "label": "$t:editor.action.set_date_range.preset_today" },
|
||||||
{ "value": "week", "label": "$t:editor.action.set_date_range.preset_week" },
|
{ "value": "week", "label": "$t:editor.action.set_date_range.preset_week" },
|
||||||
{ "value": "month", "label": "$t:editor.action.set_date_range.preset_month" },
|
{ "value": "month", "label": "$t:editor.action.set_date_range.preset_month" },
|
||||||
{ "value": "year", "label": "$t:editor.action.set_date_range.preset_year" }
|
{ "value": "1year", "label": "$t:editor.action.set_date_range.preset_1year" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -200,32 +196,29 @@
|
|||||||
"label": "$t:editor.action.toggle_filter_visibility.label",
|
"label": "$t:editor.action.toggle_filter_visibility.label",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "filterKey",
|
"key": "storageKey",
|
||||||
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
|
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
|
||||||
|
"widget": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "filterId",
|
||||||
|
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
|
||||||
"widget": "text"
|
"widget": "text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "toggleFilterVisibility",
|
"handler": "toggleFilterVisibility",
|
||||||
"params": {
|
"params": {
|
||||||
"filterKey": "{{filterKey}}"
|
"storageKey": "{{storageKey}}",
|
||||||
|
"filterId": "{{filterId}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"saveMultilingualTag": {
|
"saveMultilingualTag": {
|
||||||
"label": "$t:editor.action.save_multilingual_tag.label",
|
"label": "$t:editor.action.save_multilingual_tag.label",
|
||||||
"params": [
|
"params": [],
|
||||||
{
|
|
||||||
"key": "tag",
|
|
||||||
"label": "$t:editor.action.save_multilingual_tag.param_tag",
|
|
||||||
"widget": "i18n-text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "saveMultilingualTag",
|
"handler": "saveMultilingualTag"
|
||||||
"params": {
|
|
||||||
"tag": "{{tag}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initTheme": {
|
"initTheme": {
|
||||||
@@ -244,9 +237,7 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initTheme",
|
"handler": "initTheme",
|
||||||
"params": {
|
"target": "{{target}}"
|
||||||
"target": "{{target}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initMenuFromUrl": {
|
"initMenuFromUrl": {
|
||||||
@@ -258,9 +249,18 @@
|
|||||||
},
|
},
|
||||||
"initFilterVisibility": {
|
"initFilterVisibility": {
|
||||||
"label": "$t:editor.action.init_filter_visibility.label",
|
"label": "$t:editor.action.init_filter_visibility.label",
|
||||||
"params": [],
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "storageKey",
|
||||||
|
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
|
||||||
|
"widget": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initFilterVisibility"
|
"handler": "initFilterVisibility",
|
||||||
|
"params": {
|
||||||
|
"storageKey": "{{storageKey}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"expand_sidebar": "Expand sidebar",
|
"expand_sidebar": "Expand sidebar",
|
||||||
"collapse_sidebar": "Collapse sidebar",
|
"collapse_sidebar": "Collapse sidebar",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse",
|
||||||
"module": "Module",
|
"module": "Module",
|
||||||
"plugin": "Plugin",
|
"plugin": "Plugin",
|
||||||
"status_active": "Active",
|
"status_active": "Active",
|
||||||
|
|||||||
@@ -1261,7 +1261,7 @@
|
|||||||
},
|
},
|
||||||
"scroll_to_section": {
|
"scroll_to_section": {
|
||||||
"label": "Scroll to a section",
|
"label": "Scroll to a section",
|
||||||
"param_section_id": "Section ID"
|
"param_target_id": "Section ID"
|
||||||
},
|
},
|
||||||
"set_date_range": {
|
"set_date_range": {
|
||||||
"label": "Quick date range",
|
"label": "Quick date range",
|
||||||
@@ -1269,15 +1269,15 @@
|
|||||||
"preset_today": "Today",
|
"preset_today": "Today",
|
||||||
"preset_week": "This week",
|
"preset_week": "This week",
|
||||||
"preset_month": "This month",
|
"preset_month": "This month",
|
||||||
"preset_year": "This year"
|
"preset_1year": "Last 1 year"
|
||||||
},
|
},
|
||||||
"toggle_filter_visibility": {
|
"toggle_filter_visibility": {
|
||||||
"label": "Show/hide filter",
|
"label": "Show/hide filter",
|
||||||
"param_filter_key": "Filter key"
|
"param_storage_key": "Storage key",
|
||||||
|
"param_filter_id": "Filter ID"
|
||||||
},
|
},
|
||||||
"save_multilingual_tag": {
|
"save_multilingual_tag": {
|
||||||
"label": "Save multilingual tag",
|
"label": "Save multilingual tag"
|
||||||
"param_tag": "Tag"
|
|
||||||
},
|
},
|
||||||
"init_theme": {
|
"init_theme": {
|
||||||
"label": "Initialize screen theme",
|
"label": "Initialize screen theme",
|
||||||
@@ -1290,7 +1290,8 @@
|
|||||||
"label": "Initialize menu from URL"
|
"label": "Initialize menu from URL"
|
||||||
},
|
},
|
||||||
"init_filter_visibility": {
|
"init_filter_visibility": {
|
||||||
"label": "Initialize filter visibility"
|
"label": "Initialize filter visibility",
|
||||||
|
"param_storage_key": "Storage key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"computed": {
|
"computed": {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
"logout": "로그아웃",
|
"logout": "로그아웃",
|
||||||
"expand_sidebar": "사이드바 펼치기",
|
"expand_sidebar": "사이드바 펼치기",
|
||||||
"collapse_sidebar": "사이드바 접기",
|
"collapse_sidebar": "사이드바 접기",
|
||||||
|
"expand": "펼치기",
|
||||||
|
"collapse": "접기",
|
||||||
"module": "모듈",
|
"module": "모듈",
|
||||||
"plugin": "플러그인",
|
"plugin": "플러그인",
|
||||||
"status_active": "활성화",
|
"status_active": "활성화",
|
||||||
|
|||||||
@@ -1261,7 +1261,7 @@
|
|||||||
},
|
},
|
||||||
"scroll_to_section": {
|
"scroll_to_section": {
|
||||||
"label": "특정 영역으로 스크롤",
|
"label": "특정 영역으로 스크롤",
|
||||||
"param_section_id": "영역 ID"
|
"param_target_id": "영역 ID"
|
||||||
},
|
},
|
||||||
"set_date_range": {
|
"set_date_range": {
|
||||||
"label": "기간 빠르게 선택",
|
"label": "기간 빠르게 선택",
|
||||||
@@ -1269,15 +1269,15 @@
|
|||||||
"preset_today": "오늘",
|
"preset_today": "오늘",
|
||||||
"preset_week": "이번 주",
|
"preset_week": "이번 주",
|
||||||
"preset_month": "이번 달",
|
"preset_month": "이번 달",
|
||||||
"preset_year": "올해"
|
"preset_1year": "최근 1년"
|
||||||
},
|
},
|
||||||
"toggle_filter_visibility": {
|
"toggle_filter_visibility": {
|
||||||
"label": "필터 보이기/숨기기",
|
"label": "필터 보이기/숨기기",
|
||||||
"param_filter_key": "필터 키"
|
"param_storage_key": "저장 키",
|
||||||
|
"param_filter_id": "필터 ID"
|
||||||
},
|
},
|
||||||
"save_multilingual_tag": {
|
"save_multilingual_tag": {
|
||||||
"label": "다국어 태그 저장",
|
"label": "다국어 태그 저장"
|
||||||
"param_tag": "태그"
|
|
||||||
},
|
},
|
||||||
"init_theme": {
|
"init_theme": {
|
||||||
"label": "화면 테마 초기화",
|
"label": "화면 테마 초기화",
|
||||||
@@ -1290,7 +1290,8 @@
|
|||||||
"label": "주소로 메뉴 초기화"
|
"label": "주소로 메뉴 초기화"
|
||||||
},
|
},
|
||||||
"init_filter_visibility": {
|
"init_filter_visibility": {
|
||||||
"label": "필터 표시 초기화"
|
"label": "필터 표시 초기화",
|
||||||
|
"param_storage_key": "저장 키"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"computed": {
|
"computed": {
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
"props": {
|
"props": {
|
||||||
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
||||||
"value": "{{$locale}}",
|
"value": "{{$locale}}",
|
||||||
"options": "{{$locales}}"
|
"options": "{{$locales ?? []}}"
|
||||||
},
|
},
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
@@ -372,9 +372,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "light"
|
||||||
"theme": "light"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
@@ -402,9 +400,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "dark"
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
@@ -432,9 +428,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "auto"
|
||||||
"theme": "auto"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
|
|||||||
@@ -374,7 +374,7 @@
|
|||||||
"props": {
|
"props": {
|
||||||
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
||||||
"value": "{{$locale}}",
|
"value": "{{$locale}}",
|
||||||
"options": "{{$locales}}"
|
"options": "{{$locales ?? []}}"
|
||||||
},
|
},
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
@@ -415,9 +415,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "light"
|
||||||
"theme": "light"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
@@ -445,9 +443,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "dark"
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
@@ -475,9 +471,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "auto"
|
||||||
"theme": "auto"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
|
|||||||
@@ -472,7 +472,7 @@
|
|||||||
"props": {
|
"props": {
|
||||||
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
||||||
"value": "{{$locale}}",
|
"value": "{{$locale}}",
|
||||||
"options": "{{$locales}}"
|
"options": "{{$locales ?? []}}"
|
||||||
},
|
},
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
@@ -514,9 +514,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "light"
|
||||||
"theme": "light"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
@@ -544,9 +542,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "dark"
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
@@ -574,9 +570,7 @@
|
|||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "auto"
|
||||||
"theme": "auto"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"children": [
|
"children": [
|
||||||
|
|||||||
@@ -198,6 +198,25 @@ describe('setThemeHandler', () => {
|
|||||||
expect(setAttributeSpy).not.toHaveBeenCalled();
|
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 계약 명문화: 이 핸들러가 읽는 것은 action.target 뿐이다.
|
||||||
|
// 레이아웃이 params.theme 로 넘기면(과거 admin_login/forgot/reset 3화면이 그랬다)
|
||||||
|
// 엔진에 상호 폴백이 없어 조용히 no-op 이 된다. 되살아나면 여기가 red 가 된다.
|
||||||
|
it('params.theme 로만 넘기면 경고 후 아무 것도 하지 않아야 함 (target 단일 계약)', async () => {
|
||||||
|
const action = {
|
||||||
|
type: 'click',
|
||||||
|
handler: 'setTheme',
|
||||||
|
params: { theme: 'dark' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await setThemeHandler(action);
|
||||||
|
|
||||||
|
expect(console.warn).toHaveBeenCalledWith('[Handler:SetTheme]', 'Invalid theme:', undefined);
|
||||||
|
expect(setItemSpy).not.toHaveBeenCalled();
|
||||||
|
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(classListAddSpy).not.toHaveBeenCalled();
|
||||||
|
expect(classListRemoveSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('localStorage 저장 실패 시 오류를 출력하고 테마를 적용하지 않아야 함', async () => {
|
it('localStorage 저장 실패 시 오류를 출력하고 테마를 적용하지 않아야 함', async () => {
|
||||||
// localStorage.setItem이 실패하도록 모의
|
// localStorage.setItem이 실패하도록 모의
|
||||||
const setItemError = new Error('Storage quota exceeded');
|
const setItemError = new Error('Storage quota exceeded');
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* E2E: 미인증 관리자 화면(로그인·비밀번호 찾기·비밀번호 재설정)의 테마 버튼 (dev-g7#640)
|
||||||
|
*
|
||||||
|
* @scenario screen=admin_login|admin_forgot_password|admin_reset_password, theme=light|dark|auto
|
||||||
|
*
|
||||||
|
* 배경: 세 화면의 테마 버튼이 `setTheme` 을 `params.theme` 로 호출했으나 템플릿 핸들러는
|
||||||
|
* `action.target` 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
|
||||||
|
* 아무 것도 하지 않았다 — 예외도 실패한 요청도 없어 화면상 원인이 보이지 않는다.
|
||||||
|
* 로그인 이후 화면은 핸들러를 경유하지 않는 ThemeToggle 컴포지트를 쓰므로 정상이었다.
|
||||||
|
*
|
||||||
|
* 이 spec 은 클릭 한 번으로 `data-theme` 속성 · `dark` 클래스 · `localStorage` 세 값이
|
||||||
|
* 함께 바뀌는지를 세 화면 × 세 버튼(9변종) 전부에 대해 잰다.
|
||||||
|
*
|
||||||
|
* ## 저장 축이 여기 함께 있는 이유 (실측 2026-09-03)
|
||||||
|
*
|
||||||
|
* 이 결함을 고치는 과정에서 **두 번째 독립 결함**이 드러났다. sirsoft-gdpr 플러그인의
|
||||||
|
* 스토리지 인터셉터가 `Storage.prototype.setItem` 을 감싸고 functional 동의 전에는
|
||||||
|
* strictly-necessary 허용목록 밖 키의 쓰기를 조용히 버리는데, `g7_color_scheme` 이 그
|
||||||
|
* 목록에서 빠져 있었다. 그래서 테마를 바꿔도 새로고침하면 되돌아갔고, 증상이 미인증
|
||||||
|
* 화면뿐 아니라 관리자 화면 전체에 나타났다.
|
||||||
|
*
|
||||||
|
* 그 키를 언어 설정(`g7_locale`)과 같은 필수 항목으로 재분류해 함께 고쳤으므로, 저장과
|
||||||
|
* 새로고침 영속까지 이 spec 이 잰다. 두 결함 중 하나만 되돌아가도 여기가 붉어진다.
|
||||||
|
*
|
||||||
|
* 규율: `check()` 를 쓰지 않고 `click()` + 단언을 분리한다. `_global.theme` 은 `page.goto`
|
||||||
|
* 로 초기화되므로 E2E 단언에 넣지 않는다.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** 테마 버튼의 `title` 은 `$t:admin.theme.*` — config 가 로케일을 ko 로 고정한다. */
|
||||||
|
const THEME_BUTTON_TITLE = {
|
||||||
|
light: '라이트 모드',
|
||||||
|
dark: '다크 모드',
|
||||||
|
auto: '시스템 설정',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ThemeMode = keyof typeof THEME_BUTTON_TITLE;
|
||||||
|
|
||||||
|
const SCREENS: Array<[string, string]> = [
|
||||||
|
['로그인', '/admin/login'],
|
||||||
|
['비밀번호 찾기', '/admin/forgot-password'],
|
||||||
|
// 토큰 없이도 레이아웃은 렌더된다 (init_actions 에 토큰 가드 없음).
|
||||||
|
['비밀번호 재설정', '/admin/reset-password'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 화면 진입 — 테마 세그먼트 컨트롤이 그려질 때까지 기다린다. */
|
||||||
|
async function gotoScreen(page: Page, path: string): Promise<void> {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||||
|
await expect(page.locator(`button[title="${THEME_BUTTON_TITLE.dark}"]`)).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DOM 표현 2종 + 저장값을 한 번에 채집한다. */
|
||||||
|
async function readThemeState(page: Page) {
|
||||||
|
return await page.evaluate(() => ({
|
||||||
|
dataTheme: document.documentElement.getAttribute('data-theme'),
|
||||||
|
darkClass: document.documentElement.classList.contains('dark'),
|
||||||
|
stored: window.localStorage.getItem('g7_color_scheme'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickTheme(page: Page, mode: ThemeMode): Promise<void> {
|
||||||
|
await page.locator(`button[title="${THEME_BUTTON_TITLE[mode]}"]`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('미인증 관리자 화면 테마 버튼', () => {
|
||||||
|
for (const [label, path] of SCREENS) {
|
||||||
|
test(`${label} 화면 — 다크/라이트 전환이 화면과 저장값에 즉시 반영된다`, async ({ page }) => {
|
||||||
|
await gotoScreen(page, path);
|
||||||
|
|
||||||
|
await clickTheme(page, 'dark');
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
|
||||||
|
.toBe('dark');
|
||||||
|
const afterDark = await readThemeState(page);
|
||||||
|
expect(afterDark.darkClass).toBe(true);
|
||||||
|
expect(afterDark.stored).toBe('dark');
|
||||||
|
|
||||||
|
await clickTheme(page, 'light');
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
|
||||||
|
.toBe('light');
|
||||||
|
const afterLight = await readThemeState(page);
|
||||||
|
expect(afterLight.darkClass).toBe(false);
|
||||||
|
expect(afterLight.stored).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${label} 화면 — 자동(시스템 설정) 버튼이 시스템 모드로 해석된다`, async ({ page }) => {
|
||||||
|
await gotoScreen(page, path);
|
||||||
|
|
||||||
|
// 먼저 dark 를 걸어 두고 auto 로 되돌아오는지 본다 (초기값과 구분).
|
||||||
|
await clickTheme(page, 'dark');
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
|
||||||
|
.toBe('dark');
|
||||||
|
|
||||||
|
await clickTheme(page, 'auto');
|
||||||
|
// auto 는 prefers-color-scheme 으로 해석된다 — 이 실행 환경은 light 다.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readThemeState(page)).stored, { timeout: 10_000 })
|
||||||
|
.toBe('auto');
|
||||||
|
const state = await readThemeState(page);
|
||||||
|
expect(state.dataTheme).toBe('light');
|
||||||
|
expect(state.darkClass).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두 결함(핸들러 계약 · GDPR 허용목록)이 모두 고쳐져야만 통과한다.
|
||||||
|
// 계약이 되돌아가면 클릭이 no-op 이 되고, 허용목록이 되돌아가면 저장이 버려진다.
|
||||||
|
test('로그인 화면 — 다크 설정이 새로고침 뒤에도 유지된다 (동의 없는 첫 방문)', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await gotoScreen(page, '/admin/login');
|
||||||
|
|
||||||
|
await clickTheme(page, 'dark');
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readThemeState(page)).stored, { timeout: 10_000 })
|
||||||
|
.toBe('dark');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 20_000 })
|
||||||
|
.toBe('dark');
|
||||||
|
const restored = await readThemeState(page);
|
||||||
|
expect(restored.darkClass).toBe(true);
|
||||||
|
expect(restored.stored).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 대조군 — 허용목록이 통째로 열린 것이 아님을 확인한다.
|
||||||
|
// 이 단언이 없으면 "테마가 저장된다" 가 게이트 무력화로도 성립해 버린다.
|
||||||
|
test('로그인 화면 — 허용목록 밖 키는 여전히 동의 전 차단된다', async ({ page }) => {
|
||||||
|
await gotoScreen(page, '/admin/login');
|
||||||
|
|
||||||
|
const accepted = await page.evaluate(() => {
|
||||||
|
window.localStorage.setItem('e2e_non_allowlisted_probe', 'x');
|
||||||
|
return window.localStorage.getItem('e2e_non_allowlisted_probe') !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(accepted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('로그인 화면 — 테마 클릭이 Invalid theme 경고를 남기지 않는다', async ({ page }) => {
|
||||||
|
const noisy: string[] = [];
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'warning' || msg.type() === 'error') noisy.push(msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoScreen(page, '/admin/login');
|
||||||
|
|
||||||
|
// 세 버튼 전부 — 어느 하나라도 계약이 어긋나면 그 클릭에서 경고가 난다.
|
||||||
|
for (const mode of ['dark', 'light', 'auto'] as ThemeMode[]) {
|
||||||
|
await clickTheme(page, mode);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(noisy.filter((w) => /Invalid theme|Unsupported theme/i.test(w))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -158,7 +158,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
|||||||
| 종류 | 개수 | 위치 |
|
| 종류 | 개수 | 위치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PHPUnit | 0개 | — |
|
| PHPUnit | 0개 | — |
|
||||||
| Vitest | 141개 | `vitest.config.ts` |
|
| Vitest | 142개 | `vitest.config.ts` |
|
||||||
| Playwright | 8개 | `tests/Playwright` |
|
| Playwright | 8개 | `tests/Playwright` |
|
||||||
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
|
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
|
||||||
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
|
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
|
||||||
|
- 레이아웃 편집기의 「액션 추가」로 만든 「테마 바꾸기」·「테마 초기화」·「화면 상태 바꾸기」 동작이 만들자마자 아무 일도 하지 않던 문제를 고쳤습니다. 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
|
||||||
|
|
||||||
## [1.1.2] - 2026-08-24
|
## [1.1.2] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @file action-recipes-contract.test.ts
|
||||||
|
* @description 레이아웃 편집기 액션 레시피(actionRecipes.json) ↔ 실제 핸들러 계약 일치 회귀 테스트
|
||||||
|
*
|
||||||
|
* 배경: 편집기의 「액션 추가」 팔레트는 이 레시피의 `build` 를 그대로 레이아웃 JSON 으로 굽는다.
|
||||||
|
* `setTheme` 레시피가 `params.target` 으로 굽고 있었으나 테마 핸들러는 `action.target` 만 읽으므로,
|
||||||
|
* 편집기로 만든 테마 버튼은 생성 즉시 no-op 이었다 (오류·경고 없음).
|
||||||
|
*
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const recipes = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../editor-spec/actionRecipes.json'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('actionRecipes.json — 핸들러 계약 일치', () => {
|
||||||
|
// 두 핸들러 모두 action.target 만 읽는다 (src/handlers/setThemeHandler.ts).
|
||||||
|
it.each(['setTheme', 'initTheme'])('%s 는 top-level target 으로 굽는다', (id) => {
|
||||||
|
const build = recipes[id]?.build;
|
||||||
|
expect(build).toBeDefined();
|
||||||
|
expect(build.target).toBe('{{target}}');
|
||||||
|
expect(build.params).toBeUndefined();
|
||||||
|
expect((recipes[id].params ?? []).map((p: any) => p.key)).toContain('target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changeState 는 setState 의 상태 범위를 params.target 으로 넘긴다', () => {
|
||||||
|
// handleSetState 는 resolvedParams 에서 target 을 읽는다 (루트 action.target 은 무시).
|
||||||
|
// 루트에 두면 기본값 'component' 로 떨어져, 나중에 global 을 고를 수 있게 되는 순간
|
||||||
|
// 전역 대신 _local 에 조용히 기록된다.
|
||||||
|
const build = recipes.changeState?.build;
|
||||||
|
expect(build.handler).toBe('setState');
|
||||||
|
expect(build.target).toBeUndefined();
|
||||||
|
expect(build.params?.target).toBe('local');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,23 +169,25 @@ sirsoft-admin_basic과 동일한 localStorage 키(`g7_color_scheme`)를 사용
|
|||||||
|
|
||||||
#### setTheme
|
#### setTheme
|
||||||
|
|
||||||
|
테마 값은 액션 **top-level `target`** 으로 넘깁니다. 핸들러는 `params` 를 읽지 않으므로
|
||||||
|
`params.theme` 으로 넘기면 콘솔 경고 한 줄만 남기고 아무 것도 하지 않습니다 (dev-g7#640).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": {
|
"target": "dark"
|
||||||
"theme": "dark"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 필드 | 타입 | 필수 | 설명 |
|
| 위치 | 타입 | 필수 | 설명 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
| `target` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||||
|
|
||||||
#### initTheme
|
#### initTheme
|
||||||
|
|
||||||
앱 시작 시 `init_actions`에서 호출. params 없음.
|
앱 시작 시 `init_actions`에서 호출합니다. `target` 은 선택이며, 유효한 테마 값이면 그 값을,
|
||||||
|
없거나 유효하지 않으면 localStorage 저장값(없으면 `auto`)을 적용합니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setState",
|
"handler": "setState",
|
||||||
"target": "local",
|
|
||||||
"params": {
|
"params": {
|
||||||
|
"target": "local",
|
||||||
"{{key}}": "{{value}}"
|
"{{key}}": "{{value}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "setTheme",
|
"handler": "setTheme",
|
||||||
"params": { "target": "{{target}}" }
|
"target": "{{target}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"savePreferredCurrency": {
|
"savePreferredCurrency": {
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"handler": "initTheme",
|
"handler": "initTheme",
|
||||||
"params": { "target": "{{target}}" }
|
"target": "{{target}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"initCartKey": {
|
"initCartKey": {
|
||||||
|
|||||||
Reference in New Issue
Block a user