feat(gdpr): 쿠키 거부 기능 + 마이페이지 동의 관리 토글 재설계
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/모두켜기 + 배너 거부 토스트 + 거부 직후 마이페이지 표기 확인
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('gdpr_user_consents')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('gdpr_user_consents', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('gdpr_user_consents', 'is_rejected')) {
|
||||
$table->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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 관리자 감사 화면에서 `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까지 노출되는 조회 전용 엔드포인트입니다.
|
||||
|
||||
|
||||
|
||||
@@ -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`는 마이페이지에서 「거부」 배지로 표시됩니다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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' => '카테고리별 쿠키 사용을 개별 동의/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plugins/sirsoft-gdpr",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+6
-6
@@ -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)
|
||||
|
||||
+75
-4
@@ -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<string, unknown> | null => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const obj = node as Record<string, unknown>;
|
||||
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('환경설정 인라인 펼침 패널', () => {
|
||||
|
||||
+32
-2
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+72
-49
@@ -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 는 항상 현재 정책 버전 기록)
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "카테고리별 쿠키 사용을 개별 동의\/철회할 수 있습니다. 필수 카테고리는 끌 수 없습니다.",
|
||||
|
||||
@@ -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'))}}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Gdpr\Enums;
|
||||
|
||||
/**
|
||||
* GDPR 동의 이력(history) 행위 종류
|
||||
*
|
||||
* gdpr_user_consent_histories.action 컬럼에 기록되는 append-only 행위 값.
|
||||
*
|
||||
* - granted: 동의 부여 (필수 항목 저장 / 선택 항목 동의)
|
||||
* - revoked: 동의 철회 (기존 동의 항목을 마이페이지 등에서 취소)
|
||||
* - rejected: 명시적 거부 (배너에서 "동의하지 않고 계속하기" 로 선택형 카테고리 거부)
|
||||
*
|
||||
* revoked 와 rejected 는 모두 is_consented=false 로 수렴하지만, 사용자의
|
||||
* 의사표시 맥락이 다르다 (철회 = 이전에 동의했던 것을 되돌림 / 거부 = 처음부터 거부).
|
||||
* GDPR Art.7(1) 입증 책임상 이 구분을 이력에 보존한다.
|
||||
*/
|
||||
enum ConsentAction: string
|
||||
{
|
||||
case Granted = 'granted';
|
||||
case Revoked = 'revoked';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
/**
|
||||
* 저장 값(value/rejection 신호)으로부터 행위를 판정합니다.
|
||||
*
|
||||
* @param bool $value 동의 여부 (true=동의, false=미동의)
|
||||
* @param bool $isRejection 명시적 거부 신호 여부
|
||||
* @return self value=true 시 Granted, false+거부 시 Rejected, 그 외 Revoked
|
||||
*/
|
||||
public static function fromDecision(bool $value, bool $isRejection = false): self
|
||||
{
|
||||
if ($value) {
|
||||
return self::Granted;
|
||||
}
|
||||
|
||||
return $isRejection ? self::Rejected : self::Revoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 케이스의 string 값 목록.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allValues(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
// 활성 동의 + 옛 버전 → 갱신 의도. 활성 동의 + 최신 버전 → 갱신 불요 (이미 최신).
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, bool> $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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+143
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Gdpr\Tests\Feature\Installation;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* GDPR 거부(rejection) 컬럼 추가 마이그레이션 Installation 스모크 테스트.
|
||||
*
|
||||
* 이슈 #430 — 쿠키 거부 기능. gdpr_user_consents 테이블에
|
||||
* is_rejected / rejected_at 컬럼이 추가되고, up→down→up 왕복이 안전한지 검증.
|
||||
*
|
||||
* 기존 001 마이그레이션(drop→create)은 수정 금지 대상이므로 add-column 방식의
|
||||
* 별도 마이그레이션으로 컬럼을 추가한다. hasColumn 가드로 왕복 안전.
|
||||
*/
|
||||
class GdprUserConsentRejectionMigrationSmokeTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* @scenario entry=reject, subject=member, category=optional
|
||||
* @effects rejection_columns_exist
|
||||
*/
|
||||
public function test_rejection_columns_exist(): void
|
||||
{
|
||||
foreach (['is_rejected', 'rejected_at'] as $column) {
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Gdpr\Tests\Unit\Enums;
|
||||
|
||||
use Plugins\Sirsoft\Gdpr\Enums\ConsentAction;
|
||||
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* ConsentAction Enum 단위 테스트 (이슈 #430 거부 행위 정의).
|
||||
*
|
||||
* granted / revoked / rejected 3분기 판정 로직을 검증한다.
|
||||
*/
|
||||
class ConsentActionTest extends PluginTestCase
|
||||
{
|
||||
public function test_cases_are_defined(): void
|
||||
{
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
@@ -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, '선택형은 거부');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 값이 화이트리스트 검증 없이 통과(백엔드 무수정)."
|
||||
Reference in New Issue
Block a user