Files
Gnuboard7/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php
T
HeuJung 1124fad027 feat(gdpr): 필수 저장 항목 허용목록을 운영자 설정으로 이관
허용목록이 플러그인 코드 상수라, 새 확장이 저장 키를 도입할 때마다 이 플러그인을
고쳐야 했다. 고치지 않으면 그 키는 방문마다 파기되는데 예외도 로그도 남지 않아
운영자에게는 "설정이 저장되지 않는다" 는 증상만 보인다.

허용목록을 운영자 설정(necessary_storage_allowlist)으로 옮기고, 관리자 환경설정에
브라우저 저장소·세션 저장소·쿠키 세 카드를 두어 직접 편집하게 했다. 확장은 GDPR 을
알 필요가 없고 GDPR 도 확장을 알 필요가 없다 — 연결은 운영자가 화면에서 한다.
저장 키를 플러그인이 관측·수집하지 않는 것도 같은 이유다: 관측 자체가 추적이다.

판정은 서버(미들웨어)와 클라이언트 셋(인터셉터 둘 + 정리기)이 같은 설정을 같은 매칭
규칙으로 읽는다. 잠금 항목(로그인 토큰·CSRF·세션 쿠키·동의 기록)만 설정 밖에 두고
판정 시점에 합집합으로 얹는다 — 설정에 담기면 저장 요청 한 번으로 지워져 잠금이
아니게 된다. 기설치본은 업그레이드 스텝이 현재 카탈로그를 설정 파일에 못박되,
운영자가 이미 편집했으면 그대로 둔다.

함께 고친 선재 결함 셋:
- 차단 도메인 추천 목록이 응답에 실리지 않아 자동완성이 동작하지 않던 문제
- 세션 쿠키 이름을 기본값에서 바꾼 사이트에서 브라우저 측 목록의 그 항목이 죽던 문제
- 쿠키 목록에만 앞부분 매칭(`이름_*`)이 적용되지 않던 문제
2026-09-03 22:18:18 +09:00

803 lines
33 KiB
PHP

