From ef13a1ddffcc0bfe8457629d6fc745c4d67a06b0 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 15 Jul 2026 17:49:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(gdpr):=20=EC=BF=A0=ED=82=A4=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=20=EA=B8=B0=EB=8A=A5=20+=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8F=99=EC=9D=98=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=86=A0=EA=B8=80=20=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STEP 1~7 (거부 기능): - 쿠키 배너에 「동의하지 않고 계속하기」 거부 버튼 추가 (intent=reject) - is_rejected/rejected_at 컬럼 + ConsentAction Enum 3분기 (동의/철회/거부) - 관리자 동의 이력에 [거부] 필터 + amber 배지 STEP A~F (토글 재설계): - 필수 카테고리를 동의 축에서 완전 제외 (status/history 미저장) — ePrivacy Art.5(3) 면제 - 마이페이지 표→토글 전환: 필수=「항상 켜짐」 잠금, 선택=on/off 스위치, 동의일 제거, 「모두 켜기」 바 - 거부 토스트 문구 정정: consent.granted → rejected_saved (배너 + 컨트롤러 응답) 테스트: 백엔드 201/201, 프론트 288/288, 시나리오 커버리지 audit 통과 Chrome MCP 실측: 토글 grant/revoke/모두켜기 + 배너 거부 토스트 + 거부 직후 마이페이지 표기 확인 --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 17 ++ plugins/_bundled/sirsoft-gdpr/composer.json | 2 +- ...01_add_rejection_to_gdpr_user_consents.php | 54 +++++ .../sirsoft-gdpr/docs/api/consent-log.md | 4 +- .../_bundled/sirsoft-gdpr/docs/api/consent.md | 23 +- .../sirsoft-gdpr/lang/en/messages.php | 11 +- .../sirsoft-gdpr/lang/ko/messages.php | 11 +- plugins/_bundled/sirsoft-gdpr/package.json | 2 +- plugins/_bundled/sirsoft-gdpr/plugin.json | 2 +- .../resources/extensions/cookie_banner.json | 147 ++++++------ .../extensions/mypage_privacy_tab.json | 186 ++++++++++----- .../layouts/admin_plugin_settings.test.tsx | 12 +- .../__tests__/layouts/cookie_banner.test.tsx | 79 ++++++- .../layouts/gdpr_consent_log.test.tsx | 34 ++- .../layouts/mypage_privacy_tab.test.tsx | 121 ++++++---- .../sirsoft-gdpr/resources/lang/en.json | 19 +- .../sirsoft-gdpr/resources/lang/ko.json | 19 +- .../layouts/admin/gdpr_consent_log.json | 51 +++- .../sirsoft-gdpr/src/Enums/ConsentAction.php | 49 ++++ .../Public/GdprCookieConsentController.php | 12 +- .../Requests/StoreCookieConsentRequest.php | 3 + .../Resources/GdprUserConsentResource.php | 3 + .../src/Models/GdprUserConsent.php | 6 + .../GdprUserConsentRepository.php | 2 + .../src/Services/GdprConsentService.php | 66 ++++-- .../GdprCookieConsentControllerTest.php | 143 +++++++++++ ...UserConsentRejectionMigrationSmokeTest.php | 90 +++++++ .../tests/Unit/Enums/ConsentActionTest.php | 44 ++++ .../Unit/Services/GdprConsentServiceTest.php | 222 +++++++++++++++++- .../tests/scenarios/cookie-rejection.yaml | 102 ++++++++ 30 files changed, 1287 insertions(+), 249 deletions(-) create mode 100644 plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php create mode 100644 plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 78208d97..8e738382 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,6 +4,23 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-14 + +### Added + +- 쿠키 배너에 「동의하지 않고 계속하기」 거부 버튼 추가 — 선택 항목(기능·분석·마케팅)에 동의하지 않고 계속 이용할 수 있습니다. (sir.kr 커뮤니티의 시린♡ 님께서 제보해주셨습니다.) +- 거부한 이력을 동의·철회와 구분해 관리자 「GDPR 동의 이력」에서 별도 배지와 [거부] 필터로 확인할 수 있습니다. +- 거부 후 다시 동의하면 상태가 동의로 갱신되며, 거부·동의 이력은 모두 보존됩니다. +- 마이페이지 「쿠키 동의 관리」에 「모두 동의」 버튼 추가 — 동의하지 않은 선택 항목을 한 번에 동의할 수 있습니다. + +### Changed + +- 쿠키 배너 버튼을 정리했습니다 — 「필수만 사용」을 제거하고, 「환경설정」을 「선택 동의」로, 펼침 패널의 저장 버튼을 「선택 저장」으로 변경했습니다. +- 마이페이지 「쿠키 동의 관리」를 상태 배지 + 동의/철회 버튼 방식으로 개선했습니다 — 각 항목이 「동의함/거부함/항상 적용」 상태로 표시되고, 버튼을 누르면 동의·철회 여부를 한 번 더 확인합니다. 항목명도 「기능 쿠키」처럼 알아보기 쉽게 표기합니다. +- 마이페이지 「쿠키 동의 관리」의 글자 크기·여백이 어긋나던 문제를 바로잡고, 동의한 항목에는 동의한 날짜를 함께 표시합니다. +- 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. +- 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). + ## [1.0.1] - 2026-07-16 ### Changed diff --git a/plugins/_bundled/sirsoft-gdpr/composer.json b/plugins/_bundled/sirsoft-gdpr/composer.json index 7a887d75..8bb80961 100644 --- a/plugins/_bundled/sirsoft-gdpr/composer.json +++ b/plugins/_bundled/sirsoft-gdpr/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-gdpr", "description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft", "type": "library", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "authors": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php new file mode 100644 index 00000000..ff52cc25 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php @@ -0,0 +1,54 @@ +boolean('is_rejected') + ->default(false) + ->after('is_consented') + ->comment('명시적 거부 여부 (선택형 카테고리를 사용자가 명시적으로 거부한 상태)'); + } + + if (! Schema::hasColumn('gdpr_user_consents', 'rejected_at')) { + $table->timestamp('rejected_at') + ->nullable() + ->after('revoked_at') + ->comment('최근 명시적 거부 일시'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('gdpr_user_consents')) { + return; + } + + Schema::table('gdpr_user_consents', function (Blueprint $table) { + if (Schema::hasColumn('gdpr_user_consents', 'rejected_at')) { + $table->dropColumn('rejected_at'); + } + + if (Schema::hasColumn('gdpr_user_consents', 'is_rejected')) { + $table->dropColumn('is_rejected'); + } + }); + } +}; diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md index 305824e1..8e019ebf 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent-log.md @@ -46,7 +46,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | user_id | integer | `130` | user 식별자 (연관 리소스 참조) | | session_id | string | `c50916f6-58ee-42a6-a046-60a0c9ecfad2` | session 식별자 (연관 리소스 참조) | | consent_key | string | `cookie_marketing` | 변경된 동의 항목 키 (쿠키 카테고리 키). 어떤 동의 항목의 부여/철회가 기록된 행인지 나타냅니다 | -| action | string | `revoked` | 이 이력 행의 변경 유형 (`granted`=동의 부여, `revoked`=철회, `acknowledged`=확인). Art.7(1) 입증 트레일의 행위 구분입니다 | +| action | string | `revoked` | 이 이력 행의 변경 유형 (`granted`=동의 부여, `revoked`=철회, `rejected`=명시적 거부). `rejected`는 배너에서 "동의하지 않고 계속하기"로 선택형 카테고리를 거부한 행위이며 관리자 화면에 amber 배지로 표시됩니다. Art.7(1) 입증 트레일의 행위 구분입니다 | | source | string | `banner` | 동의 변경이 발생한 경로 (`banner`=쿠키 배너, `preference_center`=환경설정 센터, `mypage`=마이페이지, 그 외 `register`/`order`/`withdraw`) | | policy_version | string | `10` | 변경 시점의 정책 버전 문자열. 해당 동의가 어느 정책 버전 기준으로 표명되었는지 기록해 정책 갱신 후 재동의 판정과 감사에 사용됩니다 | | categories | object | `{"cookie_analytics":false,"cookie_marketing":false,"cooki…` | 변경 시점 전체 카테고리별 동의 여부 스냅샷 (키→boolean). MySQL JSON 컬럼이라 키가 알파벳 순으로 정규화 저장됩니다 | @@ -89,6 +89,6 @@ HTTP/1.1 200 -**설명** 관리자 감사 화면에서 `gdpr_user_consent_histories` 테이블의 동의 로그를 페이지네이션으로 조회합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `email`, `session_id`, `consent_keys[]`, `actions[]`(granted|revoked), `sources[]`(banner|preference_center|mypage), `per_page`(1~100, 기본 20) 쿼리 필터를 지원합니다. DPO 감사 용도로 IP 주소와 User-Agent까지 노출되는 조회 전용 엔드포인트입니다. +**설명** 관리자 감사 화면에서 `gdpr_user_consent_histories` 테이블의 동의 로그를 페이지네이션으로 조회합니다. `auth:sanctum`과 `sirsoft-gdpr.privacy.view` 권한이 필요합니다. `email`, `session_id`, `consent_keys[]`, `actions[]`(granted|revoked|rejected), `sources[]`(banner|preference_center|mypage), `per_page`(1~100, 기본 20) 쿼리 필터를 지원합니다. DPO 감사 용도로 IP 주소와 User-Agent까지 노출되는 조회 전용 엔드포인트입니다. diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md index fafef2f0..90a5047b 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md @@ -23,9 +23,13 @@ - **컨트롤러**: `Plugins\Sirsoft\Gdpr\Http\Controllers\Public\GdprCookieConsentController@store` - **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) -**요청 파라미터** +**요청 파라미터** (Body) -_요청 파라미터 없음._ +| 파라미터 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| consents | object | 예 | `{ "cookie_<카테고리키>": bool }` 형태의 동의 매트릭스. 최소 1개 이상. 필수 카테고리에 `false`를 보내면 거부됩니다(422) | +| source | string | 예 | 변경 경로. `banner` / `preference_center` / `register` / `mypage` 중 하나 | +| intent | string | 아니오 | 명시적 거부 신호. `reject` 만 허용합니다. 전송 시 선택형 미동의 항목(`false`)을 `is_rejected=true`로 저장하고 history `action=rejected`로 기록합니다. 미전송 시 미동의는 `revoked`로 기록됩니다(기존 동작). 배너의 "동의하지 않고 계속하기" 버튼이 전송합니다 | **요청 예시** @@ -33,7 +37,18 @@ _요청 파라미터 없음._ POST /api/plugins/sirsoft-gdpr/consent/cookie HTTP/1.1 Host: api.example.com Accept: application/json +Content-Type: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) + +{ + "consents": { + "cookie_necessary": true, + "cookie_analytics": false, + "cookie_marketing": false + }, + "source": "banner", + "intent": "reject" +} ``` **응답 필드** (`data` 내부) @@ -176,7 +191,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| histories | array | `[]` | 회원 본인의 동의 변경 이력 배열. 각 항목은 `consent_key`, `action`(granted/revoked), `source`, `policy_version`, `categories`, `created_at`로 구성되며 부여/철회 기록을 시간순으로 제공합니다 | +| histories | array | `[]` | 회원 본인의 동의 변경 이력 배열. 각 항목은 `consent_key`, `action`(granted/revoked/rejected), `source`, `policy_version`, `categories`, `created_at`로 구성되며 부여/철회/거부 기록을 시간순으로 제공합니다. `rejected`는 배너에서 명시적으로 거부(동의하지 않고 계속하기)한 항목입니다 | **응답 예시** @@ -233,7 +248,7 @@ _단건 응답: `data` 객체의 필드._ | user_id | integer | `166` | user 식별자 (연관 리소스 참조) | | needs_renewal | boolean | `false` | 회원의 활성 동의 중 옛 정책 버전인 항목이 있어 재동의가 필요한지 여부. 마이페이지가 「전체 다시 동의」 안내를 노출할지 판단합니다 | | current_policy_version | string | `10` | 현재 발행된 최신 정책 버전 문자열. 각 동의 항목의 `policy_version`과 비교해 항목별 갱신 필요 여부를 계산하는 기준입니다 | -| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다 | +| consents | array | `[{"id":null,"consent_key":"cookie_necessary","consent_lab…` | 카탈로그의 모든 쿠키 카테고리와 회원 status를 합친 동의 매트릭스. 항목마다 다국어 라벨(`consent_label`), 필수 여부(`is_required`), 현재 동의 상태(`is_consented`), 명시적 거부 여부(`is_rejected`)와 거부 일시(`rejected_at`), 철회/재동의 가능 여부(`can_revoke`/`can_grant`), 항목별 갱신 필요(`needs_renewal_this_item`)를 담아 한 화면에서 철회·재동의·신규 동의를 처리하게 합니다. `is_rejected=true`는 마이페이지에서 「거부」 배지로 표시됩니다 | **응답 예시** diff --git a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php index 207734d5..47ea17c5 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php @@ -4,6 +4,7 @@ return [ 'consent' => [ 'granted' => 'Consent has been saved.', 'granted_again' => 'Consent has been renewed.', + 'rejected_saved' => 'Continuing without consent. Optional items will not be used.', 'revoked' => 'Consent has been revoked.', 'renew_all_success' => '{renewed} active consent item(s) renewed to the new policy version.', 'granting' => 'Granting...', @@ -189,6 +190,7 @@ return [ 'action' => [ 'granted' => 'Granted', 'revoked' => 'Revoked', + 'rejected' => 'Rejected', ], 'source' => [ 'banner' => 'Banner', @@ -210,7 +212,7 @@ return [ 'categories_label' => 'Categories Snapshot', 'categories_hint' => "The member's category-by-category choices at the time of consent. This record stays immutable even after the policy is updated.", 'snapshot_granted' => 'Granted', - 'snapshot_revoked' => 'Revoked', + 'snapshot_revoked' => 'Not Consented', ], ], ], @@ -228,6 +230,7 @@ return [ 'col_action' => 'Action', 'consented' => 'Consented', 'revoked' => 'Revoked', + 'rejected_label' => 'Rejected', 'revoke' => 'Revoke', 'grant' => 'Consent', 'grant_again' => 'Consent Again', @@ -255,9 +258,9 @@ return [ 'title' => 'Cookie Notice', 'description' => 'This site uses cookies to improve user experience and for analytics. You can choose which cookies to allow.', 'accept_all' => 'Accept All', - 'reject_all' => 'Necessary Only', - 'preferences' => 'Preferences', - 'save' => 'Save Preferences', + 'reject_continue' => 'Continue without consent', + 'preferences' => 'Select Consent', + 'save' => 'Save Selection', 'policy_link' => 'Privacy Policy', 'preferences_title' => 'Cookie Preferences', 'preferences_description' => 'You can grant or revoke each category individually. Required categories cannot be turned off.', diff --git a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php index fd170659..f5329dfa 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php @@ -4,6 +4,7 @@ return [ 'consent' => [ 'granted' => '동의가 저장되었습니다.', 'granted_again' => '동의가 갱신되었습니다.', + 'rejected_saved' => '동의하지 않고 계속합니다. 선택 항목은 사용되지 않습니다.', 'revoked' => '동의가 철회되었습니다.', 'renew_all_success' => '활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.', 'granting' => '동의 중...', @@ -189,6 +190,7 @@ return [ 'action' => [ 'granted' => '동의', 'revoked' => '철회', + 'rejected' => '거부', ], 'source' => [ 'banner' => '배너', @@ -210,7 +212,7 @@ return [ 'categories_label' => '카테고리 스냅샷', 'categories_hint' => '동의 시점에 회원이 카테고리별로 표시한 의사입니다. 정책 본문이 갱신되어도 이 기록은 불변으로 보존됩니다.', 'snapshot_granted' => '동의', - 'snapshot_revoked' => '거부', + 'snapshot_revoked' => '미동의', ], ], ], @@ -228,6 +230,7 @@ return [ 'col_action' => '작업', 'consented' => '동의', 'revoked' => '철회', + 'rejected_label' => '거부', 'revoke' => '철회', 'grant' => '동의', 'grant_again' => '다시 동의', @@ -255,9 +258,9 @@ return [ 'title' => '쿠키 사용 안내', 'description' => '본 사이트는 사용자 경험 개선과 분석을 위해 쿠키를 사용합니다. 항목별로 동의 여부를 선택할 수 있습니다.', 'accept_all' => '모두 동의', - 'reject_all' => '필수만 사용', - 'preferences' => '환경설정', - 'save' => '선택 동의', + 'reject_continue' => '동의하지 않고 계속하기', + 'preferences' => '선택 동의', + 'save' => '선택 저장', 'policy_link' => '개인정보처리방침', 'preferences_title' => '쿠키 환경설정', 'preferences_description' => '카테고리별 쿠키 사용을 개별 동의/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.', diff --git a/plugins/_bundled/sirsoft-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json index e4cea08b..31c6df25 100644 --- a/plugins/_bundled/sirsoft-gdpr/package.json +++ b/plugins/_bundled/sirsoft-gdpr/package.json @@ -1,6 +1,6 @@ { "name": "@plugins/sirsoft-gdpr", - "version": "1.0.1", + "version": "1.0.2", "private": true, "type": "module", "scripts": { diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json index 7ad4cd8f..04d9368c 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.json +++ b/plugins/_bundled/sirsoft-gdpr/plugin.json @@ -5,7 +5,7 @@ "ko": "GDPR (일반 데이터 보호 규정)", "en": "GDPR (General Data Protection Regulation)" }, - "version": "1.0.1", + "version": "1.0.2", "description": { "ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.", "en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal." diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json index af112652..b7e8057a 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json @@ -150,7 +150,78 @@ }, "children": [ { - "comment": "환경설정 토글 — 배너 내부 인라인 펼침 영역을 열고 닫음", + "comment": "동의하지 않고 계속하기 (거부, 이슈 #430) — 텍스트/링크형(배경 없음). required 카테고리만 true 로 저장 + intent:'reject' 신호로 선택형 미동의 항목을 is_rejected=true 로 기록 후 배너 닫기. 기존 '필수만 사용'(reject_all) 버튼을 대체. 식별자 reject 로 정리.", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!!_global.gdprBannerSubmittingAction}}", + "className": "px-2 py-2 text-sm text-gray-500 dark:text-gray-400 underline hover:no-underline hover:text-gray-700 dark:hover:text-gray-200 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center bg-transparent" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "reject" } }, + { + "handler": "apiCall", + "auth_mode": "optional", + "target": "/api/plugins/sirsoft-gdpr/consent/cookie", + "params": { + "method": "POST", + "body": "{{{ consents: (gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, ['cookie_' + c.key]: c.required === true }), {}), source: 'banner', intent: 'reject' }}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "gdprBannerSubmittingAction": null, + "gdprBannerDismissed": true, + "gdprPreferencesOpen": false, + "gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: c.required === true }), {})}}" + } + }, + { + "handler": "refetchDataSource", + "params": { "dataSourceId": "gdprMyConsent" } + }, + { + "comment": "자동 차단 엔진 동의 메모리 캐시 갱신 + rescan", + "handler": "sirsoft-gdpr.syncConsent" + }, + { + "comment": "거부 전용 토스트 (이슈 #430) — 거부인데 '동의가 저장' 문구가 뜨던 오표기 수정.", + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-gdpr.consent.rejected_saved" + } + } + ], + "onError": [ + { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null } }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error?.message ?? 'Error'}}" + } + } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "if": "{{_global.gdprBannerSubmittingAction === 'reject'}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } }, + { "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'reject'}}", "text": "$t:sirsoft-gdpr.banner.reject_continue" }, + { "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'reject'}}", "text": "$t:sirsoft-gdpr.consent.granting" } + ] + }, + { + "comment": "선택 동의 토글 (이슈 #430 라벨 변경: 환경설정→선택 동의) — 배너 내부 인라인 펼침 영역을 열고 닫음. 버튼 순서: 거부 다음, 모두 동의 앞 (제보자 예시/업계 관행 — 거부 → 선택 → 전체). banner.preferences 키 유지, 값만 '선택 동의' 로 변경(STEP6).", "id": "gdpr_preferences_toggle_button", "type": "basic", "name": "Button", @@ -177,77 +248,7 @@ ] }, { - "comment": "필수만 사용 — required 카테고리만 true 로 저장 후 배너 닫기. gdprBannerSubmittingAction 식별자(string)로 어느 버튼이 진행 중인지 추적 — 클릭한 버튼만 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지 (어느 버튼을 눌렀는지 시각적 인지 가능)", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "disabled": "{{!!_global.gdprBannerSubmittingAction}}", - "className": "px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": "reject_all" } }, - { - "handler": "apiCall", - "auth_mode": "optional", - "target": "/api/plugins/sirsoft-gdpr/consent/cookie", - "params": { - "method": "POST", - "body": "{{{ consents: (gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, ['cookie_' + c.key]: c.required === true }), {}), source: 'banner' }}}" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "global", - "gdprBannerSubmittingAction": null, - "gdprBannerDismissed": true, - "gdprPreferencesOpen": false, - "gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: c.required === true }), {})}}" - } - }, - { - "handler": "refetchDataSource", - "params": { "dataSourceId": "gdprMyConsent" } - }, - { - "comment": "자동 차단 엔진 동의 메모리 캐시 갱신 + rescan", - "handler": "sirsoft-gdpr.syncConsent" - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "$t:sirsoft-gdpr.consent.granted" - } - } - ], - "onError": [ - { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null } }, - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error?.message ?? 'Error'}}" - } - } - ] - } - ] - } - ], - "children": [ - { "type": "basic", "name": "Icon", "if": "{{_global.gdprBannerSubmittingAction === 'reject_all'}}", "props": { "name": "spinner", "size": "sm", "spin": true, "className": "mr-1" } }, - { "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction !== 'reject_all'}}", "text": "$t:sirsoft-gdpr.banner.reject_all" }, - { "type": "basic", "name": "Span", "if": "{{_global.gdprBannerSubmittingAction === 'reject_all'}}", "text": "$t:sirsoft-gdpr.consent.granting" } - ] - }, - { - "comment": "현재 동의 갱신 (회원) — needs_renewal=true 한정. 회원은 status 테이블에 동의가 영속되므로 renew-all 로 활성 동의의 policy_version 만 신정책으로 bump (사용자 의사 변경 없음, Art.7(3) 자유 변경권 + Recital 32 명확한 긍정 행위 부합). 위치: 환경설정/필수만 사용 다음, 모두 동의 앞.", + "comment": "현재 동의 갱신 (회원) — needs_renewal=true 한정. 회원은 status 테이블에 동의가 영속되므로 renew-all 로 활성 동의의 policy_version 만 신정책으로 bump (사용자 의사 변경 없음, Art.7(3) 자유 변경권 + Recital 32 명확한 긍정 행위 부합). 위치: 선택 동의 다음, 모두 동의 앞.", "id": "gdpr_keep_consent_button", "type": "basic", "name": "Button", @@ -369,7 +370,7 @@ "name": "Div", "if": "{{!!gdprPublicSettings?.data?.legal_entity_name || !!gdprPublicSettings?.data?.data_storage_location}}", "props": { - "className": "mt-3 pt-2 border-t border-gray-100 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400" + "className": "mt-3 pt-2 border-t border-gray-100 dark:border-gray-700 text-tertiary" }, "children": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json index cae23066..1f5d46a0 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json @@ -347,98 +347,165 @@ "text": "$t:sirsoft-gdpr.mypage.privacy.no_consents" }, { + "comment": "「모두 켜기」 일괄 동의 바 — 선택형(is_required=false) 중 꺼진(is_consented=false) 항목이 하나라도 있을 때만 노출. 필수는 대상 외. renew-all 이 아니라 신규/재동의 축이므로 grant 를 항목별로 반복 호출하는 대신, 서버 renew-all 은 '이미 활성 항목의 정책 갱신' 전용이라 여기서는 각 꺼진 선택형 항목을 grant 로 순차 켠다. 다만 레이아웃 반복 호출은 표현이 어렵기에, 배너의 '모두 동의' 와 동일하게 consent/cookie(intent 없음, 전체 true) 로 한 번에 처리.", + "type": "basic", + "name": "Div", + "if": "{{(gdprMeConsents?.data?.consents ?? []).filter(c => c?.is_required !== true && c?.is_consented !== true).length > 0}}", + "props": { "className": "flex items-center justify-between gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800" }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-sm text-blue-800 dark:text-blue-200" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.enable_all_hint" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!!_local.enablingAll}}", + "className": "shrink-0 px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "enablingAll": true } }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-gdpr/consent/cookie", + "params": { + "method": "POST", + "body": "{{{ source: 'mypage', consents: (gdprMeConsents?.data?.consents ?? []).reduce((acc, c) => (c?.is_required === true ? acc : { ...acc, [c.consent_key]: true }), {}) }}}" + }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "enablingAll": null } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMeConsents" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsents" } }, + { "handler": "sirsoft-gdpr.syncConsent" }, + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.mypage.privacy.enable_all_success" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "enablingAll": null } }, + { "handler": "toast", "params": { "type": "error", "message": "{{error?.message ?? 'Error'}}" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "if": "{{!!_local.enablingAll}}", "props": { "name": "spinner", "size": "xs", "spin": true, "className": "mr-1" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.enable_all" } + ] + } + ] + }, + { + "comment": "토글 리스트 (이슈 #430 재설계) — 업계 표준 CMP(OneTrust/Cookiebot) 패턴: 필수=잠긴 '항상 켜짐', 선택=on/off 스위치. 동의일 컬럼은 사용자 화면에서 제거(감사 로그/관리자 화면에만 존재). 스위치는 Checkbox/Toggle 컴포넌트 부재로 클릭 가능한 Div 로 구현 — 필수는 클릭 액션 없이 시각적 잠금.", "type": "basic", "name": "Div", "if": "{{(gdprMeConsents?.data?.consents ?? []).length > 0}}", + "props": { "className": "divide-y divide-gray-100 dark:divide-gray-700" }, + "iteration": { + "source": "gdprMeConsents?.data?.consents ?? []", + "item_var": "consent", + "index_var": "consentIdx" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "text-tertiary grid grid-cols-12 gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-700 font-medium uppercase" }, - "children": [ - { "type": "basic", "name": "Div", "props": { "className": "col-span-6" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_consent_key" }, - { "type": "basic", "name": "Div", "props": { "className": "col-span-2 text-center" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_consented" }, - { "type": "basic", "name": "Div", "props": { "className": "col-span-2" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_consented_at" }, - { "type": "basic", "name": "Div", "props": { "className": "col-span-2 text-center" }, "text": "$t:sirsoft-gdpr.mypage.privacy.col_action" } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "divide-y divide-gray-100 dark:divide-gray-700" }, - "iteration": { - "source": "gdprMeConsents?.data?.consents ?? []", - "item_var": "consent", - "index_var": "consentIdx" - }, + "props": { "className": "flex items-center justify-between gap-4 px-4 py-3" }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "grid grid-cols-12 gap-2 px-4 py-2 items-center text-sm" }, + "comment": "라벨 + 사용자 친화 1줄 설명 (raw key 대신 카테고리 의미 노출).", + "props": { "className": "min-w-0 flex-1" }, "children": [ { "type": "basic", "name": "Div", - "comment": "라벨 + 사용자 친화 1줄 설명. 회원이 영문 식별자(cookie_analytics) 대신 카테고리 의미를 즉시 이해할 수 있도록 카탈로그 description (CookieCategoryService::getDescriptionForKey) 을 함께 노출.", - "props": { "className": "col-span-6" }, + "props": { "className": "flex items-center gap-2" }, "children": [ { "type": "basic", "name": "Span", - "props": { "className": "text-primary block font-medium" }, + "props": { "className": "text-primary font-medium text-sm" }, "text": "{{consent?.consent_label ?? consent?.consent_key ?? ''}}" }, { "type": "basic", "name": "Span", - "if": "{{!!consent?.consent_description}}", - "props": { "className": "block text-tertiary mt-0.5 leading-relaxed" }, - "text": "{{consent?.consent_description ?? ''}}" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "col-span-2 flex justify-center" }, - "children": [ - { - "type": "basic", - "name": "Span", - "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{consent?.is_consented ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}}" }, - "text": "{{consent?.is_consented ? '$t:sirsoft-gdpr.mypage.privacy.consented' : '$t:sirsoft-gdpr.mypage.privacy.revoked'}}" + "if": "{{consent?.is_required === true}}", + "props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.required_label" } ] }, { "type": "basic", "name": "Span", - "props": { "className": "text-secondary col-span-2 text-xs" }, - "text": "{{consent?.consented_at ?? '-'}}" - }, + "if": "{{!!consent?.consent_description}}", + "props": { "className": "block text-tertiary mt-0.5 text-xs leading-relaxed" }, + "text": "{{consent?.consent_description ?? ''}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "shrink-0 flex items-center gap-2" }, + "children": [ { - "comment": "액션 컬럼 — Art.7(3) 대칭성 + ePrivacy Art.5(3) 동의 면제 패턴 4가지 분기:\n 1) is_required → 「필수」 회색 라벨 (철회/재동의 둘 다 무의미)\n 2) can_revoke (선택형 + 활성) → 「철회」 빨간 버튼\n 3) can_grant (선택형 + 비활성) → 「다시 동의」/「동의」 파란 버튼\n 4) 그 외 (이론상 발생 X, 안전망)\nServer Resource (`is_required` / `can_grant` / `can_revoke`) 메타가 SSoT 라 클라이언트는 단순 분기.", + "comment": "필수 카테고리 — 잠긴 '항상 켜짐' 스위치. 클릭 액션 없음(철회 불가, ePrivacy Art.5(3) 면제). 파란 채움 + 오른쪽 knob + not-allowed 커서로 잠금 표현.", "type": "basic", "name": "Div", - "props": { "className": "flex-center col-span-2 justify-center gap-2" }, + "if": "{{consent?.is_required === true}}", + "props": { "className": "flex items-center gap-2" }, "children": [ { "type": "basic", "name": "Span", - "if": "{{consent?.is_required === true}}", - "props": { - "className": "text-secondary inline-flex items-center px-2 py-0.5 text-xs italic" - }, - "text": "$t:sirsoft-gdpr.mypage.privacy.required_label" + "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-gdpr.mypage.privacy.always_on" }, { + "type": "basic", + "name": "Div", + "props": { "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600/60 dark:bg-blue-500/60 cursor-not-allowed" }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "lock", "size": "xs", "className": "absolute left-1.5 text-white/80" } }, + { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-6 rounded-full bg-white shadow transition-transform" } } + ] + } + ] + }, + { + "comment": "선택형 카테고리 — on/off 스위치. 켜짐(is_consented=true)이면 녹색+revoke 확인 모달, 꺼짐이면 회색+grant 즉시 호출. grantingKey 로 해당 행만 처리 중 표시.", + "type": "basic", + "name": "Div", + "if": "{{consent?.is_required !== true}}", + "props": { "className": "flex items-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-xs font-medium {{consent?.is_consented ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'}}" }, + "text": "{{consent?.is_consented ? '$t:sirsoft-gdpr.mypage.privacy.toggle_on' : '$t:sirsoft-gdpr.mypage.privacy.toggle_off'}}" + }, + { + "comment": "켜짐 상태 스위치 — 클릭 시 철회 확인 모달. 녹색 채움 + knob 오른쪽.", "type": "basic", "name": "Button", - "if": "{{consent?.can_revoke === true}}", + "if": "{{consent?.is_consented === true}}", "props": { "type": "button", - "className": "px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 font-medium transition-colors" + "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", + "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-green-500 dark:bg-green-600 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-1" }, "actions": [ { @@ -446,7 +513,6 @@ "handler": "sequence", "actions": [ { - "comment": "모달은 별도 컨텍스트라 부모 _local 미해석 — _global 사용 필수. revokeTargetKey/Label/Description 3개를 동시 세팅하여 모달이 라벨 + 친화 설명을 표시할 수 있게 함 (raw key 노출은 사용자에게 무의미하므로 description 으로 대체)", "handler": "setState", "params": { "target": "global", @@ -460,18 +526,19 @@ } ], "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-gdpr.mypage.privacy.revoke" } + { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-6 rounded-full bg-white shadow transition-transform" } } ] }, { + "comment": "꺼짐 상태 스위치 — 클릭 시 즉시 grant. 회색 채움 + knob 왼쪽. 정책 갱신 항목도 별도 색 분기 없이 grant 로 통합(정책 갱신도 결국 동의 표명, grant 는 항상 현재 정책 버전 기록).", "type": "basic", "name": "Button", - "if": "{{consent?.can_grant === true}}", - "comment": "3분기 (#21) — needs_renewal_this_item: 최신 정책으로 갱신 / consent_count>0: 다시 동의 / 그 외: 신규 동의. needs_renewal 분기는 다른 버튼 색 (amber) 으로 시각적 구분. grantingKey 비교로 클릭한 해당 행만 spinner 노출 + 다른 행 클릭은 자동 차단.", + "if": "{{consent?.is_consented !== true}}", "props": { "type": "button", - "disabled": "{{!!_local.grantingKey}}", - "className": "{{consent?.needs_renewal_this_item === true ? 'px-3 py-1 text-xs bg-amber-600 text-white rounded hover:bg-amber-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center' : 'px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center'}}" + "aria-label": "{{consent?.consent_label ?? consent?.consent_key}}", + "disabled": "{{_local.grantingKey === consent?.consent_key}}", + "className": "relative inline-flex h-6 w-11 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-wait" }, "actions": [ { @@ -503,14 +570,7 @@ } ], "children": [ - { "type": "basic", "name": "Icon", "if": "{{_local.grantingKey === consent?.consent_key}}", "props": { "name": "spinner", "size": "xs", "spin": true, "className": "mr-1" } }, - { - "type": "basic", - "name": "Span", - "if": "{{_local.grantingKey !== consent?.consent_key}}", - "text": "{{consent?.needs_renewal_this_item === true ? '$t:sirsoft-gdpr.mypage.privacy.btn_renew_this_item' : ((consent?.consent_count ?? 0) > 0 ? '$t:sirsoft-gdpr.mypage.privacy.grant_again' : '$t:sirsoft-gdpr.mypage.privacy.grant')}}" - }, - { "type": "basic", "name": "Span", "if": "{{_local.grantingKey === consent?.consent_key}}", "text": "$t:sirsoft-gdpr.consent.granting" } + { "type": "basic", "name": "Span", "props": { "className": "inline-block h-4 w-4 translate-x-1 rounded-full bg-white shadow transition-transform" } } ] } ] diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index f7a3a964..e177dc81 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -274,8 +274,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(serialized).toContain('border-l-4'); expect(serialized).toContain('border-l-blue-500'); expect(serialized).toContain('circle-info'); - // 한 줄 + 중간 정렬 - expect(serialized).toContain('flex items-center'); + // 한 줄 + 중간 정렬 (시맨틱 flex-center 로 통일 — #399) + expect(serialized).toContain('flex-center'); // 다크모드 가독성: 카드(gray-800)보다 한 단계 어두운 gray-900 expect(serialized).toContain('dark:bg-gray-900'); @@ -298,8 +298,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(serialized).not.toContain('blocked_domains.preblocker_active'); expect(serialized).not.toContain('blocked_domains.self_hosted_attr'); expect(serialized).not.toContain('blocked_domains.static_html_limitation'); - // 타이틀 강조 — 별도 badge 없이 제목 폰트를 text-base 로 키워 타이틀 느낌 - expect(serialized).toContain('text-base'); + // 타이틀 강조 — 시맨틱 text-error-strong 로 제목 강조 (#399 시맨틱 통일) + expect(serialized).toContain('text-error-strong'); // 옛 badge 키는 lang 과 박스에서 모두 제거됨 expect(serialized).not.toContain('blocked_domains.warnings_badge'); // 기존 옛 항목 키 미사용 @@ -427,8 +427,8 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au const serialized = JSON.stringify(wrapper); // Input className 동적 변경 — 대괄호 표기법으로 errors 키 접근 (코어 _tab_general 패턴) expect(serialized).toContain(`_local.errors?.['${formField}']`); - expect(serialized).toContain('border-red-500'); - expect(serialized).toContain('focus:ring-red-500'); + // 에러 시 시맨틱 input-error 로 통일 (#399 시맨틱 통일 — 옛 border-red-500/focus:ring-red-500 원시 클래스 대체) + expect(serialized).toContain('input-error'); // 필드 아래 에러 Span — if 가드 + 첫 메시지 text (코어 단순 패턴) expect(serialized).toContain(`_local.errors?.['${formField}']?.[0]`); // 에러 Span 시각 시맨틱 (form-error) — 텍스트 톤 + 줄바꿈을 한 결로 통일 (#399) diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx index 70f8bbfc..8cfc2227 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx @@ -228,10 +228,61 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(text).toContain('!(gdprMyConsent?.data?.is_member === true && gdprMyConsent?.data?.needs_renewal === true)'); }); - it('"필수만 사용" 시 required 카테고리만 true (cookie_ prefix)', () => { + it('"동의하지 않고 계속하기"(거부) 시 required 카테고리만 true (cookie_ prefix)', () => { + // 이슈 #430 — 기존 '필수만 사용'(reject_all) 을 거부 버튼으로 대체. body 는 필수만 true 로 재활용. expect(text).toContain("['cookie_' + c.key]: c.required === true"); }); + /** + * @scenario entry=reject, subject=member, category=optional + * @effects banner_reject_button_sends_intent_reject + */ + it('거부 버튼은 POST body 에 intent:\'reject\' 신호를 실어 보낸다 (이슈 #430)', () => { + // 서버가 선택형 미동의 항목을 is_rejected=true 로 저장하도록 하는 명시적 거부 신호. + expect(text).toContain("intent: 'reject'"); + }); + + it('거부 버튼 라벨은 banner.reject_continue, 기존 banner.reject_all 은 제거됨', () => { + expect(text).toContain('sirsoft-gdpr.banner.reject_continue'); + expect(text).not.toContain('sirsoft-gdpr.banner.reject_all'); + }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects banner_reject_toast_uses_rejected_saved + */ + it('거부 버튼 성공 토스트는 consent.rejected_saved 를 쓴다 (거부인데 "동의가 저장" 오표기 정정, 이슈 #430)', () => { + // 거부 버튼 apiCall 노드를 격리해 검증 — 배너 전체 text 에는 다른 버튼의 consent.granted 도 있으므로 + // intent:'reject' 를 body 에 가진 apiCall 서브트리 안에서만 rejected_saved 를 확인한다. + const findRejectApiCall = (node: unknown): Record | null => { + if (!node || typeof node !== 'object') return null; + const obj = node as Record; + const body = (obj.params as { body?: string } | undefined)?.body; + if (obj.handler === 'apiCall' && typeof body === 'string' && body.includes("intent: 'reject'")) { + return obj; + } + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + for (const item of value) { + const found = findRejectApiCall(item); + if (found) return found; + } + } else if (value && typeof value === 'object') { + const found = findRejectApiCall(value); + if (found) return found; + } + } + return null; + }; + + const rejectApiCall = findRejectApiCall(banner); + expect(rejectApiCall).toBeTruthy(); + const rejectSubtree = serializeForSearch(rejectApiCall); + expect(rejectSubtree).toContain('sirsoft-gdpr.consent.rejected_saved'); + // 거부 버튼 서브트리에는 "동의가 저장"(consent.granted) 토스트가 남아있지 않아야 함 + expect(rejectSubtree).not.toContain('$t:sirsoft-gdpr.consent.granted"'); + }); + it('POST body 키는 백엔드 검증 규칙(StoreCookieConsentRequest)과 일치 — consents + source 사용', () => { // 회귀 가드: 백엔드는 consents (객체, cookie_ prefix 키) + source ("banner") 를 요구한다. // 과거 categories / policy_version 으로 잘못 보내서 422 "유효하지 않은 동의 항목입니다." 반환됨. @@ -283,9 +334,10 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 회귀 가드: 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어 // 어느 버튼을 클릭했는지 시각적으로 알 수 없음. 식별자 string 으로 변경하여 클릭한 버튼만 // 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지. - // 4 action 식별자: accept_all / reject_all / save_selection / renew_consent + // 4 action 식별자: accept_all / reject / save_selection / renew_consent + // (이슈 #430 — reject_all 식별자를 reject 로 정리) expect(text).toContain('"gdprBannerSubmittingAction": "accept_all"'); - expect(text).toContain('"gdprBannerSubmittingAction": "reject_all"'); + expect(text).toContain('"gdprBannerSubmittingAction": "reject"'); expect(text).toContain('"gdprBannerSubmittingAction": "save_selection"'); expect(text).toContain('"gdprBannerSubmittingAction": "renew_consent"'); @@ -295,7 +347,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 스피너 if 가드는 자기 식별자와 정확히 일치할 때만 노출 expect(text).toContain("_global.gdprBannerSubmittingAction === 'accept_all'"); - expect(text).toContain("_global.gdprBannerSubmittingAction === 'reject_all'"); + expect(text).toContain("_global.gdprBannerSubmittingAction === 'reject'"); expect(text).toContain("_global.gdprBannerSubmittingAction === 'save_selection'"); expect(text).toContain("_global.gdprBannerSubmittingAction === 'renew_consent'"); @@ -308,6 +360,25 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 옛 단일 boolean 플래그 패턴 회귀 차단 expect(text).not.toContain('"gdprBannerSubmitting": true'); }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects banner_button_order_reject_accept_preferences + */ + it('버튼 순서 (이슈 #430): 거부 → 선택 동의 토글 → 모두 동의', () => { + // 제보자 예시/업계 관행 (거부 → 선택 → 전체) 순서. + // reject_continue(거부) → preferences(선택 동의 토글) → accept_all(모두 동의) 순으로 등장해야 한다. + const rejectIdx = text.indexOf('sirsoft-gdpr.banner.reject_continue'); + const prefIdx = text.indexOf('sirsoft-gdpr.banner.preferences'); + const acceptIdx = text.indexOf('sirsoft-gdpr.banner.accept_all'); + + expect(rejectIdx).toBeGreaterThanOrEqual(0); + expect(prefIdx).toBeGreaterThanOrEqual(0); + expect(acceptIdx).toBeGreaterThanOrEqual(0); + + expect(rejectIdx).toBeLessThan(prefIdx); + expect(prefIdx).toBeLessThan(acceptIdx); + }); }); describe('환경설정 인라인 펼침 패널', () => { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx index 89d81463..b8072610 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx @@ -120,12 +120,23 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용 checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `consent_keys[${idx}]`)); }); - it('action 필터의 체크박스 3개 모두 옵션 C 패턴', () => { + it('action 필터의 체크박스 4개 모두 옵션 C 패턴 (이슈 #430 거부 추가)', () => { const checkboxes = findCheckboxesIn('action_filter'); - expect(checkboxes.length, '체크박스 3개 (전체 / granted / revoked)').toBe(3); + // 전체 / granted / revoked / rejected — 이슈 #430 에서 거부(rejected) 필터 추가 + expect(checkboxes.length, '체크박스 4개 (전체 / granted / revoked / rejected)').toBe(4); checkboxes.forEach((cb, idx) => expectSequencePattern(cb, `action[${idx}]`)); }); + /** + * @scenario entry=reject, subject=member, category=optional + * @effects admin_log_filter_rejected_checkbox + */ + it('action 필터에 거부(rejected) 체크박스가 존재한다 (이슈 #430)', () => { + const layoutJson = JSON.stringify(root); + expect(layoutJson).toContain("'rejected'"); + expect(layoutJson).toContain('sirsoft-gdpr.admin.consent_log.action.rejected'); + }); + it('source 필터의 체크박스 4개 모두 옵션 C 패턴', () => { const checkboxes = findCheckboxesIn('source_filter'); expect(checkboxes.length, '체크박스 4개 (전체 / banner / preference_center / mypage)').toBe(4); @@ -264,4 +275,23 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용 expect(layoutJson).not.toContain('dark:bg-green-900/40'); }); }); + + describe('action 배지 — 거부(rejected) 케이스 (이슈 #430)', () => { + const layoutJson = JSON.stringify(root); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects admin_log_action_badge_rejected_amber + */ + it('action 배지 className 분기에 rejected → amber 색이 존재한다', () => { + // granted=green / rejected=amber / revoked=red / 그 외=gray + expect(layoutJson).toContain("row.action === 'rejected'"); + expect(layoutJson).toContain('bg-amber-100'); + }); + + it('action 배지 텍스트는 동적으로 action 값에 따라 lang 키를 해석한다 (rejected 포함)', () => { + // {{$t('sirsoft-gdpr.admin.consent_log.action.' + (row.action ?? 'granted'))}} + expect(layoutJson).toContain('sirsoft-gdpr.admin.consent_log.action.'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx index 5a1173b7..6fba4b7f 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx @@ -265,66 +265,99 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); }); - describe('동의 현황 표 컬럼 구성', () => { + describe('토글 UI 구조 (이슈 #430 재설계 — 업계 표준 CMP 패턴)', () => { const section = card ? findById(card, 'gdpr_section_consents') : null; - it('표 헤더에 col_consent_key / col_consented / col_consented_at / col_action 컬럼이 있다', () => { + /** + * @scenario entry=reject, subject=member, category=required + * @effects mypage_no_consent_date_column_for_user + */ + it('사용자 화면에 동의일 컬럼이 없다 (감사/관리자 전용) — col_consented_at / col_action 헤더 제거', () => { + // 재설계 회귀 가드: 표 헤더(col_consent_key/col_consented/col_consented_at/col_action) 전면 제거. + // 동의일은 GDPR 감사 로그·관리자 화면에만 존재하고 사용자 토글 화면엔 노출하지 않음. const text = serializeForSearch(section); - expect(text).toContain('mypage.privacy.col_consent_key'); - expect(text).toContain('mypage.privacy.col_consented'); - expect(text).toContain('mypage.privacy.col_consented_at'); - expect(text).toContain('mypage.privacy.col_action'); + expect(text).not.toContain('mypage.privacy.col_consented_at'); + expect(text).not.toContain('mypage.privacy.col_action'); + // 행 단위 consented_at 바인딩도 사용자 화면에서 제거 + expect(text).not.toContain('consent?.consented_at'); }); - }); - describe('동의 매트릭스 액션 4분기 (Art.7(3) 대칭성 + ePrivacy Art.5(3))', () => { - const section = card ? findById(card, 'gdpr_section_consents') : null; - - it('필수 카테고리 행 — 「필수」 라벨 노출 (철회/재동의 둘 다 무의미)', () => { + /** + * @scenario entry=reject, subject=member, category=required + * @effects mypage_required_shows_locked_always_on_toggle + */ + it('필수 카테고리 — 잠긴 「항상 켜짐」 토글 (액션 없음, lock 아이콘 + not-allowed)', () => { const text = serializeForSearch(section); expect(text).toContain('consent?.is_required === true'); + expect(text).toContain('mypage.privacy.always_on'); + // 잠금 시각 표현: lock 아이콘 + not-allowed 커서 + expect(text).toContain('"name": "lock"'); + expect(text).toContain('cursor-not-allowed'); + // 필수 행에는 required_label 배지도 노출 expect(text).toContain('mypage.privacy.required_label'); }); - it('선택형 활성 행 — can_revoke=true 일 때 「철회」 빨간 버튼', () => { + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_optional_shows_on_off_toggle + */ + it('선택형 켜짐(is_consented) — 녹색 토글 + 「켜짐」 라벨, 클릭 시 철회 확인 모달', () => { const text = serializeForSearch(section); - expect(text).toContain('consent?.can_revoke === true'); - expect(text).toContain('bg-red-600'); + expect(text).toContain('consent?.is_consented === true'); + expect(text).toContain('mypage.privacy.toggle_on'); + // 켜짐 토글은 녹색 + expect(text).toContain('bg-green-500'); + // 클릭 시 철회 확인 모달 오픈 (즉시 철회 아님 — 오조작 방지) + expect(text).toContain('"target": "revoke_confirm_modal"'); }); - it('선택형 비활성 행 — can_grant=true 일 때 「동의」/「다시 동의」 파란 버튼', () => { - const text = serializeForSearch(section); - expect(text).toContain('consent?.can_grant === true'); - expect(text).toContain('mypage.privacy.grant_again'); - expect(text).toContain('mypage.privacy.grant'); - }); - - it('동의 부여 버튼 클릭 시 POST /consent/grant + onSuccess 에 refetch + 토스트', () => { + it('선택형 꺼짐(!is_consented) — 회색 토글 + 「꺼짐」 라벨, 클릭 시 즉시 grant', () => { const text = serializeForSearch(section); + expect(text).toContain('consent?.is_consented !== true'); + expect(text).toContain('mypage.privacy.toggle_off'); + // 꺼짐 토글은 회색 + expect(text).toContain('bg-gray-300'); + // 클릭 시 POST /consent/grant expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/grant"'); - expect(text).toContain('"dataSourceId": "gdprMeConsents"'); }); - it('grant onSuccess 에서 sirsoft-gdpr.syncConsent 핸들러를 호출한다', () => { - // 회귀 가드: 다시 동의 시점에 functional 카테고리가 grant 되면 interceptor 의 - // functionalConsented 플래그를 즉시 true 로 갱신해야 새 storage/cookie 쓰기가 - // 통과됨. 누락 시 새로고침 전까지 차단 유지 (동의 즉시성 약화). + it('grant onSuccess 에서 refetch + syncConsent + 토스트 호출', () => { + // 회귀 가드: 꺼짐→켜짐 시 interceptor functionalConsented 즉시 갱신 (동의 즉시성). const text = serializeForSearch(section); - // 액션 컬럼 안의 grant 버튼이 syncConsent 호출하는지 검증. - // 섹션에는 revoke 버튼도 있어 toContain 중복 일치 가능 — match 회수로 정확히 검증. - // 액션 컬럼 sequence 가 모달을 열도록 setState 만 한 revoke 버튼과 달리, - // grant 버튼은 직접 apiCall 하므로 onSuccess 가 명시되어 있음. + expect(text).toContain('"dataSourceId": "gdprMeConsents"'); const matches = text.match(/"handler":\s*"sirsoft-gdpr\.syncConsent"/g) ?? []; expect(matches.length).toBeGreaterThanOrEqual(1); }); - it('행별 grant 버튼에 grantingKey 비교 패턴 spinner + "동의 중" 라벨 노출', () => { - // 회귀 가드: 행별 분기 — _local.grantingKey 가 해당 행의 consent_key 와 일치할 때만 - // spinner 노출. 다른 행 버튼은 disabled (전역 진행 중 표시). + it('꺼짐 토글에 grantingKey 비교 disabled — 처리 중 중복 클릭 차단', () => { const text = serializeForSearch(section); expect(text).toContain("_local.grantingKey === consent?.consent_key"); - expect(text).toContain('"name": "spinner"'); - expect(text).toContain('sirsoft-gdpr.consent.granting'); + }); + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_enable_all_bar_when_optional_off + */ + it('꺼진 선택 항목이 있을 때만 「모두 켜기」 바 노출 + POST /consent/cookie 로 일괄 동의', () => { + const text = serializeForSearch(section); + // 노출 조건: 선택형(is_required !== true) 중 꺼진(is_consented !== true) 항목 존재 + expect(text).toContain("c?.is_required !== true && c?.is_consented !== true"); + expect(text).toContain('mypage.privacy.enable_all'); + expect(text).toContain('mypage.privacy.enable_all_hint'); + // 일괄 동의는 배너 「모두 동의」와 동일한 consent/cookie 경로 (필수 제외, prefixed key) + expect(text).toContain('"target": "/api/plugins/sirsoft-gdpr/consent/cookie"'); + expect(text).toContain('c.consent_key'); + expect(text).toContain('mypage.privacy.enable_all_success'); + }); + + it('제거된 표 배지/버튼 구조가 부활하지 않는다 (회귀 가드)', () => { + // 재설계로 상태 배지(bg-amber-100)·철회 빨간 버튼(bg-red-600 버튼)·can_grant/can_revoke 분기가 + // 토글로 대체됨. can_grant/can_revoke 메타는 서버 Resource 에 남아있으나 레이아웃은 미참조. + const text = serializeForSearch(section); + expect(text).not.toContain('consent?.can_revoke === true'); + expect(text).not.toContain('consent?.can_grant === true'); + expect(text).not.toContain('mypage.privacy.rejected_label'); + expect(text).not.toContain('needs_renewal_this_item'); }); }); @@ -386,17 +419,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); }); - describe('액션 컬럼 — 「최신 정책으로 갱신」 분기 (#21)', () => { - const section = card ? findById(card, 'gdpr_section_consents') : null; - - it('needs_renewal_this_item === true 일 때 amber 톤 버튼 + 「최신 정책으로 갱신」 라벨', () => { - const text = serializeForSearch(section); - expect(text).toContain('consent?.needs_renewal_this_item === true'); - expect(text).toContain('mypage.privacy.btn_renew_this_item'); - expect(text).toContain('bg-amber-600'); - // 기존 분기도 유지 - expect(text).toContain('mypage.privacy.grant_again'); - expect(text).toContain('mypage.privacy.grant'); - }); - }); + // 「최신 정책으로 갱신」 항목별 amber 분기(#21)는 토글 재설계(#430)로 제거됨. + // 정책 갱신은 카드 상단 amber 박스(gdpr_needs_renewal_banner)의 「전체 항목 다시 동의」로 통합되고, + // 개별 항목은 단순 on/off 토글로 켜면 최신 정책 버전으로 grant 된다. (grant 는 항상 현재 정책 버전 기록) }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json index 95930133..ae46dac7 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json @@ -15,6 +15,7 @@ "consent": { "granted": "Consent has been saved.", "granted_again": "Consent has been renewed.", + "rejected_saved": "Continuing without consent. Optional items will not be used.", "revoked": "Consent has been revoked.", "renew_all_success": "{renewed} active consent item(s) renewed to the new policy version.", "granting": "Granting...", @@ -225,7 +226,8 @@ }, "action": { "granted": "Granted", - "revoked": "Revoked" + "revoked": "Revoked", + "rejected": "Rejected" }, "source": { "banner": "Banner", @@ -247,7 +249,7 @@ "categories_label": "Categories Snapshot", "categories_hint": "The member's category-by-category choices at the time of consent. This record stays immutable even after the policy is updated.", "snapshot_granted": "Granted", - "snapshot_revoked": "Revoked" + "snapshot_revoked": "Not Consented" } } }, @@ -265,9 +267,16 @@ "col_action": "Action", "consented": "Consented", "revoked": "Revoked", + "rejected_label": "Rejected", "revoke": "Revoke", "grant": "Consent", "grant_again": "Consent Again", + "always_on": "Always On", + "toggle_on": "On", + "toggle_off": "Off", + "enable_all": "Enable All", + "enable_all_hint": "You have optional items turned off. Enable them all at once.", + "enable_all_success": "All optional items have been enabled.", "required_label": "Required", "required_hint": "This item is required for service operation and cannot be revoked.", "revoke_confirm_title": "Revoke Consent", @@ -295,9 +304,9 @@ "title": "Cookie Notice", "description": "This site uses cookies to improve user experience and for analytics. You can choose which cookies to allow.", "accept_all": "Accept All", - "reject_all": "Necessary Only", - "preferences": "Preferences", - "save": "Save Preferences", + "reject_continue": "Continue without consent", + "preferences": "Select Consent", + "save": "Save Selection", "policy_link": "Privacy Policy", "preferences_title": "Cookie Preferences", "preferences_description": "You can grant or revoke each category individually. Required categories cannot be turned off.", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json index fcec964c..e31291fb 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json @@ -15,6 +15,7 @@ "consent": { "granted": "동의가 저장되었습니다.", "granted_again": "동의가 갱신되었습니다.", + "rejected_saved": "동의하지 않고 계속합니다. 선택 항목은 사용되지 않습니다.", "revoked": "동의가 철회되었습니다.", "renew_all_success": "활성 항목 {renewed} 개를 새 정책 버전으로 갱신했습니다.", "granting": "동의 중...", @@ -225,7 +226,8 @@ }, "action": { "granted": "동의", - "revoked": "철회" + "revoked": "철회", + "rejected": "거부" }, "source": { "banner": "배너", @@ -247,7 +249,7 @@ "categories_label": "카테고리 스냅샷", "categories_hint": "동의 시점에 회원이 카테고리별로 표시한 의사입니다. 정책 본문이 갱신되어도 이 기록은 불변으로 보존됩니다.", "snapshot_granted": "동의", - "snapshot_revoked": "거부" + "snapshot_revoked": "미동의" } } }, @@ -265,9 +267,16 @@ "col_action": "작업", "consented": "동의", "revoked": "철회", + "rejected_label": "거부", "revoke": "철회", "grant": "동의", "grant_again": "다시 동의", + "always_on": "항상 켜짐", + "toggle_on": "켜짐", + "toggle_off": "꺼짐", + "enable_all": "모두 켜기", + "enable_all_hint": "동의하지 않은 선택 항목이 있습니다. 한 번에 모두 켤 수 있습니다.", + "enable_all_success": "선택 항목을 모두 켰습니다.", "required_label": "필수", "required_hint": "서비스 제공에 필수적이라 철회할 수 없습니다.", "revoke_confirm_title": "동의 철회 확인", @@ -295,9 +304,9 @@ "title": "쿠키 사용 안내", "description": "본 사이트는 사용자 경험 개선과 분석을 위해 쿠키를 사용합니다. 항목별로 동의 여부를 선택할 수 있습니다.", "accept_all": "모두 동의", - "reject_all": "필수만 사용", - "preferences": "환경설정", - "save": "선택 동의", + "reject_continue": "동의하지 않고 계속하기", + "preferences": "선택 동의", + "save": "선택 저장", "policy_link": "개인정보처리방침", "preferences_title": "쿠키 환경설정", "preferences_description": "카테고리별 쿠키 사용을 개별 동의\/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json index 99096059..ba88ddf5 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json @@ -215,7 +215,7 @@ "name": "Icon", "props": { "name": "fa-solid fa-arrows-rotate", - "className": "w-5 h-5" + "className": "text-xl" } } ] @@ -769,6 +769,53 @@ "text": "$t:sirsoft-gdpr.admin.consent_log.action.revoked" } ] + }, + { + "comment": "거부 필터 (이슈 #430) — action=rejected 행만 조회. 백엔드 actions[] whereIn 이 rejected 를 자동 지원하므로 백엔드 무수정.", + "type": "basic", + "name": "Label", + "props": { + "className": "inline-clickable" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "checkbox", + "className": "checkbox", + "checked": "{{(_local.filter.actions || []).includes('rejected')}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "filter.actions": "{{$event.target.checked ? [...(_local.filter.actions || []).filter(a => a !== 'rejected'), 'rejected'] : (_local.filter.actions || []).filter(a => a !== 'rejected')}}" + } + }, + { + "actionRef": "searchConsentLogs" + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label" + }, + "text": "$t:sirsoft-gdpr.admin.consent_log.action.rejected" + } + ] } ] } @@ -1257,7 +1304,7 @@ "type": "basic", "name": "Span", "props": { - "className": "{{row.action === 'granted' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : (row.action === 'revoked' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300')}}" + "className": "{{row.action === 'granted' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : (row.action === 'rejected' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : (row.action === 'revoked' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'))}}" }, "text": "{{$t('sirsoft-gdpr.admin.consent_log.action.' + (row.action ?? 'granted'))}}" } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php new file mode 100644 index 00000000..921251ff --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentAction.php @@ -0,0 +1,49 @@ + + */ + public static function allValues(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php index 69435b3f..d73c39be 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php @@ -42,6 +42,8 @@ class GdprCookieConsentController extends PublicBaseController { $consents = (array) $request->validated('consents'); $source = (string) $request->validated('source'); + // 명시적 거부 신호 (이슈 #430) — 'reject' 이면 선택형 미동의 항목을 is_rejected=true 로 저장. + $isRejection = $request->validated('intent') === 'reject'; $userId = $request->user()?->id; $sessionId = null; @@ -54,9 +56,15 @@ class GdprCookieConsentController extends PublicBaseController } } - $this->consentService->updateConsents($userId, $sessionId, $consents, $source); + $this->consentService->updateConsents($userId, $sessionId, $consents, $source, $isRejection); - $response = ResponseHelper::success('sirsoft-gdpr::messages.consent.granted', [ + // 거부(intent=reject)와 일반 동의 저장의 응답 메시지를 구분 (이슈 #430) — + // 거부인데 "동의가 저장되었습니다" 로 응답하던 오표기 정정. 프론트 토스트도 동일 키 사용. + $messageKey = $isRejection + ? 'sirsoft-gdpr::messages.consent.rejected_saved' + : 'sirsoft-gdpr::messages.consent.granted'; + + $response = ResponseHelper::success($messageKey, [ 'user_id' => $userId, 'session_id' => $sessionId, 'consents' => $consents, diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php index ac2f1f46..8846f945 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Requests/StoreCookieConsentRequest.php @@ -46,6 +46,9 @@ class StoreCookieConsentRequest extends FormRequest 'consents' => ['required', 'array', 'min:1'], 'consents.*' => ['required', 'boolean'], 'source' => ['required', 'string', 'in:banner,preference_center,register,mypage'], + // 명시적 거부 신호 (이슈 #430). 배너 "동의하지 않고 계속하기" 버튼이 전송. + // 미전송(null) 시 일반 저장(기존 동작). 값은 'reject' 만 허용. + 'intent' => ['sometimes', 'nullable', 'string', 'in:reject'], ]; } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php index a1ebc4fe..05b51579 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php @@ -47,6 +47,9 @@ class GdprUserConsentResource extends BaseApiResource 'consent_category' => $this->consent_category, 'is_required' => $isRequired, 'is_consented' => $isConsented, + // 이슈 #430 — 명시적 거부 상태. 마이페이지 배지 3분화(동의/철회/거부) 에 사용. + 'is_rejected' => (bool) $this->is_rejected, + 'rejected_at' => $this->formatDateTimeStringForUser($this->rejected_at), // Art.7(3) 대칭성 + ePrivacy Art.5(3) 동의 면제 패턴 — 필수 카테고리는 철회/재동의 불가, 선택형만 양방향. 'can_revoke' => ! $isRequired && $isConsented, // 활성 동의 + 옛 버전 → 갱신 의도. 활성 동의 + 최신 버전 → 갱신 불요 (이미 최신). diff --git a/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php b/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php index 0f97b49c..fcfc3c94 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Models/GdprUserConsent.php @@ -18,8 +18,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string $consent_key * @property string|null $consent_category * @property bool $is_consented + * @property bool $is_rejected * @property \Carbon\Carbon|null $consented_at * @property \Carbon\Carbon|null $revoked_at + * @property \Carbon\Carbon|null $rejected_at * @property int $consent_count * @property string|null $policy_version * @property string|null $last_source @@ -46,8 +48,10 @@ class GdprUserConsent extends Model 'consent_key', 'consent_category', 'is_consented', + 'is_rejected', 'consented_at', 'revoked_at', + 'rejected_at', 'consent_count', 'policy_version', 'last_source', @@ -62,8 +66,10 @@ class GdprUserConsent extends Model { return [ 'is_consented' => 'boolean', + 'is_rejected' => 'boolean', 'consented_at' => 'datetime', 'revoked_at' => 'datetime', + 'rejected_at' => 'datetime', 'consent_count' => 'integer', ]; } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php index f8c133a6..1af4bb23 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php @@ -64,9 +64,11 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface $row->consent_key = $consentKey; $row->consent_category = $consentCategory; $row->is_consented = false; + $row->is_rejected = false; $row->consent_count = 0; $row->consented_at = null; $row->revoked_at = null; + $row->rejected_at = null; $row->policy_version = null; $row->last_source = null; diff --git a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php index 038f11c3..714c81e7 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php @@ -6,6 +6,7 @@ use App\Extension\HookManager; use App\Services\PluginSettingsService; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Plugins\Sirsoft\Gdpr\Enums\ConsentAction; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsent; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsentHistory; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface; @@ -73,6 +74,7 @@ class GdprConsentService * @param bool $value 동의 여부 * @param string $source 변경 경로 (banner/preference_center/register/mypage/order/withdraw) * @param array|null $categories 카테고리 스냅샷 (배너 일괄 변경 시) + * @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장. * @return void */ public function updateConsent( @@ -82,29 +84,49 @@ class GdprConsentService bool $value, string $source, ?array $categories = null, + bool $isRejection = false, ): void { $policyVersion = $this->getCurrentPolicyVersion(); $now = now(); + // 항목별 거부 여부 — 값이 false 이면서 거부 신호가 온 경우만 거부. + // 필수 항목은 value=true 로 전달되므로 자동으로 거부 대상에서 제외된다. + $newRejected = $isRejection && ! $value; + $consent = null; - if ($userId !== null) { + // 필수 카테고리는 "동의 축"에서 제외한다 (이슈 #430 후속 — 업계 표준 정합). + // 필수 쿠키는 ePrivacy Art.5(3) 면제로 동의 대상이 아니므로, "현재 상태"(status) 테이블에 + // 동의/거부로 저장하지 않는다. 저장하면 "동의하지 않았는데 동의로 표시"되는 모순이 생긴다. + // 단, history(이력)에는 "이 시점에 배너에서 결정을 내렸다"는 사실을 남긴다 (GDPR Art.7(1) 입증). + // 배너 표시 조건(hasCurrentCookieConsent)은 선택형 status 행만 있어도 충족되고, + // 자동 차단 엔진(blocker)은 필수를 status 무관하게 항상 허용하므로 필수 status 부재가 안전하다. + $isRequiredCategory = $this->categoryService->isRequired($consentKey); + + if ($userId !== null && ! $isRequiredCategory) { $existing = $this->statusRepository->findByUserAndKey($userId, $consentKey); // 동일 상태 중복 처리 방지 (이력 중복 INSERT 방지). // policy_version 동일 조건 추가 — 정책 bump 후 같은 값 재요청은 신정책 적용 // (status.policy_version 갱신 + history INSERT) 으로 이어져야 needs_renewal 이 해소되고 // GDPR Art.7(1) 입증 책임 (신정책 동의 기록) 도 충족됨. + // is_rejected 변화 조건 추가 (이슈 #430) — 미동의(false) 상태에서 거부 신호가 오면 + // is_consented 는 그대로 false 라 기존 가드가 early-return 하여 is_rejected 저장이 누락됐다. + // 거부(rejected)와 일반 미동의(revoked)를 데이터로 구분하려면 이 조건이 필수. if ($existing && (bool) $existing->is_consented === $value + && (bool) $existing->is_rejected === $newRejected && (string) $existing->policy_version === $policyVersion) { return; } $data = [ 'is_consented' => $value, + 'is_rejected' => $newRejected, 'consented_at' => $value ? $now : ($existing?->consented_at), 'revoked_at' => $value ? null : $now, + // 거부 시각은 거부일 때만 갱신, 재동의(value=true) 시 null 로 해제. + 'rejected_at' => $newRejected ? $now : null, 'policy_version' => $policyVersion, 'last_source' => $source, 'consent_category' => $this->resolveCategory($consentKey), @@ -120,23 +142,28 @@ class GdprConsentService $consent = $this->statusRepository->upsert($userId, $consentKey, $data); } - // 호출자가 매트릭스를 명시 전달하지 않은 경우 (마이페이지 grant/revoke 등 단건 변경), - // 변경 직후 시점의 회원 동의 매트릭스를 자동 구성하여 history 에 보존합니다. - // GDPR Art.7(1) 입증 책임 — 모든 동의 변경 시점에 카테고리 전체 의사를 immutable 기록. - $snapshotCategories = $categories ?? ($userId !== null ? $this->buildCategoriesSnapshotForUser($userId) : null); + // 필수 카테고리는 동의 축에서 제외되므로 history(동의 이력)에도 기록하지 않는다. + // 필수는 "동의/거부"라는 의사표시 대상이 아니라 항상 적용되는 항목이므로, + // "동의 부여/철회/거부" 이력 자체가 성립하지 않는다. 관리자 동의이력에도 필수 행은 남지 않는다. + if (! $isRequiredCategory) { + // 호출자가 매트릭스를 명시 전달하지 않은 경우 (마이페이지 grant/revoke 등 단건 변경), + // 변경 직후 시점의 회원 동의 매트릭스를 자동 구성하여 history 에 보존합니다. + // GDPR Art.7(1) 입증 책임 — 모든 동의 변경 시점에 카테고리 전체 의사를 immutable 기록. + $snapshotCategories = $categories ?? ($userId !== null ? $this->buildCategoriesSnapshotForUser($userId) : null); - // 회원·게스트 모두 history INSERT (불변 append-only) - $this->historyRepository->record([ - 'user_id' => $userId, - 'session_id' => $sessionId, - 'consent_key' => $consentKey, - 'action' => $value ? 'granted' : 'revoked', - 'source' => $source, - 'policy_version' => $policyVersion, - 'categories' => $snapshotCategories, - 'ip_address' => request()->ip(), - 'user_agent' => substr((string) request()->userAgent(), 0, 500), - ]); + // 회원·게스트 모두 history INSERT (불변 append-only) + $this->historyRepository->record([ + 'user_id' => $userId, + 'session_id' => $sessionId, + 'consent_key' => $consentKey, + 'action' => ConsentAction::fromDecision($value, $isRejection)->value, + 'source' => $source, + 'policy_version' => $policyVersion, + 'categories' => $snapshotCategories, + 'ip_address' => request()->ip(), + 'user_agent' => substr((string) request()->userAgent(), 0, 500), + ]); + } $hookName = $value ? self::PLUGIN_ID . '.consent.granted' @@ -151,14 +178,15 @@ class GdprConsentService * @param string|null $sessionId 게스트 세션 ID * @param array $consents 동의 데이터 [key => bool] * @param string $source 변경 경로 + * @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장. * @return void */ - public function updateConsents(?int $userId, ?string $sessionId, array $consents, string $source): void + public function updateConsents(?int $userId, ?string $sessionId, array $consents, string $source, bool $isRejection = false): void { $categories = $consents; foreach ($consents as $consentKey => $value) { - $this->updateConsent($userId, $sessionId, $consentKey, (bool) $value, $source, $categories); + $this->updateConsent($userId, $sessionId, $consentKey, (bool) $value, $source, $categories, $isRejection); } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php index 3e814841..6d973e3a 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php @@ -456,4 +456,147 @@ class GdprCookieConsentControllerTest extends PluginTestCase $this->assertNotContains($name, $cookieNames, "응답에 functional cookie ({$name}) 가 발송되면 안 됨 — Phase 2 단순화"); } } + + // ===== 이슈 #430 — intent:'reject' 거부 저장 (컨트롤러 경로) ===== + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects member_rejection_intent_marks_optional_as_rejected, required_category_excluded_from_status, reject_controller_response_uses_rejected_saved_message + */ + public function test_member_rejection_intent_marks_optional_as_rejected(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + 'cookie_marketing' => false, + ], + 'source' => 'banner', + 'intent' => 'reject', + ])->assertOk(); + + // 거부 응답 메시지 = rejected_saved (거부인데 "동의가 저장" 오표기 정정, 이슈 #430) + $response->assertJsonPath('message', __('sirsoft-gdpr::messages.consent.rejected_saved')); + + // 필수 → 동의 축 제외: status 에 저장되지 않음 (이슈 #430 후속) + $this->assertDatabaseMissing('gdpr_user_consents', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_necessary', + ]); + + // 선택형 → is_rejected=true, history action=rejected + $this->assertDatabaseHas('gdpr_user_consents', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'is_consented' => false, + 'is_rejected' => true, + ]); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'rejected', + 'source' => 'banner', + ]); + } + + /** + * @scenario entry=reject, subject=guest, category=optional + * @effects guest_rejection_intent_records_history_as_rejected + */ + public function test_guest_rejection_intent_records_history_as_rejected(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + + $this->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_marketing' => false, + ], + 'source' => 'banner', + 'intent' => 'reject', + ])->assertOk(); + + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'consent_key' => 'cookie_marketing', + 'action' => 'rejected', + 'source' => 'banner', + ]); + } + + /** + * @scenario entry=reject, subject=guest, category=required + * @effects rejection_intent_invalid_value_is_rejected_by_validation + */ + public function test_rejection_intent_invalid_value_is_rejected_by_validation(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + + // intent 는 'reject' 만 허용 — 다른 값은 422 + $this->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => ['cookie_necessary' => true], + 'source' => 'banner', + 'intent' => 'accept', + ])->assertStatus(422); + } + + /** + * @scenario entry=save_selection, subject=member, category=optional + * @effects without_intent_regression_stores_revoked_not_rejected + */ + public function test_without_intent_regression_stores_revoked_not_rejected(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + $user = User::factory()->create(); + + // intent 미전달 (기존 배너 동작) — 미동의는 revoked 로 기록, is_rejected=false + $this->actingAs($user)->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + ], + 'source' => 'banner', + ])->assertOk(); + + $this->assertDatabaseHas('gdpr_user_consents', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'is_consented' => false, + 'is_rejected' => false, + ]); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'revoked', + ]); + } + + /** + * 거부 후 배너 미재출현 고정 — 거부해도 현재 정책 버전으로 동의(필수) 이력이 남아 + * has_consented=true 가 되어 배너가 다시 뜨지 않는다 (이슈 요구: 쿠키 배너 띄우지 않음). + * + * @scenario entry=reject, subject=member, category=required + * @effects rejection_makes_banner_not_reappear_for_member + */ + public function test_rejection_makes_banner_not_reappear_for_member(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + $user = User::factory()->create(); + + $this->actingAs($user)->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + 'cookie_marketing' => false, + ], + 'source' => 'banner', + 'intent' => 'reject', + ])->assertOk(); + + $status = $this->actingAs($user)->getJson('/api/plugins/sirsoft-gdpr/consent/cookie/status'); + $status->assertOk(); + $status->assertJsonPath('data.has_consented', true); + } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php new file mode 100644 index 00000000..a6f12c2c --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php @@ -0,0 +1,90 @@ +assertTrue( + Schema::hasColumn('gdpr_user_consents', $column), + "gdpr_user_consents.{$column} column should exist" + ); + } + } + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects is_rejected_defaults_to_false + */ + public function test_is_rejected_defaults_to_false(): void + { + $user = \App\Models\User::factory()->create(); + + $consent = \Plugins\Sirsoft\Gdpr\Models\GdprUserConsent::create([ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'consent_category' => 'cookie', + 'is_consented' => false, + ]); + + $this->assertFalse($consent->fresh()->is_rejected, 'is_rejected 기본값은 false 여야 함'); + $this->assertNull($consent->fresh()->rejected_at, 'rejected_at 기본값은 null 이어야 함'); + } + + /** + * @scenario entry=reject, subject=member, category=optional + * @effects rejection_migration_is_reversible + */ + public function test_rejection_migration_is_reversible(): void + { + $migration = require base_path( + 'plugins/_bundled/sirsoft-gdpr/database/migrations/2026_07_14_000001_add_rejection_to_gdpr_user_consents.php' + ); + + // down: 컬럼 제거 + $migration->down(); + + $this->assertFalse( + Schema::hasColumn('gdpr_user_consents', 'is_rejected'), + 'down 후 is_rejected 컬럼이 제거되어야 함' + ); + $this->assertFalse( + Schema::hasColumn('gdpr_user_consents', 'rejected_at'), + 'down 후 rejected_at 컬럼이 제거되어야 함' + ); + + // up: 컬럼 재추가 (왕복 안전성 — hasColumn 가드로 중복 추가 방지) + $migration->up(); + + $this->assertTrue( + Schema::hasColumn('gdpr_user_consents', 'is_rejected'), + 'up 재실행 후 is_rejected 컬럼이 복원되어야 함' + ); + $this->assertTrue( + Schema::hasColumn('gdpr_user_consents', 'rejected_at'), + 'up 재실행 후 rejected_at 컬럼이 복원되어야 함' + ); + + // up 재호출 시 hasColumn 가드로 중복 추가 예외가 발생하지 않아야 함 (idempotent) + $migration->up(); + $this->assertTrue(Schema::hasColumn('gdpr_user_consents', 'is_rejected')); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php new file mode 100644 index 00000000..8bdbc034 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php @@ -0,0 +1,44 @@ +assertSame( + ['granted', 'revoked', 'rejected'], + ConsentAction::allValues() + ); + } + + /** + * @scenario entry=accept_all, subject=member, category=required + * @effects consent_action_from_decision_granted_revoked_rejected + */ + public function test_from_decision_returns_granted_when_value_true(): void + { + // 동의 시 거부 신호와 무관하게 항상 granted (필수 항목 등) + $this->assertSame(ConsentAction::Granted, ConsentAction::fromDecision(true, false)); + $this->assertSame(ConsentAction::Granted, ConsentAction::fromDecision(true, true)); + } + + public function test_from_decision_returns_rejected_when_value_false_and_rejection(): void + { + $this->assertSame(ConsentAction::Rejected, ConsentAction::fromDecision(false, true)); + } + + public function test_from_decision_returns_revoked_when_value_false_without_rejection(): void + { + // 명시적 거부 신호가 없는 미동의는 철회로 취급 (기존 동작 유지) + $this->assertSame(ConsentAction::Revoked, ConsentAction::fromDecision(false, false)); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php index 046a2767..8647ea75 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php @@ -202,6 +202,10 @@ class GdprConsentServiceTest extends PluginTestCase /** * v1.5.0 자동 차단 엔진 SSoT 회귀 가드: * 회원의 카테고리별 동의 상태가 cookie_ 접두사 제거된 카테고리 키로 매핑되어야 함. + * + * 이슈 #430 후속 — 필수 카테고리는 동의 축에서 제외되어 status 에 저장되지 않으므로 + * 이 맵에도 등장하지 않는다. 자동 차단 엔진(blocker.ts)은 necessary 를 맵 유무와 + * 무관하게 항상 허용하므로(category==='necessary' → true) 필수 부재가 안전하다. */ public function test_get_current_cookie_consents_returns_category_map_for_member(): void { @@ -214,11 +218,13 @@ class GdprConsentServiceTest extends PluginTestCase $consents = $this->service->getCurrentCookieConsents($user->id, null); + // 필수(necessary)는 status 미저장 → 맵에서 제외. 선택형만 등장. $this->assertEqualsCanonicalizing( - ['necessary' => true, 'analytics' => true, 'marketing' => false], + ['analytics' => true, 'marketing' => false], $consents ); - $this->assertCount(3, $consents); + $this->assertCount(2, $consents); + $this->assertArrayNotHasKey('necessary', $consents); } /** @@ -331,4 +337,216 @@ class GdprConsentServiceTest extends PluginTestCase $this->assertSame(1, GdprUserConsentHistory::where('user_id', $user->id)->count()); $this->assertSame(1, GdprUserConsent::where('user_id', $user->id)->first()->consent_count); } + + // ===== 이슈 #430 — 쿠키 거부 저장 ===== + + /** + * 선택형 카테고리 거부 시 is_rejected=true, rejected_at 기록, history action=rejected. + * + * STEP3-3 중복방지 가드 결함 회귀 가드: 가드가 is_consented+policy_version 만 비교하면 + * 거부(false)와 일반 미동의(false)를 동일 취급 → is_rejected=true 저장이 누락된다. + * + * @scenario entry=reject, subject=member, category=optional + * @effects rejection_marks_optional_consent_as_rejected + */ + public function test_rejection_marks_optional_consent_as_rejected(): void + { + $user = User::factory()->create(); + + $this->service->updateConsent( + $user->id, null, 'cookie_analytics', false, 'banner', null, true + ); + + $consent = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertNotNull($consent); + $this->assertFalse($consent->is_consented); + $this->assertTrue((bool) $consent->is_rejected, '선택형 거부는 is_rejected=true 여야 함'); + $this->assertNotNull($consent->rejected_at, 'rejected_at 이 기록되어야 함'); + + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'rejected', + 'source' => 'banner', + ]); + } + + /** + * 거부 가드 결함 직접 재현: 이미 status row 가 미동의(false)로 존재해도 + * 거부 신호가 오면 is_rejected 플래그가 저장되어야 한다. + * + * @scenario entry=reject, subject=member, category=optional + * @effects rejection_persists_even_when_status_already_false + */ + public function test_rejection_persists_even_when_status_already_false(): void + { + $user = User::factory()->create(); + + // 1) 먼저 철회로 미동의(false) 상태를 만든다 (is_rejected=false). + $this->service->updateConsent($user->id, null, 'cookie_analytics', true, 'banner'); + $this->service->updateConsent($user->id, null, 'cookie_analytics', false, 'mypage'); + + $before = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertFalse((bool) $before->is_rejected, '사전조건: 아직 거부 아님'); + + // 2) 이제 명시적 거부 신호. is_consented 는 여전히 false 지만 is_rejected 는 true 로 바뀌어야 함. + $this->service->updateConsent( + $user->id, null, 'cookie_analytics', false, 'banner', null, true + ); + + $after = GdprUserConsent::where('user_id', $user->id)->first()->fresh(); + $this->assertTrue((bool) $after->is_rejected, '가드 결함: is_rejected 저장 누락 (가드에 is_rejected 비교 필요)'); + $this->assertNotNull($after->rejected_at); + } + + /** + * 필수 카테고리는 동의 축에서 제외 — status·history 모두 저장하지 않는다 (이슈 #430 후속). + * + * 필수 쿠키는 ePrivacy Art.5(3) 면제로 동의 대상이 아니다. 거부·동의 어떤 신호가 와도 + * "현재 상태"(status)나 "동의 이력"(history)에 기록하지 않는다. 필수를 저장하면 + * "동의하지 않았는데 동의로 표시"되는 모순이 생기기 때문이다. + * + * @scenario entry=reject, subject=member, category=required + * @effects required_category_excluded_from_status + */ + public function test_required_category_is_excluded_from_status_and_history(): void + { + $user = User::factory()->create(); + + // 필수에 대해 거부 신호를 포함해 호출해도 필수는 저장되지 않아야 함. + $this->service->updateConsent( + $user->id, null, 'cookie_necessary', true, 'banner', null, true + ); + + // status 미저장 + $this->assertNull( + GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_necessary')->first(), + '필수는 status 에 저장되지 않아야 함' + ); + + // history 미기록 + $this->assertDatabaseMissing('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_necessary', + ]); + } + + /** + * 거부 후 재동의 시 is_rejected 플래그 자동 해제 + rejected_at null. + * history 는 append-only 라 rejected/granted 둘 다 보존. + * + * @scenario entry=reject_then_reaccept, subject=member, category=optional + * @effects re_consent_clears_rejection_flag_but_keeps_history + */ + public function test_re_consent_clears_rejection_flag_but_keeps_history(): void + { + $user = User::factory()->create(); + + // 1) 거부 + $this->service->updateConsent( + $user->id, null, 'cookie_analytics', false, 'banner', null, true + ); + // 2) 재동의 + $this->service->updateConsent($user->id, null, 'cookie_analytics', true, 'banner'); + + $consent = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertTrue($consent->is_consented); + $this->assertFalse((bool) $consent->is_rejected, '재동의 시 거부 플래그 자동 해제'); + $this->assertNull($consent->rejected_at, '재동의 시 rejected_at null 로 해제'); + + // history 는 rejected + granted 둘 다 보존 + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'rejected', + ]); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'consent_key' => 'cookie_analytics', + 'action' => 'granted', + ]); + } + + /** + * 게스트 거부는 status 미사용, history 에만 action=rejected 로 기록. + * + * @scenario entry=reject, subject=guest, category=optional + * @effects guest_rejection_records_history_only + */ + public function test_guest_rejection_records_history_only(): void + { + $sessionId = 'guest-reject-xyz'; + + $this->service->updateConsent( + null, $sessionId, 'cookie_marketing', false, 'banner', null, true + ); + + $this->assertDatabaseCount('gdpr_user_consents', 0); + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'session_id' => $sessionId, + 'consent_key' => 'cookie_marketing', + 'action' => 'rejected', + ]); + } + + /** + * intent 미전달(기본값 false) 시 기존 동작 회귀 무영향 — 미동의는 revoked 로 기록. + * + * @scenario entry=save_selection, subject=member, category=optional + * @effects without_rejection_intent_false_is_revoked_not_rejected + */ + public function test_without_rejection_intent_false_is_revoked_not_rejected(): void + { + $user = User::factory()->create(); + + $this->service->updateConsent($user->id, null, 'cookie_analytics', false, 'mypage'); + + $consent = GdprUserConsent::where('user_id', $user->id)->first(); + $this->assertFalse((bool) $consent->is_rejected, '거부 신호 없으면 is_rejected=false'); + + $this->assertDatabaseHas('gdpr_user_consent_histories', [ + 'user_id' => $user->id, + 'action' => 'revoked', + ]); + } + + /** + * updateConsents 일괄 경로에도 거부 신호가 전달되어 선택형만 rejected 로 저장. + * (배너 "동의하지 않고 계속하기" 실제 호출 경로) + * + * 이슈 #430 후속 — 필수는 동의 축에서 제외되어 status 에 저장되지 않는다. + * + * @scenario entry=reject, subject=member, category=optional + * @effects update_consents_batch_applies_rejection_to_optional_only, required_category_excluded_from_status + */ + public function test_update_consents_batch_applies_rejection_to_optional_only(): void + { + $user = User::factory()->create(); + + // 배너 거부: 필수=true, 선택형=false + isRejection=true + $this->service->updateConsents( + $user->id, + null, + [ + 'cookie_necessary' => true, + 'cookie_analytics' => false, + 'cookie_marketing' => false, + ], + 'banner', + true + ); + + $necessary = GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_necessary')->first(); + $analytics = GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_analytics')->first(); + $marketing = GdprUserConsent::where('user_id', $user->id)->where('consent_key', 'cookie_marketing')->first(); + + // 필수는 동의 축에서 제외 → status 행 자체가 생성되지 않음 + $this->assertNull($necessary, '필수는 status 에 저장되지 않아야 함 (동의 축 제외)'); + + // 선택형만 거부(is_rejected=true) 로 저장 + $this->assertFalse($analytics->is_consented); + $this->assertTrue((bool) $analytics->is_rejected, '선택형은 거부'); + $this->assertFalse($marketing->is_consented); + $this->assertTrue((bool) $marketing->is_rejected, '선택형은 거부'); + } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml b/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml new file mode 100644 index 00000000..099238ec --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/scenarios/cookie-rejection.yaml @@ -0,0 +1,102 @@ +feature: GDPR 쿠키 거부 (명시적 reject) 저장 및 표기 + +description: | + 이슈 #430 — 쿠키 배너에서 "동의하지 않고 계속하기"(거부) 를 명시적 상태로 저장하고 + 마이페이지·관리자 화면에 표기한다. + + 기존 시스템은 거부·철회·미동의를 모두 is_consented=false 로만 저장하여 구분이 불가능했다. + is_rejected 컬럼 + history action='rejected' 를 도입하여 "명시적 거부" 를 데이터로 분리한다. + + 저장 규칙: + - 선택형(기능/분석/마케팅) 거부 → is_consented=false, is_rejected=true, rejected_at=now, history action='rejected' + - 필수(necessary) → 거부 신호와 무관하게 is_consented=true, is_rejected=false, history action='granted' + (Request 가 필수 false 를 차단하므로 항상 value=true 로 전달됨) + - 재동의(모두 동의/선택 동의) → is_consented=true 로 덮어쓰고 is_rejected=false, rejected_at=null 자동 해제 + - history 는 append-only 라 rejected/granted 이력을 둘 다 보존 (GDPR Art.7(1) 입증 책임) + + 거부 신호 전달: POST /api/plugins/sirsoft-gdpr/consent/cookie body 에 intent:'reject'. + 미전달(null) 시 기존 동작(미동의는 revoked) 유지. + +axes: + entry: [reject, accept_all, save_selection, reject_then_reaccept] + subject: [member, guest] + category: [required, optional] + +exclusions: + # 재동의(reject_then_reaccept) — 게스트는 status 미사용(history-only)이라 플래그 해제 개념 부재 + - { entry: reject_then_reaccept, subject: guest, category: optional, reason: "게스트는 status 미사용 — 재동의 플래그 해제 대상 아님 (history append 만)" } + - { entry: reject_then_reaccept, subject: guest, category: required, reason: "게스트 status 미사용 + 필수는 재동의 개념 부재" } + - { entry: reject_then_reaccept, subject: member, category: required, reason: "필수는 ePrivacy Art.5(3) 면제 — 항상 granted, 거부→재동의 전이 자체가 발생하지 않음" } + + # 모두 동의(accept_all) — 거부 신호 없음(intent 미전달). is_rejected 항상 false, 이 기능(거부)의 범위 밖. + # granted 판정은 Enum 테스트(consent_action_from_decision_granted_revoked_rejected)가 커버. + - { entry: accept_all, subject: member, category: optional, reason: "모두 동의는 거부 신호 없음 — is_rejected=false 기존 동작, 거부 기능 범위 밖 (granted 는 Enum 테스트 커버)" } + - { entry: accept_all, subject: member, category: required, reason: "모두 동의 + 필수 → 항상 granted, 거부 무관" } + - { entry: accept_all, subject: guest, category: optional, reason: "게스트 모두 동의 — 거부 신호 없음, 거부 기능 범위 밖" } + - { entry: accept_all, subject: guest, category: required, reason: "게스트 모두 동의 + 필수 → granted, 거부 무관" } + + # 선택 저장(save_selection) — 필수는 저장 경로와 무관하게 항상 granted + - { entry: save_selection, subject: member, category: required, reason: "필수는 저장 경로 무관 항상 granted — 선택 저장 축은 optional 조합만 유효" } + - { entry: save_selection, subject: guest, category: required, reason: "게스트 + 필수 → granted, 저장 경로 무관" } + - { entry: save_selection, subject: guest, category: optional, reason: "게스트 선택 저장은 history-only — 회원 save_selection(without_intent 회귀 테스트)이 대표 커버, 게스트는 guest_rejection 이 history 경로 커버" } + +effects: + # 거부 저장 (Service / Controller) + - rejection_marks_optional_consent_as_rejected # 선택형 거부 → is_rejected=true + rejected_at + history rejected + - rejection_persists_even_when_status_already_false # 가드 결함 회귀: 이미 false 여도 is_rejected 저장 (핵심 수정) + - required_category_excluded_from_status # 필수는 status/history 양쪽에서 제외 (재설계 #430 — 동의 축 아님) + - guest_rejection_records_history_only # 게스트 거부는 status 미사용, history action=rejected + - member_rejection_intent_marks_optional_as_rejected # 컨트롤러 경로(회원): intent:'reject' → 선택형 rejected + - guest_rejection_intent_records_history_as_rejected # 컨트롤러 경로(게스트): history rejected + - reject_controller_response_uses_rejected_saved_message # 컨트롤러 거부 응답 메시지 = rejected_saved (동의 저장 오표기 정정) + + # 재동의 플래그 해제 (이력 보존) + - re_consent_clears_rejection_flag_but_keeps_history # 재동의 시 is_rejected=false + rejected_at=null, 이력은 rejected/granted 둘 다 + + # 회귀 무영향 (intent 미전달) + - without_rejection_intent_false_is_revoked_not_rejected # 신호 없으면 미동의는 revoked (기존 동작) + - without_intent_regression_stores_revoked_not_rejected # 컨트롤러 경로 회귀 가드 + - rejection_intent_invalid_value_is_rejected_by_validation # intent 는 'reject' 만 허용 (그 외 422) + + # 일괄 경로 (배너 실제 호출) + - update_consents_batch_applies_rejection_to_optional_only # updateConsents 일괄: 필수 granted + 선택형 rejected + + # 배너 미재출현 (이슈 요구: 쿠키 배너 띄우지 않음) + - rejection_makes_banner_not_reappear_for_member # 거부 후 has_consented=true → 배너 재출현 안 함 + + # DB 컬럼 (마이그레이션) + - rejection_columns_exist # is_rejected / rejected_at 컬럼 존재 + - is_rejected_defaults_to_false # 기본값 false / rejected_at null + - rejection_migration_is_reversible # up→down→up 왕복 안전 + + # Enum 판정 + - consent_action_from_decision_granted_revoked_rejected # value/isRejection → granted/revoked/rejected 3분기 + + # 화면 표기 (레이아웃 렌더링) + - mypage_required_shows_locked_always_on_toggle # 마이페이지 필수 = 잠긴 '항상 켜짐' 토글 (액션/동의일 없음) + - mypage_optional_shows_on_off_toggle # 마이페이지 선택 = on/off 토글 (켜짐 녹색→철회, 꺼짐 회색→동의) + - mypage_no_consent_date_column_for_user # 사용자 화면에 동의일 컬럼 미노출 (감사/관리자 전용) + - mypage_enable_all_bar_when_optional_off # 꺼진 선택 항목 있을 때 '모두 켜기' 바 노출 + - admin_log_action_badge_rejected_amber # 관리자 동의이력 rejected 배지(amber) + - admin_log_filter_rejected_checkbox # 관리자 [거부] 필터 체크박스 + - banner_reject_button_sends_intent_reject # 배너 거부 버튼이 intent:'reject' 전송 + - banner_reject_toast_uses_rejected_saved # 배너 거부 성공 토스트 = rejected_saved (동의 저장 오표기 정정) + - banner_button_order_reject_accept_preferences # 버튼 순서: 거부 > 모두 동의 > 선택 동의 토글 + +test_files: + # PHPUnit (백엔드) + - plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprUserConsentRejectionMigrationSmokeTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Unit/Enums/ConsentActionTest.php + + # Vitest (프론트엔드 레이아웃 렌더링) + - plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx + - plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx + - plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx + +limitations: + - "마이페이지는 회원 전용(consent/me auth_required) 이라 게스트 거부는 화면 표기 없음 — 이슈 요구와 일치 (게스트 거부는 history 에만 기록)." + - "거부는 선택형(non-required) 카테고리에만 의미가 있다. 필수 카테고리는 ePrivacy Art.5(3) 면제로 거부 대상 아님." + - "재동의 플래그 해제는 회원 status 에만 적용. 게스트는 status 미사용이라 history append 만 이뤄진다." + - "admin 동의이력 [거부] 필터는 백엔드 actions[] whereIn 을 그대로 사용 — rejected 값이 화이트리스트 검증 없이 통과(백엔드 무수정)."