feat(gdpr): 필수 저장 항목 허용목록을 운영자 설정으로 이관
허용목록이 플러그인 코드 상수라, 새 확장이 저장 키를 도입할 때마다 이 플러그인을 고쳐야 했다. 고치지 않으면 그 키는 방문마다 파기되는데 예외도 로그도 남지 않아 운영자에게는 "설정이 저장되지 않는다" 는 증상만 보인다. 허용목록을 운영자 설정(necessary_storage_allowlist)으로 옮기고, 관리자 환경설정에 브라우저 저장소·세션 저장소·쿠키 세 카드를 두어 직접 편집하게 했다. 확장은 GDPR 을 알 필요가 없고 GDPR 도 확장을 알 필요가 없다 — 연결은 운영자가 화면에서 한다. 저장 키를 플러그인이 관측·수집하지 않는 것도 같은 이유다: 관측 자체가 추적이다. 판정은 서버(미들웨어)와 클라이언트 셋(인터셉터 둘 + 정리기)이 같은 설정을 같은 매칭 규칙으로 읽는다. 잠금 항목(로그인 토큰·CSRF·세션 쿠키·동의 기록)만 설정 밖에 두고 판정 시점에 합집합으로 얹는다 — 설정에 담기면 저장 요청 한 번으로 지워져 잠금이 아니게 된다. 기설치본은 업그레이드 스텝이 현재 카탈로그를 설정 파일에 못박되, 운영자가 이미 편집했으면 그대로 둔다. 함께 고친 선재 결함 셋: - 차단 도메인 추천 목록이 응답에 실리지 않아 자동완성이 동작하지 않던 문제 - 세션 쿠키 이름을 기본값에서 바꾼 사이트에서 브라우저 측 목록의 그 항목이 죽던 문제 - 쿠키 목록에만 앞부분 매칭(`이름_*`)이 적용되지 않던 문제
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
|
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
|
||||||
|
- 「必須の保存項目」(필수 저장 항목) 설정 화면의 일본어 문구 추가 — 카드 제목·설명, 저장소 구분 3종(ブラウザストレージ / セッションストレージ / クッキー) 라벨과 설명, 안내 4항목(목적·와일드카드 표기·항목 이름 찾는 법·필수 항목만 등록), 잠금 항목 라벨·안내, 입력 placeholder
|
||||||
|
- 필수 저장 항목 허용목록의 검증 메시지 일본어 추가 — 저장소 구분별 형식 위반·길이 초과 메시지, 배열 형식 오류, 알 수 없는 저장소 구분
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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": "有効化",
|
||||||
@@ -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 (ブラウザストレージ·セッションストレージ·クッキーのみ使用できます)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +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 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만
|
차단"이지 "등록된 것만 차단"이 아니기 때문입니다.
|
||||||
차단"이 아니기 때문입니다.
|
|
||||||
|
|
||||||
**허용목록은 둘입니다** — 쿠키(`cookieInterceptor.ts` · `CookieConsentMiddleware`)와 저장소
|
**필수 허용목록은 코드 상수가 아니라 운영자 설정입니다**(`necessary_storage_allowlist`).
|
||||||
(`storageInterceptor.ts` 의 `DEFAULT_NECESSARY_ALLOWLIST`)는 서로 다른 목록이고 항목 수도
|
관리자 환경설정의 「필수 저장 항목」 카드에서 저장소 구분(브라우저 저장소 / 세션 저장소 / 쿠키)
|
||||||
다릅니다. 위 "4종" 은 쿠키 쪽 이야기이며, 저장소 쪽은 코어가 동작에 필요로 하는 키와 WP29
|
셋을 각각 편집하며, 끝의 `*` 는 앞부분 매칭입니다. `plugin.php` 의
|
||||||
Opinion 04/2012 §3.6 의 user-initiated preference 예외 항목(`g7_locale` 언어 설정,
|
`DEFAULT_NECESSARY_ALLOWLIST_CATALOG` 는 **신규 설치 시드용 출하 기본값이자 화면 추천 목록**일
|
||||||
`g7_color_scheme` 화면 테마)을 담습니다. 둘을 같은 목록으로 착각하면 한쪽만 고치게 됩니다.
|
뿐 판정 목록이 아닙니다 — 새 확장이 저장 키를 도입해도 이 플러그인을 고칠 필요가 없습니다.
|
||||||
|
|
||||||
|
그래서 **판정 목록은 하나**입니다. 서버(`CookieConsentMiddleware`)와 클라이언트 셋
|
||||||
|
(`storageInterceptor` · `cookieInterceptor` · `functionalCleaner`)이 같은 설정을 같은 매칭 규칙
|
||||||
|
(`Support\NecessaryAllowlist` ↔ `resources/js/necessaryAllowlist.ts`)으로 읽습니다. 예전에는
|
||||||
|
쿠키 목록과 저장소 목록이 따로 있었고, 그 둘이 어긋나도 아무 신호가 없었습니다.
|
||||||
|
|
||||||
|
**잠금 항목만 설정 밖입니다** — `auth_token` · `XSRF-TOKEN` · 세션 쿠키(런타임 해석) ·
|
||||||
|
`gdpr_session` 넷은 없으면 사이트가 서지 못하므로 코드가 정하고, 판정에는 언제나 운영자 목록과
|
||||||
|
합집합으로 얹힙니다. 설정에 담으면 저장 요청 한 번으로 지워져 잠금이 아니게 됩니다.
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 2. 디렉토리 지도
|
## 2. 디렉토리 지도
|
||||||
@@ -134,8 +142,11 @@ Opinion 04/2012 §3.6 의 user-initiated preference 예외 항목(`g7_locale`
|
|||||||
- [ ] 다국어 키 추가 시 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) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
|
||||||
- [ ] `storageInterceptor.ts` 의 `DEFAULT_NECESSARY_ALLOWLIST`(저장소 쪽, 쿠키 목록과 별개)를 고치면 **동의 안내 문구도 함께** 고친다 — 항목이 어느 카테고리에 속하는지 사용자에게 말하는 자리가 `plugin.php`(설치 시드) · `src/Services/CookieCategoryService.php`(런타임 폴백) · `resources/lang/{ko,en}.json`(관리자 안내) · `editor-spec.json`(편집기 샘플) 넷이다. 한 곳만 고치면 동의 고지가 실제 동작과 어긋난 채 남는다
|
- [ ] **필수 허용목록에 항목이 필요하면 코드가 아니라 관리자 화면에서 추가한다** — 운영자 설정(`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`)
|
- [ ] 그 문구를 고쳤으면 기설치본의 **저장된** 안내도 정정하는 업그레이드 스텝을 동반한다 — 카테고리 정의는 설치 시점에 시드되고 이후 갱신되지 않는다 (선례: `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`
|
||||||
|
|
||||||
@@ -146,7 +157,11 @@ Opinion 04/2012 §3.6 의 user-initiated preference 예외 항목(`g7_locale`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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곳·업그레이드 스텝을 한 작업 단위로 | 안내가 "이 항목은 기능 쿠키이고 거부하면 저장되지 않는다" 라고 말하는데 실제로는 항상 저장되면, 고지 자체가 사실과 달라진다 |
|
| 저장소 허용목록만 고치고 동의 안내 문구는 그대로 두기 | 목록·문구 4곳·업그레이드 스텝을 한 작업 단위로 | 안내가 "이 항목은 기능 쿠키이고 거부하면 저장되지 않는다" 라고 말하는데 실제로는 항상 저장되면, 고지 자체가 사실과 달라진다 |
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
@@ -156,10 +171,10 @@ Opinion 04/2012 §3.6 의 user-initiated preference 예외 항목(`g7_locale`
|
|||||||
<!-- @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 | 13개 | `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,6 +11,10 @@
|
|||||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||||
|
- GDPR 설정 화면에 「필수 저장 항목」 카드를 추가했습니다. 기능 쿠키에 동의하지 않은 방문자에게도 저장이 허용되는 항목을 브라우저 저장소·세션 저장소·쿠키로 나누어 직접 편집할 수 있습니다. 새로 설치한 확장이 저장하는 항목이 동의 전에 지워진다면, 이제 이 화면에서 추가하면 됩니다.
|
||||||
|
- 항목 이름 끝에 `*` 를 붙이면 앞부분이 같은 항목을 모두 포함합니다. 예를 들어 `g7_filters_*` 는 `g7_filters_orders_1` 을 함께 포함합니다.
|
||||||
|
- 사이트 동작에 반드시 필요한 항목(로그인 토큰, CSRF 토큰, 세션 쿠키, 쿠키 동의 기록)은 「잠금 항목」으로 카드 위쪽에 표시되며 삭제할 수 없습니다.
|
||||||
|
- 이미 설치된 사이트는 업데이트 시 현재 기본 목록이 설정으로 옮겨집니다. 이미 편집한 값이 있으면 그대로 둡니다.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -19,6 +23,9 @@
|
|||||||
- 화면 테마(밝게/어둡게/시스템 설정) 선택이 저장되지 않던 문제를 수정했습니다. 기능 쿠키에 동의하기 전에는 테마를 바꿔도 새로고침하면 원래대로 돌아갔고, 관리자·사용자 화면 모두 같았습니다. 테마는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과 같은 필수 항목으로 분류해 동의 여부와 무관하게 저장합니다.
|
- 화면 테마(밝게/어둡게/시스템 설정) 선택이 저장되지 않던 문제를 수정했습니다. 기능 쿠키에 동의하기 전에는 테마를 바꿔도 새로고침하면 원래대로 돌아갔고, 관리자·사용자 화면 모두 같았습니다. 테마는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과 같은 필수 항목으로 분류해 동의 여부와 무관하게 저장합니다.
|
||||||
- 이에 맞춰 쿠키 동의 안내 문구를 정정했습니다 — 필수 항목 설명에 화면 테마가 포함되고, 기능 쿠키 설명에서는 빠집니다. 이미 설치된 사이트의 저장된 안내 문구도 업데이트 시 함께 정정됩니다(운영자가 직접 고친 문구는 그대로 둡니다).
|
- 이에 맞춰 쿠키 동의 안내 문구를 정정했습니다 — 필수 항목 설명에 화면 테마가 포함되고, 기능 쿠키 설명에서는 빠집니다. 이미 설치된 사이트의 저장된 안내 문구도 업데이트 시 함께 정정됩니다(운영자가 직접 고친 문구는 그대로 둡니다).
|
||||||
- 화면 테마와 같은 이유로 사라지던 나머지 항목도 함께 필수로 분류했습니다. 비회원이 주문 후 주문내역을 조회할 때 쓰는 정보, 결제창에서 돌아왔을 때 결제 중단 사유를 서버에 알리는 기록, 본인인증을 마치고 원래 화면·입력 내용으로 복귀하는 데 쓰는 기록, 관리자 화면의 메뉴 접기·목록 필터 표시·닫은 경고·레이아웃 편집기 작업 상태, 그리고 사이트가 자산 주소 형식을 기억해 두는 내부 캐시가 해당합니다. 기능 쿠키 동의 여부와 무관하게 유지됩니다.
|
- 화면 테마와 같은 이유로 사라지던 나머지 항목도 함께 필수로 분류했습니다. 비회원이 주문 후 주문내역을 조회할 때 쓰는 정보, 결제창에서 돌아왔을 때 결제 중단 사유를 서버에 알리는 기록, 본인인증을 마치고 원래 화면·입력 내용으로 복귀하는 데 쓰는 기록, 관리자 화면의 메뉴 접기·목록 필터 표시·닫은 경고·레이아웃 편집기 작업 상태, 그리고 사이트가 자산 주소 형식을 기억해 두는 내부 캐시가 해당합니다. 기능 쿠키 동의 여부와 무관하게 유지됩니다.
|
||||||
|
- 자동 차단 정책 화면의 차단 도메인 입력칸에 추천 도메인이 나타나지 않던 문제를 수정했습니다. 이미 선택한 도메인만 목록에 다시 보여 추천이 동작하는 것처럼 보였습니다.
|
||||||
|
- 세션 쿠키 이름을 기본값(`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 -->
|
||||||
|
|
||||||
## 사용 방법
|
## 사용 방법
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,6 +489,17 @@ 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',
|
||||||
@@ -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,79 +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();
|
||||||
});
|
});
|
||||||
|
|
||||||
// dev-g7#640: 동의 철회 정리에서도 테마는 언어 설정과 같이 남아야 한다.
|
// dev-g7#640: 동의 철회 정리에서도 테마는 언어 설정과 같이 남아야 한다.
|
||||||
it('strictly necessary 키 (g7_color_scheme) 는 보존', () => {
|
it('설정에 등재된 키 (g7_color_scheme) 는 보존', () => {
|
||||||
window.localStorage.setItem('g7_color_scheme', 'dark');
|
window.localStorage.setItem('g7_color_scheme', 'dark');
|
||||||
window.localStorage.setItem('app_pref', 'value');
|
window.localStorage.setItem('app_pref', 'value');
|
||||||
|
|
||||||
cleanupFunctionalArtifacts();
|
cleanupFunctionalArtifacts({
|
||||||
|
allowlist: allowlist({ localStorage: ['g7_color_scheme'] }),
|
||||||
|
});
|
||||||
|
|
||||||
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
|
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
|
||||||
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
expect(window.localStorage.getItem('app_pref')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefix 매칭 키 (g7_devtools_*) 는 보존', () => {
|
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');
|
||||||
@@ -123,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}` 와
|
||||||
|
|||||||
+247
-28
@@ -1,24 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* strictly necessary allowlist 모집단 커버리지 테스트
|
* strictly necessary 허용목록 커버리지·정합성 테스트
|
||||||
*
|
*
|
||||||
* functionalCleaner 는 functional 미동의(기본 상태)에서 **부팅마다** allowlist 밖의
|
* functionalCleaner 는 functional 미동의(기본 상태)에서 **부팅마다** 허용목록 밖의
|
||||||
* localStorage / sessionStorage 를 전량 파기한다. 그래서 코어·확장이 새로 저장 키를
|
* localStorage / sessionStorage 를 전량 파기한다. 그래서 코어·확장이 새로 저장 키를
|
||||||
* 도입하면서 allowlist 등재를 잊으면, 그 설정은 "저장은 되는데 새로고침하면 사라지는"
|
* 도입했는데 출하 카탈로그에도 없고 운영자도 등재하지 않으면, 그 설정은 "저장은 되는데
|
||||||
* 상태가 된다 — 예외도 콘솔 오류도 남지 않아 증상만으로는 원인을 특정할 수 없다.
|
* 새로고침하면 사라지는" 상태가 된다 — 예외도 콘솔 오류도 남지 않아 증상만으로는 원인을
|
||||||
|
* 특정할 수 없다.
|
||||||
*
|
*
|
||||||
* 이 테스트는 allowlist 를 **손으로 열거하지 않는다.** 저장소의 소스를 훑어
|
* 이 테스트는 목록을 **손으로 열거하지 않는다.** 저장소의 소스를 훑어 `.setItem()` 이 쓰는
|
||||||
* `.setItem()` 이 쓰는 키를 기계 도출하고, 그 전량이 allowlist 에 등재되었거나
|
* 키를 기계 도출하고, 그 전량이 출하 카탈로그(`plugin.php`)에 등재되었거나 의도적 비필수
|
||||||
* 의도적 비필수(INTENTIONALLY_NON_NECESSARY)로 선언되었는지 검사한다.
|
* (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
|
* @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 { describe, it, expect } from 'vitest';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { DEFAULT_NECESSARY_ALLOWLIST } from '../storageInterceptor';
|
import {
|
||||||
|
ALLOWLIST_SCOPES,
|
||||||
|
LOCKED_FALLBACK,
|
||||||
|
matchesAllowlistPattern,
|
||||||
|
type AllowlistScope,
|
||||||
|
} from '../necessaryAllowlist';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장소 루트를 탐색합니다. (artisan + composer.json 동시 보유 디렉토리)
|
* 저장소 루트를 탐색합니다. (artisan + composer.json 동시 보유 디렉토리)
|
||||||
@@ -40,6 +64,7 @@ function findRepoRoot(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REPO_ROOT = findRepoRoot();
|
const REPO_ROOT = findRepoRoot();
|
||||||
|
const PLUGIN_ROOT = path.join(REPO_ROOT, 'plugins', '_bundled', 'sirsoft-gdpr');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스캔 대상 디렉토리 (glob 없이 실제 디렉토리 열거로 확장 전량 포함).
|
* 스캔 대상 디렉토리 (glob 없이 실제 디렉토리 열거로 확장 전량 포함).
|
||||||
@@ -201,6 +226,7 @@ interface DiscoveredKey {
|
|||||||
*
|
*
|
||||||
* @param keyExpr 키 표현식 원문
|
* @param keyExpr 키 표현식 원문
|
||||||
* @param source 파일 원문 (식별자 해석용)
|
* @param source 파일 원문 (식별자 해석용)
|
||||||
|
* @param file 호출이 있는 파일 절대경로
|
||||||
* @return 해석 결과 또는 null (해석 불가)
|
* @return 해석 결과 또는 null (해석 불가)
|
||||||
*/
|
*/
|
||||||
function resolveKeyExpression(
|
function resolveKeyExpression(
|
||||||
@@ -277,7 +303,7 @@ function resolveKeyExpression(
|
|||||||
/**
|
/**
|
||||||
* 저장소 전체에서 storage 저장 키를 도출합니다.
|
* 저장소 전체에서 storage 저장 키를 도출합니다.
|
||||||
*
|
*
|
||||||
* @return 도출된 키 목록
|
* @return 도출된 키 목록과 해석 실패 지점
|
||||||
*/
|
*/
|
||||||
function discoverStorageKeys(): { keys: DiscoveredKey[]; unresolved: string[] } {
|
function discoverStorageKeys(): { keys: DiscoveredKey[]; unresolved: string[] } {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
@@ -318,11 +344,86 @@ function discoverStorageKeys(): { keys: DiscoveredKey[]; unresolved: string[] }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 필수(strictly necessary) 가 **아니라고 의도적으로 판단한** 키.
|
* PHP 원문에서 `이름 => [ ... ]` 형태의 스코프 그룹을 문자열 배열로 파싱합니다.
|
||||||
*
|
*
|
||||||
* 여기 적힌 키는 functional 동의가 없으면 파기되는 것이 설계 의도다.
|
* 대상은 `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'),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 키가 실행 시점에 정해져 **정적으로 해석할 수 없는** 쓰기 지점.
|
* 키가 실행 시점에 정해져 **정적으로 해석할 수 없는** 쓰기 지점.
|
||||||
*
|
*
|
||||||
@@ -340,6 +441,12 @@ const DECLARED_DYNAMIC_CALL_SITES: ReadonlyArray<{ site: string; reason: string
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수(strictly necessary) 가 **아니라고 의도적으로 판단한** 키.
|
||||||
|
*
|
||||||
|
* 여기 적힌 키는 functional 동의가 없으면 파기되는 것이 설계 의도다.
|
||||||
|
* 새 키를 여기 넣을 때는 사용자에게 그 상실이 수용 가능한지 근거를 함께 남긴다.
|
||||||
|
*/
|
||||||
const INTENTIONALLY_NON_NECESSARY: ReadonlyArray<{ key: string; reason: string }> = [
|
const INTENTIONALLY_NON_NECESSARY: ReadonlyArray<{ key: string; reason: string }> = [
|
||||||
{
|
{
|
||||||
key: 'g7_preferred_currency',
|
key: 'g7_preferred_currency',
|
||||||
@@ -348,26 +455,24 @@ const INTENTIONALLY_NON_NECESSARY: ReadonlyArray<{ key: string; reason: string }
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 도출된 키가 allowlist 에 등재되어 있는지 검사합니다.
|
* 출하 카탈로그(전 스코프 합집합)가 도출된 키를 덮는지 검사합니다.
|
||||||
*
|
*
|
||||||
* 저장소 종류(localStorage/sessionStorage)는 개별 테스트가 잠그며, 여기서는
|
* 저장소 종류는 개별 테스트가 잠그며, 여기서는 "출하 목록이 이 키를 알고 있는가" 만 본다.
|
||||||
* "필수 목록이 이 키를 알고 있는가" 만 본다.
|
|
||||||
*
|
*
|
||||||
* @param discovered 도출된 키
|
* @param discovered 도출된 키
|
||||||
* @return 등재 여부
|
* @return 등재 여부
|
||||||
*/
|
*/
|
||||||
function isDeclared(discovered: DiscoveredKey): boolean {
|
function isInShippedCatalog(discovered: DiscoveredKey): boolean {
|
||||||
return DEFAULT_NECESSARY_ALLOWLIST.some((entry) => {
|
return Object.values(SHIPPED_CATALOG).some((patterns) =>
|
||||||
const entryMatch = entry.matchType ?? 'exact';
|
patterns.some((pattern) => (
|
||||||
if (entryMatch === 'exact') {
|
// 도출된 키가 접두형이면 카탈로그의 접두 패턴과 같은 계열인지도 본다.
|
||||||
return entry.key === discovered.key;
|
matchesAllowlistPattern(discovered.key, pattern)
|
||||||
}
|
|| (pattern.endsWith('*') && pattern.slice(0, -1) === discovered.key)
|
||||||
// prefix 항목은 그 접두사로 시작하는 키(정확형·접두형 모두)를 덮는다.
|
)),
|
||||||
return discovered.key.startsWith(entry.key);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('strictly necessary allowlist 모집단 커버리지', () => {
|
describe('strictly necessary 허용목록 모집단 커버리지', () => {
|
||||||
const { keys: discovered, unresolved } = discoverStorageKeys();
|
const { keys: discovered, unresolved } = discoverStorageKeys();
|
||||||
|
|
||||||
it('저장 키 도출이 비어 있지 않다 (모집단 하한 — 스캐너가 죽으면 붉어진다)', () => {
|
it('저장 키 도출이 비어 있지 않다 (모집단 하한 — 스캐너가 죽으면 붉어진다)', () => {
|
||||||
@@ -382,11 +487,19 @@ describe('strictly necessary allowlist 모집단 커버리지', () => {
|
|||||||
expect(unresolved).toEqual(DECLARED_DYNAMIC_CALL_SITES.map((s) => s.site));
|
expect(unresolved).toEqual(DECLARED_DYNAMIC_CALL_SITES.map((s) => s.site));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('도출된 모든 저장 키가 allowlist 등재 또는 의도적 비필수 선언 중 하나에 해당한다', () => {
|
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 exempt = new Set(INTENTIONALLY_NON_NECESSARY.map((e) => e.key));
|
||||||
|
const lockedKeys = new Set([...PHP_LOCKED.localStorage, ...PHP_LOCKED.sessionStorage]);
|
||||||
|
|
||||||
const uncovered = discovered
|
const uncovered = discovered
|
||||||
.filter((d) => !exempt.has(d.key))
|
.filter((d) => !exempt.has(d.key))
|
||||||
.filter((d) => !isDeclared(d))
|
.filter((d) => !lockedKeys.has(d.key))
|
||||||
|
.filter((d) => !isInShippedCatalog(d))
|
||||||
.map((d) => `${d.key} (${d.matchType}) ← ${d.origin}`);
|
.map((d) => `${d.key} (${d.matchType}) ← ${d.origin}`);
|
||||||
|
|
||||||
expect(uncovered).toEqual([]);
|
expect(uncovered).toEqual([]);
|
||||||
@@ -401,3 +514,109 @@ describe('strictly necessary allowlist 모집단 커버리지', () => {
|
|||||||
expect(stale).toEqual([]);
|
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,10 +53,10 @@ 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');
|
||||||
@@ -45,30 +65,77 @@ describe('storageInterceptor', () => {
|
|||||||
|
|
||||||
// dev-g7#640: 화면 테마가 목록에서 빠져 있어, 동의 전에는 테마를 바꿔도 저장이
|
// dev-g7#640: 화면 테마가 목록에서 빠져 있어, 동의 전에는 테마를 바꿔도 저장이
|
||||||
// 조용히 버려졌다 (새로고침하면 원래대로). 미인증 화면뿐 아니라 관리자 화면 전체가 같았다.
|
// 조용히 버려졌다 (새로고침하면 원래대로). 미인증 화면뿐 아니라 관리자 화면 전체가 같았다.
|
||||||
it('strictly necessary 키 (g7_color_scheme) → 항상 통과 (미동의여도)', () => {
|
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');
|
window.localStorage.setItem('g7_color_scheme', 'dark');
|
||||||
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
|
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefix 매칭 (g7_devtools_*) → 통과', () => {
|
// 설정이 실제로 판정에 쓰이는지의 대조군 — 목록에서 빼면 같은 키가 차단되어야 한다.
|
||||||
|
it('설정에서 뺀 키는 차단된다 (목록이 판정에 쓰인다는 증거)', () => {
|
||||||
installStorageInterceptor({
|
installStorageInterceptor({
|
||||||
functionalConsented: false,
|
functionalConsented: false,
|
||||||
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
|
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');
|
||||||
@@ -78,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());
|
||||||
@@ -89,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)
|
||||||
@@ -100,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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 미동의 + 비-사용자 → 차단
|
// 미동의 + 비-사용자 → 차단
|
||||||
@@ -120,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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 인터셉터 활성 시 차단
|
// 인터셉터 활성 시 차단
|
||||||
@@ -145,7 +241,7 @@ 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());
|
||||||
@@ -162,4 +258,4 @@ describe('storageInterceptor', () => {
|
|||||||
// 함수 호출만으로 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 매칭 → 항상 허용
|
||||||
* (등재 항목은 `DEFAULT_NECESSARY_ALLOWLIST` 가 SSoT — 여기에 목록을 복제하지
|
|
||||||
* 않는다. 사본을 두면 항목이 늘 때 한쪽만 고쳐져 조용히 어긋난다.)
|
|
||||||
* 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,127 +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 필요.
|
|
||||||
*
|
|
||||||
* 계열: 인증·세션 / 사용자 명시 선택(언어·테마) / 구매 동선(장바구니·비회원 주문) /
|
|
||||||
* 관리자 화면 상태 / 레이아웃 편집기 작업 상태 / 결제 복귀 기록 / 본인인증 복귀.
|
|
||||||
*
|
|
||||||
* 이 배열이 유일한 정본이다. 코어·확장이 새 저장 키를 도입하면서 여기 등재를 잊으면
|
|
||||||
* functionalCleaner 가 부팅마다 그 키를 파기해 "저장은 되는데 새로고침하면 사라지는"
|
|
||||||
* 상태가 되고, 예외도 로그도 남지 않는다. 그 누락은 저장소의 `.setItem()` 을 기계
|
|
||||||
* 도출해 대조하는 `__tests__/necessaryAllowlistCoverage.test.ts` 가 잡는다.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_NECESSARY_ALLOWLIST: readonly NecessaryAllowlistEntry[] = [
|
|
||||||
// 사용자 명시 선택 (WP29 §3.6) — 다국어 설정
|
|
||||||
{ key: 'g7_locale', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 사용자 명시 선택 (WP29 §3.6) — 화면 테마(밝게/어둡게/시스템 설정).
|
|
||||||
// 언어 설정과 같은 범주다: 사용자가 화면에서 직접 고른 표시 환경이며 추적에 쓰이지
|
|
||||||
// 않는다. 목록에서 빠져 있는 동안에는 테마를 바꿔도 새로고침하면 되돌아갔고,
|
|
||||||
// 그 증상이 미인증 화면뿐 아니라 관리자 화면 전체에 나타났다 (dev-g7#640).
|
|
||||||
{ key: 'g7_color_scheme', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 인증 토큰 — 로그인 유지 필수 (strictly necessary, Art.6(1)(b))
|
|
||||||
{ key: 'auth_token', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 코어 캐시 버전 — 운영자가 의도적 갱신 시 사용
|
|
||||||
{ key: 'g7_cache_version', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 자산 URL 형식 판정 캐시 — 사용자 정보가 아니라 서버 능력 판정 결과다.
|
|
||||||
// 파기되면 재방문마다 기본 형식으로 첫 자산 요청을 보내 404 를 겪은 뒤에야
|
|
||||||
// 대체 형식으로 넘어간다. 키에 캐시 버전이 붙으므로 접두사로 등재한다.
|
|
||||||
{ key: 'g7_asset_url_mode', storage: 'localStorage', matchType: 'prefix' },
|
|
||||||
// 장바구니 게스트 키 — 익명 카트 식별 (구매 동선 필수)
|
|
||||||
{ key: 'g7_cart_key', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// devtools UI 상태 — 개발자 환경 (strictly necessary 개발자 도구)
|
|
||||||
{ key: 'g7-devtools-panel', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 비회원 주문 조회 — 주문 이행에 필요 (Art.6(1)(b)). 게스트 장바구니 키와 같은 범주로,
|
|
||||||
// 파기되면 방금 주문한 비회원이 자기 주문내역에 도달할 수 없다.
|
|
||||||
{ key: 'g7_guest_order_token', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
{ key: 'g7_guest_order_number', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
{ key: 'g7_guest_order_expires_at', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 관리자 페이지 상태 (필터/정렬/컬럼/devtools) — 사용자 의사로 조작
|
|
||||||
{ key: 'g7_devtools_', matchType: 'prefix' },
|
|
||||||
{ key: 'g7_filters_', matchType: 'prefix' },
|
|
||||||
{ key: 'g7_columns_', matchType: 'prefix' },
|
|
||||||
{ key: 'g7_order_', matchType: 'prefix' },
|
|
||||||
// 관리자 화면 표시 설정 — 테마와 같은 범주 (사용자가 화면에서 직접 고른 표시 환경).
|
|
||||||
// `g7_filters_` 는 목록 필터 '값' 이고 `g7_filter_visibility_` 는 필터 '표시 여부' 라
|
|
||||||
// 접두사가 서로 덮지 않는다 — 목록에 따로 서야 한다 (dev-g7#640).
|
|
||||||
{ key: 'g7_admin_sidebar_collapsed', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
{ key: 'g7_filter_visibility_', matchType: 'prefix' },
|
|
||||||
{ key: 'g7_dismissed_warnings', storage: 'localStorage', matchType: 'exact' },
|
|
||||||
// 레이아웃 편집기 작업 상태 (라우트 트리 접힘·클립보드) — 운영자 편집 동선 유지
|
|
||||||
{ key: 'g7le.', matchType: 'prefix' },
|
|
||||||
// 결제 진행 기록 — 결제창 복귀 시 종료·실패 사유 보고에 필요 (Art.6(1)(b)).
|
|
||||||
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로 sessionStorage 에 맡겨 두는데,
|
|
||||||
// 부팅 시 파기되면 그 보고가 통째로 누락된다.
|
|
||||||
{ key: 'g7:sirsoft-pay_kginicis:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
|
|
||||||
{ key: 'g7:sirsoft-pay_nhnkcp:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
|
|
||||||
{ key: 'g7:sirsoft-tosspayments:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
|
|
||||||
{ key: '__sirsoftKginicisMobilePaymentReturnPending', matchType: 'exact' },
|
|
||||||
// 본인인증 복귀 — 인증창에서 돌아왔을 때 원래 화면·입력 내용 복원에 필요
|
|
||||||
{ key: 'g7.identity.redirectStash', matchType: 'exact' },
|
|
||||||
{ key: 'sirsoft-verification_nhnkcp.formStash', matchType: 'exact' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let installed = false;
|
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 호출이 허용되는지 판정합니다.
|
||||||
*
|
*
|
||||||
@@ -166,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "필수",
|
||||||
|
|||||||
@@ -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 : '',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 기본값이 방어한다."
|
||||||
|
- "출하 카탈로그를 나중에 줄여도 이미 시드된 사이트의 저장값은 그대로 남는다 (의도) — 운영자가 화면에서 직접 빼야 한다."
|
||||||
+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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user