<?php
namespace Plugins\Sirsoft\Gdpr\Tests\Feature\Api\Admin;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\Gdpr\Models\GdprPolicyVersion;
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
/**
* 관리자 GDPR 설정 API 테스트
*
* - GET /api/plugins/sirsoft-gdpr/admin/settings
* - PUT /api/plugins/sirsoft-gdpr/admin/settings
*
* @scenario scope=localStorage, notation=exact, locked=operator_item, settings_state=populated, request=invalid_format
*
* @effects update_persists_allowlist_per_scope, update_normalizes_textarea_string_to_array, update_without_key_leaves_existing_value_untouched, update_rejects_invalid_item_format_with_scope_indexed_error_key, update_rejects_unknown_scope, update_ignores_locked_list_from_request, show_includes_default_catalog_previews
*/
class GdprAdminSettingsControllerTest extends PluginTestCase
{
public function test_show_requires_auth(): void
{
$this->getJson('/api/plugins/sirsoft-gdpr/admin/settings')
->assertUnauthorized();
}
public function test_show_returns_settings_for_privacy_operator(): void
{
$values = [
'banner_enabled' => true,
'cookie_categories' => json_encode([['key' => 'necessary', 'required' => true]]),
];
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
function (string $id, ?string $key = null, mixed $default = null) use ($values) {
if ($key === null) {
return $values;
}
return $values[$key] ?? $default;
}
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$response = $this->actingAs($user)->getJson('/api/plugins/sirsoft-gdpr/admin/settings');
$response->assertOk()
->assertJsonPath('data.settings.banner_enabled', true)
->assertJsonPath('data.settings.cookie_categories.0.key', 'necessary')
->assertJsonMissingPath('data.settings.master_switch');
}
/**
* 마이페이지 카드 노출 토글 제거 회귀 — GDPR Art.7(3) 대칭성 의무에 따라
* mypage_privacy_tab_visible 키는 settings 스키마에서 완전히 제거되었다.
* 운영자가 PUT 으로 해당 키를 보내도 검증을 통과하지만 저장되지 않으며,
* GET 응답 settings 에도 노출되지 않는다.
*
* @return void
*/
public function test_mypage_privacy_tab_visible_key_is_completely_removed(): void
{
$captured = null;
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('save')->willReturnCallback(function (string $id, array $settings) use (&$captured) {
$captured = $settings;
return true;
});
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'mypage_privacy_tab_visible' => true,
])->assertOk();
$this->assertIsArray($captured);
$this->assertArrayNotHasKey('mypage_privacy_tab_visible', $captured);
}
/**
* auto_blocking_enabled 토글 제거 회귀 — GDPR Art.6 "동의 전 처리 금지" 의 강제
* 메커니즘인 차단을 운영자가 단독 OFF 할 수 있으면 위반 조합 (배너 ON + 차단 OFF)
* 가능 → CNIL 처벌 패턴과 동일. banner_enabled (쿠키 배너 노출) 단일 토글
* 로 통합되어 auto_blocking_enabled 키는 settings 스키마에서 완전히 제거됨.
* 운영자가 PUT 으로 해당 키를 보내도 stripped 되며 GET 응답에도 노출 안 됨.
*
* @return void
*/
public function test_auto_blocking_enabled_key_is_completely_removed(): void
{
$captured = null;
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('save')->willReturnCallback(function (string $id, array $settings) use (&$captured) {
$captured = $settings;
return true;
});
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'auto_blocking_enabled' => false,
])->assertOk();
$this->assertIsArray($captured);
$this->assertArrayNotHasKey('auto_blocking_enabled', $captured);
}
public function test_update_requires_auth(): void
{
$this->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'banner_enabled' => true,
])->assertUnauthorized();
}
public function test_update_persists_settings_for_privacy_operator(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->expects($this->atLeastOnce())
->method('save')
->willReturn(true);
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'banner_enabled' => true,
'cookie_policy_version' => '1.1',
])->assertOk();
}
public function test_update_rejects_invalid_banner_position(): void
{
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'banner_position' => 'invalid_position',
])->assertStatus(422);
}
/**
* F-02 도메인 차단 — textarea 줄바꿈 문자열을 배열로 정규화하여 저장하는지 검증.
*
* @return void
*/
public function test_update_normalizes_blocked_domains_textarea_string_to_array(): void
{
$captured = null;
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('save')->willReturnCallback(function (string $id, array $settings) use (&$captured) {
$captured = $settings;
return true;
});
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'blocked_domains' => [
'analytics' => "google-analytics.com\n*.hotjar.com\n wcs.naver.net \n\n",
'marketing' => "facebook.com\n",
],
])->assertOk();
$this->assertIsArray($captured);
$this->assertArrayHasKey('blocked_domains', $captured);
$this->assertSame(
['google-analytics.com', '*.hotjar.com', 'wcs.naver.net'],
$captured['blocked_domains']['analytics'],
);
$this->assertSame(['facebook.com'], $captured['blocked_domains']['marketing']);
}
/**
* F-02 도메인 차단 — 정확 매칭 도메인과 와일드카드 매칭 도메인 모두 통과.
*
* @return void
*/
public function test_update_accepts_exact_and_wildcard_domains(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('save')->willReturn(true);
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'blocked_domains' => [
'analytics' => ['google-analytics.com', '*.hotjar.com', '*.sub.example.com'],
'marketing' => ['*.facebook.com'],
],
])->assertOk();
}
/**
* F-02 도메인 차단 — 잘못된 도메인 형식은 422.
*
* @return void
*/
public function test_update_rejects_invalid_domain_format(): void
{
$user = $this->createPrivacyOperatorUser();
// 단일 라벨 (FQDN 아님) — 거부
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'blocked_domains' => [
'analytics' => ['localhost'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['blocked_domains.analytics.0']);
// 프로토콜 포함 — 거부
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'blocked_domains' => [
'analytics' => ['https://example.com'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['blocked_domains.analytics.0']);
// 라벨 시작/끝 하이픈 — 거부
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'blocked_domains' => [
'analytics' => ['-bad.example.com'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['blocked_domains.analytics.0']);
}
/**
* Phase 1: functional 카테고리 저장 — `in:` 룰에 functional 통과 + functional 차단 도메인 저장.
*
* ICO/CNIL 4분류 체계 (necessary/functional/analytics/marketing) 부합.
* UpdateAdminSettingsRequest 의 `in:necessary,functional,analytics,marketing` 룰이
* functional 키를 허용하는지 회귀 가드.
*/
public function test_update_accepts_functional_category_and_domains(): void
{
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'cookie_categories' => [
['key' => 'necessary', 'required' => true, 'label' => ['ko' => '필수', 'en' => 'Necessary']],
['key' => 'functional', 'required' => false, 'label' => ['ko' => '기능', 'en' => 'Functional']],
['key' => 'analytics', 'required' => false, 'label' => ['ko' => '분석', 'en' => 'Analytics']],
['key' => 'marketing', 'required' => false, 'label' => ['ko' => '마케팅', 'en' => 'Marketing']],
],
'blocked_domains' => [
'functional' => ['*.crisp.chat', 'widget.intercom.io'],
'analytics' => ['google-analytics.com'],
'marketing' => ['facebook.com'],
],
])->assertOk();
}
/**
* Phase 1: functional 차단 도메인 형식 검증 — 잘못된 도메인은 functional 전용 메시지로 거부.
*
* messages() 의 `blocked_domains.functional.*.regex` 키가 매핑되어 운영자가
* 어느 카테고리 도메인이 문제인지 즉시 식별 가능.
*/
public function test_update_rejects_invalid_functional_domain(): void
{
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'blocked_domains' => [
'functional' => ['localhost'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['blocked_domains.functional.0']);
}
/**
* 검증 에러 메시지의 :attribute placeholder 가 사용자 친화 한국어 라벨로 치환되는지 확인.
*
* Laravel 기본 :attribute 는 underscore 만 공백으로 치환한 영문 키 (예: "privacy policy slug") 를
* 노출하여 사용자가 어느 필드를 의미하는지 인지하기 어려움. FormRequest::attributes() 메서드로
* 각 필드에 lang 의 사용자 친화 라벨을 매핑하여 메시지 안에 노출되도록 함.
*
* 검토 #10d — 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가.
*/
public function test_update_validation_messages_use_friendly_attribute_names(): void
{
// 테스트 환경 기본 로케일이 en 일 수 있으므로 한국어 라벨 검증을 위해
// Accept-Language 헤더로 ko 강제 (코어 SetLocale 미들웨어가 헤더에서 locale 추출)
app()->setLocale('ko');
$user = $this->createPrivacyOperatorUser();
$response = $this->actingAs($user)
->withHeaders(['Accept-Language' => 'ko'])
->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'privacy_policy_slug' => '개인정보처리방침', // 한글 — regex 위반
'legal_entity_name' => str_repeat('가', 201), // max:200 위반
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['privacy_policy_slug', 'legal_entity_name']);
// :attribute 가 "개인정보처리방침 페이지 슬러그" 로 치환되어야 함 (영문 키 "privacy policy slug" 가 아님)
$errors = $response->json('errors');
$this->assertIsArray($errors);
$this->assertNotEmpty($errors['privacy_policy_slug'] ?? null);
$this->assertStringContainsString('개인정보처리방침 페이지 슬러그', $errors['privacy_policy_slug'][0]);
$this->assertStringNotContainsString('privacy policy slug', $errors['privacy_policy_slug'][0]);
$this->assertNotEmpty($errors['legal_entity_name'] ?? null);
$this->assertStringContainsString('법인명 / 운영 주체', $errors['legal_entity_name'][0]);
$this->assertStringNotContainsString('legal entity name', $errors['legal_entity_name'][0]);
}
/**
* 데이터 저장 위치에 보안 민감 식별자 (IP / CIDR / AWS 리전 코드) 가 입력되면 422 검증 실패.
*
* GDPR Art.13(1)(f) / PIPA 28조의8 은 「국가 단위」 표기를 요구하며, 클라우드 리전 코드나
* IP 대역은 처리방침 본문에 적시할 의무가 없을뿐더러 공격 표면 reconnaissance 단서가 되므로
* FormRequest 에서 not_regex 2종으로 사전 차단한다.
*
* @return void
*/
public function test_update_rejects_sensitive_format_in_data_storage_location(): void
{
app()->setLocale('ko');
$user = $this->createPrivacyOperatorUser();
$invalidValues = [
'192.168.1.1', // IPv4
'10.0.0.0/24', // CIDR
'AWS ap-northeast-2 (Seoul)', // AWS 리전 코드
'GCP asia-northeast3', // GCP 리전 코드
];
foreach ($invalidValues as $invalid) {
$response = $this->actingAs($user)
->withHeaders(['Accept-Language' => 'ko'])
->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'data_storage_location' => $invalid,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['data_storage_location']);
$errors = $response->json('errors');
$this->assertNotEmpty($errors['data_storage_location'] ?? null, "Should reject: {$invalid}");
// 사용자 친화 메시지 — IP/CIDR/리전 코드 안내 + 국가명 예시
$this->assertStringContainsString('국가명', $errors['data_storage_location'][0]);
}
}
/**
* 데이터 저장 위치에 국가 단위 표기는 정상 통과한다 (Art.13 권고 형식).
*
* @return void
*/
public function test_update_accepts_country_level_data_storage_location(): void
{
$captured = null;
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('save')->willReturnCallback(function (string $id, array $settings) use (&$captured) {
$captured = $settings;
return true;
});
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$validValues = [
'대한민국 (자체 데이터센터)',
'미국 (AWS)',
'Korea (self-hosted IDC)',
'대한민국, 미국',
];
foreach ($validValues as $valid) {
$this->actingAs($user)
->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'data_storage_location' => $valid,
])
->assertOk();
$this->assertSame($valid, $captured['data_storage_location'] ?? null, "Should accept: {$valid}");
}
}
/**
* 설정 저장은 정책 버전을 자동 발행하지 않는다 (수동 발행 모델).
*
* 옛 자동 발행 흐름이 운영자에게 "내가 안 누른 발행" 을 인지시키지 못해
* GDPR Art.30 처리 기록 의무의 *변경 사유* 입증을 약화시키므로 폐기됨.
* 정책 버전 발행은 운영자가 「+ 새 버전 발행」 (POST /admin/policy-versions) 으로 명시 트리거.
*/
public function test_update_does_not_auto_publish_policy_version_on_material_change(): void
{
// 현재 settings: necessary 카테고리만
$currentSettings = [
'cookie_categories' => json_encode([
['key' => 'necessary', 'required' => true, 'label' => ['ko' => '필수', 'en' => 'Necessary']],
], JSON_UNESCAPED_UNICODE),
];
$this->mockPluginSettings($currentSettings);
$user = $this->createPrivacyOperatorUser();
// 새 입력: analytics 카테고리 추가 — 옛 모델에서는 Material 자동 발행 트리거였으나 이제는 발행 없음
$response = $this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'cookie_categories' => [
['key' => 'necessary', 'required' => true, 'label' => ['ko' => '필수', 'en' => 'Necessary']],
['key' => 'analytics', 'required' => false, 'label' => ['ko' => '분석', 'en' => 'Analytics']],
],
]);
$response->assertOk();
// 응답에 옛 자동 발행 흐름의 키가 더 이상 노출되지 않는다
$response->assertJsonMissingPath('data.change_type');
$response->assertJsonMissingPath('data.published_version');
// DB 에 정책 버전 row 는 initial(v1) 1건만 — 새 발행 없음
$this->assertSame(1, GdprPolicyVersion::count());
$this->assertSame(1, GdprPolicyVersion::first()->version);
}
/**
* 비-Material 변경 (도메인만 추가) 시에도 동일하게 정책 버전 발행 없음 (수동 발행 모델 — 모든 저장 흐름 공통).
*/
public function test_update_does_not_publish_policy_version_on_non_material_change(): void
{
$currentSettings = [
'cookie_categories' => json_encode([
['key' => 'necessary', 'required' => true, 'label' => ['ko' => '필수', 'en' => 'Necessary']],
['key' => 'analytics', 'required' => false, 'label' => ['ko' => '분석', 'en' => 'Analytics']],
], JSON_UNESCAPED_UNICODE),
'blocked_domains' => ['analytics' => ['google-analytics.com']],
];
$this->mockPluginSettings($currentSettings);
$user = $this->createPrivacyOperatorUser();
// 새 입력: 같은 카테고리 + 도메인 1개 추가 (Non-material)
$response = $this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'cookie_categories' => [
['key' => 'necessary', 'required' => true, 'label' => ['ko' => '필수', 'en' => 'Necessary']],
['key' => 'analytics', 'required' => false, 'label' => ['ko' => '분석', 'en' => 'Analytics']],
],
'blocked_domains' => [
'analytics' => ['google-analytics.com', 'hotjar.com'],
],
]);
$response->assertOk();
$response->assertJsonMissingPath('data.change_type');
$response->assertJsonMissingPath('data.published_version');
// 마이그레이션 시 initial 행 (version=1) 만 존재 — 저장 흐름은 발행 없음
$this->assertSame(1, GdprPolicyVersion::count());
$this->assertSame(1, GdprPolicyVersion::first()->version);
}
/**
* Phase 2 단순화: admin 응답에도 functional 등록 표 필드는 노출되지 않는다.
*
* 운영자 등록 표 자체가 제거되어 functional_storage_keys / functional_cookies /
* functional_allow_user_initiated 모두 settings 응답에서 사라짐.
*
* @return void
*/
public function test_show_does_not_expose_phase2_registration_fields(): void
{
$operator = $this->createPrivacyOperatorUser();
$this->mockPluginSettings([]);
$this->actingAs($operator)
->getJson('/api/plugins/sirsoft-gdpr/admin/settings')
->assertOk()
->assertJsonMissingPath('data.settings.functional_storage_keys')
->assertJsonMissingPath('data.settings.functional_cookies')
->assertJsonMissingPath('data.settings.functional_allow_user_initiated');
}
/**
* PluginSettingsService::get 을 통제 가능한 Mock 으로 교체.
*
* @param array<string, mixed> $values
* @return void
*/
private function mockPluginSettings(array $values): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
function (string $id, ?string $key = null, mixed $default = null) use (&$values) {
if ($key === null) {
return $values;
}
return $values[$key] ?? $default;
}
);
// save 호출 시 내부 values 갱신 (saveAdminSettings 가 save 직후 다시 get 으로 응답 구성)
$mock->method('save')->willReturnCallback(
function (string $id, array $data) use (&$values): bool {
$values = array_merge($values, $data);
return true;
}
);
$this->app->instance(PluginSettingsService::class, $mock);
}
/**
* 관리자 응답에 출하 카탈로그 두 벌이 실린다 (추천 목록 결함 해소).
*
* 관리자 레이아웃의 TagInput 은 이 값을 자동완성 추천으로 쓴다. 응답에 없으면 드롭다운에
* **이미 선택된 칩만** 다시 나타나므로 추천이 동작하는 것처럼 보이고, 오류도 빈 목록도
* 남지 않아 증상만으로는 알 수 없다.
*
* @return void
*/
public function test_show_includes_default_catalog_previews(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $key === null ? [] : $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$response = $this->actingAs($user)->getJson('/api/plugins/sirsoft-gdpr/admin/settings');
$response->assertOk()
->assertJsonStructure([
'data' => [
'default_blocked_domains_preview' => ['functional', 'analytics', 'marketing'],
'default_necessary_allowlist_preview' => ['localStorage', 'sessionStorage', 'cookie'],
],
]);
$this->assertContains(
'g7_color_scheme',
$response->json('data.default_necessary_allowlist_preview.localStorage'),
'출하 카탈로그가 실제 항목을 담아야 추천이 의미를 가진다'
);
$this->assertContains(
'google-analytics.com',
$response->json('data.default_blocked_domains_preview.analytics')
);
}
/**
* 잠금 항목은 출하 카탈로그(운영자 편집 대상)에 실리지 않는다.
*
* 실리면 운영자가 화면에서 지울 수 있고, 그 순간 잠금이 잠금이 아니게 된다.
*
* @return void
*/
public function test_default_catalog_preview_excludes_locked_items(): void
{
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $key === null ? [] : $default
);
$this->app->instance(PluginSettingsService::class, $mock);
$user = $this->createPrivacyOperatorUser();
$preview = $this->actingAs($user)
->getJson('/api/plugins/sirsoft-gdpr/admin/settings')
->json('data.default_necessary_allowlist_preview');
$this->assertNotContains('auth_token', $preview['localStorage']);
$this->assertNotContains('XSRF-TOKEN', $preview['cookie']);
$this->assertNotContains('gdpr_session', $preview['cookie']);
}
/**
* 필수 저장 항목 허용목록이 스코프별로 저장된다.
*
* @return void
*/
public function test_update_persists_necessary_storage_allowlist(): void
{
$captured = $this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'localStorage' => ['g7_locale', 'g7_filters_*'],
'sessionStorage' => ['g7:sirsoft-pay_kginicis:pendingClose'],
'cookie' => ['laravel_maintenance', 'myplugin_*'],
],
])->assertOk();
$saved = $captured->value['necessary_storage_allowlist'] ?? null;
$this->assertIsArray($saved);
$this->assertSame(['g7_locale', 'g7_filters_*'], $saved['localStorage']);
$this->assertSame(['g7:sirsoft-pay_kginicis:pendingClose'], $saved['sessionStorage']);
$this->assertSame(['laravel_maintenance', 'myplugin_*'], $saved['cookie']);
}
/**
* 줄바꿈 문자열로 들어와도 배열로 정규화된다 (blocked_domains 와 동일 규약).
*
* @return void
*/
public function test_update_normalizes_necessary_storage_allowlist_textarea_string(): void
{
$captured = $this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'localStorage' => "g7_locale\n g7_color_scheme \n\n",
],
])->assertOk();
$saved = $captured->value['necessary_storage_allowlist'];
$this->assertSame(['g7_locale', 'g7_color_scheme'], $saved['localStorage']);
// 전송하지 않은 스코프는 빈 배열로 보충되어 화면이 항상 세 카드를 그린다.
$this->assertSame([], $saved['sessionStorage']);
$this->assertSame([], $saved['cookie']);
}
/**
* 키를 아예 보내지 않으면 기존 저장값을 건드리지 않는다.
*
* 빈 배열로 보충해 버리면 이 화면을 모르는 클라이언트의 저장 한 번이 허용목록을 통째로
* 비우고, 그 사이트는 다음 방문부터 로그인 외 모든 설정을 잃는다.
*
* @return void
*/
public function test_update_without_allowlist_key_leaves_it_untouched(): void
{
$captured = $this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'banner_enabled' => true,
])->assertOk();
$this->assertArrayNotHasKey('necessary_storage_allowlist', $captured->value);
}
/**
* 항목 형식 위반은 스코프·인덱스가 드러나는 키로 422 를 낸다.
*
* 에러 키가 `necessary_storage_allowlist.{scope}.{index}` 여야 화면이 그 카드에
* 메시지를 붙일 수 있다.
*
* @return void
*/
public function test_update_rejects_invalid_allowlist_item_format(): void
{
$this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'localStorage' => ['bad key!'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['necessary_storage_allowlist.localStorage.0']);
// `*` 는 끝에만 허용 — 중간/앞 표기는 거른다 (앞에 두면 전체 개방이 된다).
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'cookie' => ['*_suffix'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['necessary_storage_allowlist.cookie.0']);
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'sessionStorage' => ['g7_*_middle'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['necessary_storage_allowlist.sessionStorage.0']);
}
/**
* 와일드카드 표기와 실제 사용 키는 통과한다 (위 테스트의 대조군).
*
* @return void
*/
public function test_update_accepts_valid_allowlist_items(): void
{
$this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'localStorage' => ['g7_locale', 'g7_filters_*', 'g7le.clipboard', 'g7_asset_url_mode*'],
'sessionStorage' => ['g7:sirsoft-pay_nhnkcp:pendingClose', '__sirsoftKginicisMobilePaymentReturnPending'],
'cookie' => ['laravel_maintenance', 'XSRF-TOKEN'],
],
])->assertOk();
}
/**
* 스코프 키 화이트리스트 — 알 수 없는 스코프는 422.
*
* 조용히 버리면 그 항목은 어떤 판정에도 쓰이지 않은 채 저장되고, 운영자에게는
* "등록했는데 안 되는" 상태로만 보인다.
*
* @return void
*/
public function test_update_rejects_unknown_allowlist_scope(): void
{
$this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_allowlist' => [
'localstorage' => ['g7_locale'],
],
])->assertStatus(422)
->assertJsonValidationErrors(['necessary_storage_allowlist.localstorage']);
}
/**
* 잠금 항목 목록은 저장되지 않는다 (요청에 섞여 와도 무시).
*
* 저장 가능하면 API 한 번으로 잠금을 무력화할 수 있다.
*
* @return void
*/
public function test_update_ignores_locked_list_from_request(): void
{
$captured = $this->captureSave();
$user = $this->createPrivacyOperatorUser();
$this->actingAs($user)->putJson('/api/plugins/sirsoft-gdpr/admin/settings', [
'necessary_storage_locked' => [
'localStorage' => [],
'sessionStorage' => [],
'cookie' => [],
],
])->assertOk();
$this->assertArrayNotHasKey('necessary_storage_locked', $captured->value);
}
/**
* 저장 페이로드를 가로채는 PluginSettingsService mock 을 등록합니다.
*
* @return object 저장된 페이로드를 담는 컨테이너 (`value` 프로퍼티)
*/
private function captureSave(): object
{
$captured = new class
{
/** @var array<string, mixed> */
public array $value = [];
};
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('save')->willReturnCallback(function (string $id, array $settings) use ($captured) {
$captured->value = $settings;
return true;
});
$mock->method('get')->willReturnCallback(
fn (string $id, ?string $key = null, mixed $default = null) => $key === null ? [] : $default
);
$this->app->instance(PluginSettingsService::class, $mock);
return $captured;
}
}