admin = $this->createAdminUser(); $this->token = $this->admin->createToken('test-token')->plainTextToken; } /** * 관리자 역할 생성 및 할당 * * @param array $permissions 사용자에게 부여할 권한 식별자 목록 */ private function createAdminUser(array $permissions = ['core.settings.read', 'core.settings.update']): User { $user = User::factory()->create([ 'password' => Hash::make('password123'), ]); // 권한 생성 $permissionIds = []; foreach ($permissions as $permIdentifier) { $permission = Permission::firstOrCreate( ['identifier' => $permIdentifier], [ 'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]), 'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'type' => 'admin', ] ); $permissionIds[] = $permission->id; } // 고유한 식별자로 역할 생성 (테스트별 격리를 위해) // admin 미들웨어가 hasRole('admin')을 체크하므로 'admin'으로 시작하는 역할 사용 $roleIdentifier = 'admin_test_'.uniqid(); $adminRole = Role::create([ 'identifier' => $roleIdentifier, 'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']), 'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']), 'is_active' => true, ]); // admin 역할도 추가 (admin 미들웨어 통과용) $adminBaseRole = Role::firstOrCreate( ['identifier' => 'admin'], [ 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), 'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'is_active' => true, ] ); // 테스트용 역할에 권한 할당 $adminRole->permissions()->sync($permissionIds); // 사용자에게 admin 역할과 테스트용 역할 모두 할당 $user->roles()->attach($adminBaseRole->id, [ 'assigned_at' => now(), 'assigned_by' => null, ]); $user->roles()->attach($adminRole->id, [ 'assigned_at' => now(), 'assigned_by' => null, ]); return $user->fresh(); } /** * super_admin 역할을 가진 사용자 생성 (필요한 권한 포함) * admin 미들웨어를 통과하기 위해 admin 역할도 함께 할당 */ private function createSuperAdminUser(): User { $user = User::factory()->create([ 'password' => Hash::make('password123'), ]); // admin 역할 생성 (admin 미들웨어 통과용) $adminRole = Role::firstOrCreate( ['identifier' => 'admin'], [ 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), 'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'is_active' => true, ] ); // super_admin 역할 생성 $superAdminRole = Role::firstOrCreate( ['identifier' => 'super_admin'], [ 'name' => json_encode(['ko' => '최고관리자', 'en' => 'Super Administrator']), 'description' => json_encode(['ko' => '최고 권한 관리자', 'en' => 'Super Administrator']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'is_active' => true, ] ); // core.settings.update 권한 생성 및 역할에 할당 $permission = Permission::firstOrCreate( ['identifier' => 'core.settings.update'], [ 'name' => json_encode(['ko' => '설정 수정', 'en' => 'Update Settings']), 'description' => json_encode(['ko' => '설정 수정 권한', 'en' => 'Update Settings Permission']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'type' => 'admin', ] ); if (! $superAdminRole->permissions()->where('permissions.id', $permission->id)->exists()) { $superAdminRole->permissions()->attach($permission->id); } // 사용자에게 admin 역할과 super_admin 역할 모두 할당 $user->roles()->attach($adminRole->id, [ 'assigned_at' => now(), 'assigned_by' => null, ]); $user->roles()->attach($superAdminRole->id, [ 'assigned_at' => now(), 'assigned_by' => null, ]); return $user->fresh(); } /** * 인증된 요청 헬퍼 메서드 */ private function authRequest(): static { return $this->withHeaders([ 'Authorization' => 'Bearer '.$this->token, 'Accept' => 'application/json', ]); } // ======================================================================== // 인증/권한 테스트 // ======================================================================== /** * 인증 없이 설정 목록 조회 시 401 반환 */ public function test_index_returns_401_without_authentication(): void { $response = $this->getJson('/api/admin/settings'); $response->assertStatus(401); } /** * 권한 없이 설정 목록 조회 시 403 반환 */ public function test_index_returns_403_without_permission(): void { // 권한 없는 관리자 생성 $user = User::factory()->create(); $adminRole = Role::firstOrCreate( ['identifier' => 'admin'], [ 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), 'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'is_active' => true, ] ); // 기존 권한 분리 (테스트용) $readPermission = Permission::where('identifier', 'core.settings.read')->first(); if ($adminRole && $readPermission) { $adminRole->permissions()->detach($readPermission->id); } $user->roles()->attach($adminRole->id, [ 'assigned_at' => now(), 'assigned_by' => null, ]); $token = $user->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->getJson('/api/admin/settings'); $response->assertStatus(403); } /** * 권한 없이 설정 저장 시 403 반환 */ public function test_store_returns_403_without_update_permission(): void { // read 권한만 있는 관리자 생성 $user = $this->createAdminUser(['core.settings.read']); $token = $user->createToken('test-token')->plainTextToken; // 전체 필수 필드를 포함하여 권한 테스트 $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->postJson('/api/admin/settings', [ '_tab' => 'general', 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Asia/Seoul', 'language' => 'ko', ]); $response->assertStatus(403); } // ======================================================================== // 설정 조회 테스트 (index) // ======================================================================== /** * 설정 목록 조회 성공 */ public function test_index_returns_all_settings(): void { $response = $this->authRequest()->getJson('/api/admin/settings'); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 설정 목록 응답 구조 검증 */ public function test_index_returns_correct_structure(): void { $response = $this->authRequest()->getJson('/api/admin/settings'); $response->assertStatus(200) ->assertJsonStructure([ 'success', 'message', 'data', ]); } // ======================================================================== // abilities 응답 테스트 // ======================================================================== /** * read+update 권한 사용자 → abilities.can_update: true */ public function test_index_returns_can_update_true_with_update_permission(): void { $response = $this->authRequest()->getJson('/api/admin/settings'); $response->assertStatus(200) ->assertJson([ 'success' => true, 'data' => [ 'abilities' => [ 'can_update' => true, ], ], ]); } /** * read 권한만 사용자 → abilities.can_update: false */ public function test_index_returns_can_update_false_without_update_permission(): void { // read 권한만 있는 관리자 생성 $readOnlyUser = $this->createAdminUser(['core.settings.read']); $readOnlyToken = $readOnlyUser->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$readOnlyToken, 'Accept' => 'application/json', ])->getJson('/api/admin/settings'); $response->assertStatus(200) ->assertJson([ 'success' => true, 'data' => [ 'abilities' => [ 'can_update' => false, ], ], ]); } // ======================================================================== // 설정 저장 테스트 (store) // ======================================================================== /** * 일반 탭 설정 저장 성공 */ public function test_store_saves_general_tab_settings(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Asia/Seoul', 'language' => 'ko', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 보안 탭 설정 저장 성공 */ public function test_store_saves_security_tab_settings(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'security', 'security' => [ 'force_https' => true, 'login_attempt_enabled' => true, 'auth_token_lifetime' => 60, 'max_login_attempts' => 5, 'login_lockout_time' => 30, ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 고급 탭 설정 저장 성공 */ public function test_store_saves_advanced_tab_settings(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 3600, 'stats_cache_enabled' => true, 'stats_cache_ttl' => 3600, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 3600, 'debug_mode' => false, 'sql_query_log' => false, 'core_update_github_url' => 'https://github.com/test/repo', 'core_update_github_token' => 'ghp_test_token_12345', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 본인인증(IDV) 탭 저장 성공 — _tab='identity' 가 허용 목록에 포함되어야 한다. * * 회귀 방지: identity 가 _tab Rule::in 목록에서 누락되어 422 ("선택한 tab이(가) 올바르지 않습니다.") 가 * 발생했던 사례. 환경설정 > 본인인증 탭의 모든 저장 시도가 차단됐음. */ public function test_store_saves_identity_tab_settings(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'identity', 'identity' => [ 'enabled' => true, 'default_provider' => 'g7:core.mail', 'signup' => [ 'mode' => 'disabled', 'provider' => null, ], 'purpose_providers' => [ 'password_reset' => null, 'self_update' => null, 'sensitive_action' => null, ], 'challenge_ttl_minutes' => 15, 'max_attempts' => 5, ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 본인인증 탭 — purpose id 에 점(.)이 포함된 목적별 프로바이더 매핑 저장 성공. * * 회귀 방지: KG이니시스 플러그인이 선언한 purpose id `inicis.adult_verification` 처럼 * 점을 포함하는 purpose 를 매핑하면, 프론트엔드 폼 바인딩이 dot-notation name * (`identity.purpose_providers.inicis.adult_verification`) 을 중첩 객체로 풀어 * `purpose_providers.inicis = { adult_verification: '...' }` 형태로 전송한다. * 백엔드 조회(IdentityVerificationManager::resolveForPurpose)는 config dot-path 라 * 이 중첩 구조가 정상이지만, validation rule 이 1단계 깊이만 string 으로 허용하면 * 중첩된 `inicis` 가 배열이 되어 422 ("문자열이어야 합니다.") 가 발생했던 사례. * 환경설정 > 본인인증 > 목적별 프로바이더 저장이 전면 차단됐음. */ public function test_store_saves_identity_purpose_providers_with_dotted_purpose_id(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'identity', 'identity' => [ 'default_provider' => 'g7:core.mail', 'purpose_providers' => [ 'signup' => '', 'password_reset' => null, // 점(.)을 포함한 purpose id → 폼 바인딩이 중첩 객체로 풀어 전송 'inicis' => [ 'adult_verification' => 'inicis', ], ], 'challenge_ttl_minutes' => 15, 'max_attempts' => 5, ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 본인인증 탭 — 점 포함 purpose 매핑 값이 max:100 을 초과하면 중첩 경로로 검증 실패. * * nested 허용으로 완화하되, 값 길이 제약(string|max:100)은 임의 깊이에서도 유지되어야 한다. */ public function test_store_validates_nested_purpose_provider_value_length(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'identity', 'identity' => [ 'purpose_providers' => [ 'inicis' => [ 'adult_verification' => str_repeat('x', 101), // max:100 초과 ], ], 'challenge_ttl_minutes' => 15, 'max_attempts' => 5, ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['identity.purpose_providers.inicis.adult_verification']); } /** * 본인인증 탭 — challenge_ttl_minutes 범위(1~1440) 검증. */ public function test_store_validates_identity_challenge_ttl_range(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'identity', 'identity' => [ 'enabled' => true, 'challenge_ttl_minutes' => 99999, // 초과 'max_attempts' => 5, ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['identity.challenge_ttl_minutes']); } /** * 본인인증 탭 — max_attempts 범위(1~20) 검증. */ public function test_store_validates_identity_max_attempts_range(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'identity', 'identity' => [ 'enabled' => true, 'challenge_ttl_minutes' => 15, 'max_attempts' => 0, // 1 미만 ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['identity.max_attempts']); } /** * 일반 탭에서 site_name 필수 검증 */ public function test_store_validates_site_name_required_for_general_tab(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ // site_name 누락 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Asia/Seoul', 'language' => 'ko', ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['general.site_name']); } /** * site_url 형식 검증 */ public function test_store_validates_site_url_format(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'not-a-valid-url', 'admin_email' => 'admin@example.com', 'timezone' => 'Asia/Seoul', 'language' => 'ko', ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['general.site_url']); } /** * admin_email 형식 검증 */ public function test_store_validates_admin_email_format(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'not-a-valid-email', 'timezone' => 'Asia/Seoul', 'language' => 'ko', ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['general.admin_email']); } /** * timezone 지원 목록 검증 */ public function test_store_validates_timezone_in_supported_list(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Invalid/Timezone', 'language' => 'ko', ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['general.timezone']); } /** * auth_token_lifetime 범위 검증 (30-3600 또는 0) */ public function test_store_validates_auth_token_lifetime_range(): void { // 범위 벗어난 값 (1-29) $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'security', 'security' => [ 'force_https' => true, 'login_attempt_enabled' => true, 'auth_token_lifetime' => 15, // 30 미만, 0이 아님 ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['security.auth_token_lifetime']); } /** * 캐시 TTL 범위 검증 */ public function test_store_validates_cache_ttl_range(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 99999, // 최대 14400 초과 'stats_cache_enabled' => true, 'stats_cache_ttl' => 3600, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 3600, 'debug_mode' => false, 'sql_query_log' => false, ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['advanced.layout_cache_ttl']); } /** * 잘못된 탭 값 검증 */ public function test_store_returns_422_for_invalid_tab(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'invalid_tab', 'site_name' => 'Test Site', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['_tab']); } // ======================================================================== // 개별 설정 테스트 (show/update) // ======================================================================== /** * 단일 설정 조회 성공 */ public function test_show_returns_single_setting(): void { $response = $this->authRequest()->getJson('/api/admin/settings/site_name'); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonStructure([ 'success', 'message', 'data' => [ 'key', 'value', ], ]); } /** * 존재하지 않는 설정 키 조회 시 기본값 반환 */ public function test_show_returns_default_for_nonexistent_key(): void { $response = $this->authRequest()->getJson('/api/admin/settings/nonexistent_key'); $response->assertStatus(200) ->assertJson([ 'success' => true, 'data' => [ 'key' => 'nonexistent_key', ], ]); } /** * 단일 설정 업데이트 성공 */ public function test_update_modifies_single_setting(): void { // set() 메서드는 category.key 형식을 요구함 (예: general.site_name) $response = $this->authRequest()->putJson('/api/admin/settings/general.site_name', [ 'value' => 'Updated Site Name', ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 설정 업데이트 시 value 필수 검증 */ public function test_update_validates_value_required(): void { $response = $this->authRequest()->putJson('/api/admin/settings/general.site_name', []); $response->assertStatus(422) ->assertJsonValidationErrors(['value']); } // ======================================================================== // 시스템 정보 테스트 (systemInfo) // ======================================================================== /** * 시스템 정보 응답 구조 검증 * * @scenario probe_item=cpu_info,probe_outcome=success,auth_state=authenticated_with_permission * * @effects all_probes_success_returns_full_payload */ public function test_system_info_returns_correct_structure(): void { $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonStructure([ 'success', 'message', 'data', ]); } /** * 메모리 사용량은 디스크 사용량과 동일한 구조(total/used/free/percentage)로 반환된다. * * 회귀 테스트: memory_get_usage(true)는 PHP 프로세스 메모리만 반환하여 * 서버 물리 RAM과 무관한 값(6~12MB)이 노출되던 이슈 방지. */ public function test_system_info_memory_usage_has_disk_like_structure(): void { $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ 'memory_usage' => ['total', 'used', 'free', 'percentage'], ], ]); $memory = $response->json('data.memory_usage'); $this->assertIsArray($memory); $this->assertIsNumeric($memory['percentage']); $this->assertGreaterThanOrEqual(0, $memory['percentage']); $this->assertLessThanOrEqual(100, $memory['percentage']); } /** * CPU 정보는 비어있지 않은 문자열이며, 명령 실행 오류 메시지의 꼬리말이 * 그대로 노출되지 않는다. * * 회귀 테스트: Windows 11/Server 2025에서 wmic 제거로 인해 * "operable program or batch file."가 그대로 노출되던 이슈 방지. */ public function test_system_info_cpu_info_is_not_shell_error_tail(): void { $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); $response->assertStatus(200); $cpu = $response->json('data.cpu_info'); $this->assertIsString($cpu); $this->assertNotSame('', trim($cpu)); $this->assertStringNotContainsStringIgnoringCase('operable program or batch file', $cpu); $this->assertStringNotContainsStringIgnoringCase('is not recognized', $cpu); } /** * 두 번째 호출부터는 하드웨어 정보가 캐시에서 제공된다. * * 회귀 테스트: PowerShell(CIM) 호출이 수백ms ~ 수초 걸려 탭 전환 * UX 를 저해하던 이슈 방지. server_time 은 캐시 제외이므로 * 매 호출마다 갱신됨도 함께 검증한다. */ public function test_system_info_caches_hardware_payload_but_refreshes_server_time(): void { $cache = app(CacheInterface::class); $cache->forget('settings.system_info.'.app()->getLocale()); $first = $this->authRequest()->getJson('/api/admin/settings/system-info'); $first->assertStatus(200); // 캐시에 기록됐는지 직접 확인 $this->assertTrue($cache->has('settings.system_info.'.app()->getLocale())); // 1초 이상 간격을 두고 재호출 → server_time 은 갱신, cpu_info 는 동일해야 함 sleep(1); $second = $this->authRequest()->getJson('/api/admin/settings/system-info'); $second->assertStatus(200); $this->assertSame( $first->json('data.cpu_info'), $second->json('data.cpu_info'), 'cpu_info 는 캐시에서 재사용되어야 한다' ); $this->assertNotSame( $first->json('data.server_time'), $second->json('data.server_time'), 'server_time 은 캐시 우회하여 매 호출마다 갱신되어야 한다' ); } /** * clearCache 호출 시 시스템 정보 캐시도 함께 무효화된다. */ public function test_clear_cache_invalidates_system_info_cache(): void { // 캐시 적재 $this->authRequest()->getJson('/api/admin/settings/system-info')->assertStatus(200); $cache = app(CacheInterface::class); $locale = app()->getLocale(); $this->assertTrue($cache->has('settings.system_info.'.$locale)); // clearCache 호출 $this->authRequest()->postJson('/api/admin/settings/clear-cache')->assertStatus(200); $this->assertFalse( $cache->has('settings.system_info.'.$locale), 'clearCache 는 settings.system_info.* 캐시도 제거해야 한다' ); } // ======================================================================== // 캐시 관리 테스트 (clearCache) // ======================================================================== /** * 캐시 정리 성공 */ public function test_clear_cache_succeeds(): void { $response = $this->authRequest()->postJson('/api/admin/settings/clear-cache'); $response->assertStatus(200) ->assertJson(['success' => true]); } // ======================================================================== // 앱 키 테스트 (getAppKey/regenerateAppKey) // ======================================================================== /** * 앱 키 조회 시 마스킹된 키 반환 */ public function test_get_app_key_returns_masked_key(): void { $response = $this->authRequest()->getJson('/api/admin/settings/app-key'); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonStructure([ 'success', 'message', 'data' => [ 'app_key', ], ]); // 마스킹 확인 (키의 일부가 *로 대체되어 있어야 함) $appKey = $response->json('data.app_key'); $this->assertStringContainsString('*', $appKey); } /** * 앱 키 재생성 시 비밀번호 필수 */ public function test_regenerate_app_key_requires_password(): void { $superAdmin = $this->createSuperAdminUser(); $token = $superAdmin->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->postJson('/api/admin/settings/regenerate-app-key', []); $response->assertStatus(422) ->assertJsonValidationErrors(['password']); } /** * 앱 키 재생성 시 잘못된 비밀번호 검증 */ public function test_regenerate_app_key_validates_password(): void { $superAdmin = $this->createSuperAdminUser(); $token = $superAdmin->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->postJson('/api/admin/settings/regenerate-app-key', [ 'password' => 'wrong_password', ]); // 비밀번호 검증 실패 시 401 반환 $response->assertStatus(401); } /** * 앱 키 재생성 시 super_admin 역할 필수 */ public function test_regenerate_app_key_requires_super_admin_role(): void { // 일반 admin 역할로 요청 $response = $this->authRequest()->postJson('/api/admin/settings/regenerate-app-key', [ 'password' => 'password123', ]); // authorize 실패로 403 반환 $response->assertStatus(403); } /** * super_admin 역할로 앱 키 재생성 성공 */ public function test_regenerate_app_key_succeeds_with_super_admin(): void { $superAdmin = $this->createSuperAdminUser(); $token = $superAdmin->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->postJson('/api/admin/settings/regenerate-app-key', [ 'password' => 'password123', ]); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonStructure([ 'success', 'message', 'data' => [ 'app_key', ], ]); } // ======================================================================== // 테스트 메일 발송 테스트 (testMail) // ======================================================================== /** * 인증 없이 테스트 메일 발송 시 401 반환 */ public function test_test_mail_returns_401_without_authentication(): void { $response = $this->postJson('/api/admin/settings/test-mail', [ 'to_email' => 'test@example.com', ]); $response->assertStatus(401); } /** * 이메일 없이 테스트 메일 발송 시 422 반환 */ public function test_test_mail_returns_422_without_email(): void { $response = $this->authRequest()->postJson('/api/admin/settings/test-mail', []); $response->assertStatus(422) ->assertJsonValidationErrors(['to_email']); } /** * 잘못된 이메일 형식으로 테스트 메일 발송 시 422 반환 */ public function test_test_mail_returns_422_with_invalid_email(): void { $response = $this->authRequest()->postJson('/api/admin/settings/test-mail', [ 'to_email' => 'not-a-valid-email', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['to_email']); } /** * 올바른 이메일로 테스트 메일 발송 성공 */ public function test_test_mail_succeeds_with_valid_email(): void { Mail::fake(); $response = $this->authRequest()->postJson('/api/admin/settings/test-mail', [ 'to_email' => 'test@example.com', 'mailer' => 'smtp', 'host' => 'smtp.example.com', 'port' => 587, 'from_address' => 'noreply@example.com', 'from_name' => 'G7 Test', ]); $response->assertStatus(200) ->assertJson(['success' => true]); } // ======================================================================== // 타임존 설정 테스트 // ======================================================================== /** * Europe/Paris 타임존이 유효성 검사를 통과하는지 테스트합니다. */ public function test_store_accepts_europe_paris_timezone(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Europe/Paris', 'language' => 'ko', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 타임존 저장 후 app.timezone이 UTC를 유지하는지 테스트합니다. */ public function test_store_timezone_does_not_change_app_timezone(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'America/New_York', 'language' => 'ko', ], ]); $response->assertStatus(200); // app.timezone은 항상 UTC 유지 $this->assertEquals('UTC', config('app.timezone')); } /** * 고급 탭에서 코어 업데이트 설정(GitHub URL, 토큰) 저장 성공 */ public function test_store_saves_core_update_settings_in_advanced_tab(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 3600, 'stats_cache_enabled' => true, 'stats_cache_ttl' => 1800, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 7200, 'debug_mode' => false, 'sql_query_log' => false, 'core_update_github_url' => 'https://github.com/gnuboard/g7', 'core_update_github_token' => 'ghp_test_token_abcdef123456', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); // 저장된 설정이 조회 시 반영되는지 확인 $getResponse = $this->authRequest()->getJson('/api/admin/settings'); $getResponse->assertStatus(200); $settings = $getResponse->json('data.advanced'); $this->assertNotNull($settings, 'advanced 카테고리가 응답에 포함되어야 합니다'); $this->assertEquals('https://github.com/gnuboard/g7', $settings['core_update_github_url']); $this->assertEquals('ghp_test_token_abcdef123456', $settings['core_update_github_token']); } /** * 코어 업데이트 GitHub URL 형식 검증 */ public function test_store_validates_core_update_github_url_format(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 3600, 'stats_cache_enabled' => true, 'stats_cache_ttl' => 1800, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 7200, 'debug_mode' => false, 'sql_query_log' => false, 'core_update_github_url' => 'not-a-valid-url', ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['advanced.core_update_github_url']); } /** * 코어 업데이트 GitHub 토큰 최대 길이 검증 */ public function test_store_validates_core_update_github_token_max_length(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 3600, 'stats_cache_enabled' => true, 'stats_cache_ttl' => 1800, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 7200, 'debug_mode' => false, 'sql_query_log' => false, 'core_update_github_token' => str_repeat('a', 501), ], ]); $response->assertStatus(422) ->assertJsonValidationErrors(['advanced.core_update_github_token']); } /** * 코어 업데이트 설정이 비어있어도 저장 성공 (nullable) */ public function test_store_allows_empty_core_update_settings(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 3600, 'stats_cache_enabled' => true, 'stats_cache_ttl' => 1800, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 7200, 'debug_mode' => false, 'sql_query_log' => false, 'core_update_github_url' => '', 'core_update_github_token' => '', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 코어 업데이트 설정 저장 후 config()에 반영되는지 확인 */ public function test_core_update_settings_override_config(): void { // 기본 config 값 확인 $originalUrl = config('app.update.github_url'); // settings로 저장 $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'advanced', 'advanced' => [ 'cache_enabled' => true, 'layout_cache_enabled' => true, 'layout_cache_ttl' => 3600, 'stats_cache_enabled' => true, 'stats_cache_ttl' => 1800, 'seo_cache_enabled' => true, 'seo_cache_ttl' => 7200, 'debug_mode' => false, 'sql_query_log' => false, 'core_update_github_url' => 'https://github.com/custom/repo', 'core_update_github_token' => 'ghp_custom_token', ], ]); $response->assertStatus(200); // SettingsServiceProvider가 다음 요청에서 config를 오버라이드하는지 확인 // JsonConfigRepository에서 직접 읽어서 확인 $configRepo = app(ConfigRepositoryInterface::class); $coreUpdateSettings = $configRepo->getCategory('core_update'); $this->assertEquals('https://github.com/custom/repo', $coreUpdateSettings['github_url']); $this->assertEquals('ghp_custom_token', $coreUpdateSettings['github_token']); } /** * site_logo가 Attachment 객체 배열로 전송되어도 검증 통과 * * initLocal로 복사된 Attachment 객체가 폼 데이터에 포함되어 * 정수 검증 실패하는 버그 회귀 방지 */ public function test_store_general_accepts_site_logo_as_attachment_objects(): void { // Attachment 레코드 생성 $attachment = Attachment::factory()->create([ 'collection' => 'site_logo', ]); // 프론트엔드에서 전송되는 형태: Attachment 객체 배열 $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Asia/Seoul', 'language' => 'ko', 'site_logo' => [ [ 'id' => $attachment->id, 'hash' => $attachment->hash, 'original_filename' => $attachment->original_filename, 'mime_type' => $attachment->mime_type, 'size' => $attachment->size, 'download_url' => '/attachments/'.$attachment->hash, 'order' => 0, 'is_image' => true, ], ], ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * site_logo가 정수 ID 배열로 전송되어도 정상 동작 */ public function test_store_general_accepts_site_logo_as_integer_ids(): void { $attachment = Attachment::factory()->create([ 'collection' => 'site_logo', ]); $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'general', 'general' => [ 'site_name' => 'Test Site', 'site_url' => 'https://test.example.com', 'admin_email' => 'admin@example.com', 'timezone' => 'Asia/Seoul', 'language' => 'ko', 'site_logo' => [$attachment->id], ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } // ======================================================================== // 드라이버 탭 - 웹소켓 필수 필드 검증 테스트 // ======================================================================== /** * 웹소켓 활성화 시 앱 ID, 앱 키, 앱 시크릿 필수 검증 */ public function test_store_validates_websocket_required_fields_when_enabled(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'drivers', 'drivers' => [ 'websocket_enabled' => true, 'websocket_app_id' => '', 'websocket_app_key' => '', 'websocket_app_secret' => '', 'websocket_host' => 'localhost', 'websocket_port' => 8080, 'websocket_scheme' => 'https', ], ]); $response->assertStatus(422) ->assertJsonValidationErrors([ 'drivers.websocket_app_id', 'drivers.websocket_app_key', 'drivers.websocket_app_secret', ]); } /** * 웹소켓 비활성화 시 앱 ID, 앱 키, 앱 시크릿 비필수 */ public function test_store_allows_empty_websocket_fields_when_disabled(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'drivers', 'drivers' => [ 'storage_driver' => 'local', 'cache_driver' => 'file', 'session_driver' => 'file', 'queue_driver' => 'database', 'log_driver' => 'daily', 'log_level' => 'error', 'websocket_enabled' => false, 'websocket_app_id' => '', 'websocket_app_key' => '', 'websocket_app_secret' => '', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 웹소켓 활성화 + 모든 필수 필드 입력 시 저장 성공 */ public function test_store_saves_websocket_settings_with_all_required_fields(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'drivers', 'drivers' => [ 'storage_driver' => 'local', 'cache_driver' => 'file', 'session_driver' => 'file', 'queue_driver' => 'database', 'log_driver' => 'daily', 'log_level' => 'error', 'websocket_enabled' => true, 'websocket_app_id' => 'test-app-id', 'websocket_app_key' => 'test-app-key', 'websocket_app_secret' => 'test-app-secret', 'websocket_host' => 'localhost', 'websocket_port' => 8080, 'websocket_scheme' => 'https', ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 드라이버 탭 저장 왕복에 S3 신규 키(s3_endpoint/s3_use_path_style)가 포함되어야 합니다 (#563). */ public function test_store_drivers_roundtrip_includes_new_s3_keys(): void { $response = $this->authRequest()->postJson('/api/admin/settings', [ '_tab' => 'drivers', 'drivers' => [ 'storage_driver' => 's3', 's3_bucket' => 'roundtrip-bucket', 's3_region' => 'auto', 's3_access_key' => 'roundtrip-key', 's3_secret_key' => 'roundtrip-secret', 's3_endpoint' => 'https://roundtrip.r2.cloudflarestorage.com', 's3_use_path_style' => true, 'cache_driver' => 'file', 'session_driver' => 'file', 'queue_driver' => 'database', 'log_driver' => 'daily', 'log_level' => 'error', 'websocket_enabled' => false, ], ]); $response->assertStatus(200) ->assertJson(['success' => true]); $drivers = $this->authRequest()->getJson('/api/admin/settings') ->assertStatus(200) ->json('data.drivers'); $this->assertSame('auto', $drivers['s3_region'] ?? null); $this->assertSame('https://roundtrip.r2.cloudflarestorage.com', $drivers['s3_endpoint'] ?? null); $this->assertTrue((bool) ($drivers['s3_use_path_style'] ?? false)); } // ======================================================================== // 시스템 정보 probe 실패 격리 테스트 — gnuboard/g7#59 // (구조/캐시/정상값은 위 systemInfo 테스트가 이미 커버. 여기서는 호스팅 // disable_functions/open_basedir 로 probe 가 실패해도 전체 200 을 유지하는지 검증) // ======================================================================== /** * 인증 없이 시스템 정보 조회 시 401 반환 (probe 실행 전 인증 가드 차단) * * @scenario probe_item=cpu_info,probe_outcome=success,auth_state=unauthenticated * * @effects unauthenticated_returns_401 */ public function test_system_info_returns_401_without_authentication(): void { $response = $this->getJson('/api/admin/settings/system-info'); $response->assertStatus(401); } /** * read 권한 없이 시스템 정보 조회 시 403 반환 (probe 실행 전 권한 가드 차단) * * @scenario probe_item=cpu_info,probe_outcome=success,auth_state=authenticated_without_permission * * @effects without_permission_returns_403 */ public function test_system_info_returns_403_without_permission(): void { $user = User::factory()->create(); $adminRole = Role::firstOrCreate( ['identifier' => 'admin'], [ 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), 'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', 'is_active' => true, ] ); $readPermission = Permission::where('identifier', 'core.settings.read')->first(); if ($adminRole && $readPermission) { $adminRole->permissions()->detach($readPermission->id); } $user->roles()->attach($adminRole->id, [ 'assigned_at' => now(), 'assigned_by' => null, ]); $token = $user->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->getJson('/api/admin/settings/system-info'); $response->assertStatus(403); } /** * probe 실패 격리(핵심 회귀 #59): 개별 항목 수집이 예외/Error 를 던져도 * API 전체는 500 이 아니라 200 을 반환하고, 실패 항목만 'unknown' 폴백으로 채운다. * * disable_functions/open_basedir 호스팅에서 php_uname·shell_exec·disk_*_space 가 * Error 를 던지는 상황을 getCpuInfo() 로 시뮬레이션한다. safeSystemProbe 가 이를 * 격리하지 못하면 buildSystemInfo → getSystemInfo → 컨트롤러로 전파되어 500 이 난다. * * @scenario probe_item=cpu_info,probe_outcome=throws_error,auth_state=authenticated_with_permission * * @effects failing_probe_isolated_and_api_returns_200 * @effects failed_item_filled_with_unknown_fallback */ public function test_system_info_isolates_failing_probe_and_returns_200(): void { // 캐시된 정상 페이로드가 있으면 buildSystemInfo 가 재실행되지 않으므로 먼저 비운다. $cache = $this->app->make(CacheInterface::class); $cache->forget('settings.system_info.'.app()->getLocale()); $service = new class($this->app) extends SettingsService { public function __construct($app) { parent::__construct( $app->make(ConfigRepositoryInterface::class), $app->make(AttachmentRepositoryInterface::class), $app->make(CacheInterface::class), ); } protected function getCpuInfo(): string { throw new \Error('Call to undefined function shell_exec() (disable_functions)'); } }; $this->app->instance(SettingsService::class, $service); $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); // 실패한 cpu_info 만 unknown 폴백, 나머지 항목은 정상 수집되어 전체 200 유지. $response->assertStatus(200) ->assertJson([ 'success' => true, 'data' => [ 'cpu_info' => __('common.unknown'), ], ]) ->assertJsonStructure([ 'data' => [ 'memory_usage' => ['total', 'used', 'free', 'percentage'], 'disk_usage' => ['total', 'used', 'free', 'percentage'], 'php_extensions' => ['required', 'optional'], 'server_time', ], ]); } /** * OPcache 상태가 시스템 정보 페이로드에 포함된다. * * 관리자 환경설정 > 정보 탭이 이 값으로 상태 행과 성능 저하 경고 블록을 렌더한다. * enabled 는 true/false 외에 null("확인 불가" — ini_get 차단 환경)을 가질 수 있다. * * @scenario probe_item=opcache,probe_outcome=success,auth_state=authenticated_with_permission * * @effects opcache_status_exposed_in_system_info */ public function test_system_info_includes_opcache_status(): void { $cache = $this->app->make(CacheInterface::class); $cache->forget('settings.system_info.'.app()->getLocale()); $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ 'opcache' => ['loaded', 'enabled'], ], ]); $opcache = $response->json('data.opcache'); $this->assertIsBool($opcache['loaded']); $this->assertTrue( $opcache['enabled'] === null || is_bool($opcache['enabled']), 'opcache.enabled 는 true/false/null 중 하나여야 합니다.' ); } // 아래 test_system_info_reports_each_opcache_state 의 DataProvider 가 함께 검증하는 // 나머지 3개 조합 (docblock 은 @scenario 를 1건만 인식하므로 라인 주석으로 병기). // @scenario opcache_state=loaded_but_disabled, surface=system_info_api // @effects opcache_status_exposed_in_system_info // @scenario opcache_state=not_loaded, surface=system_info_api // @effects opcache_status_exposed_in_system_info // @scenario opcache_state=ini_get_blocked, surface=system_info_api // @effects opcache_status_exposed_in_system_info /** * OPcache 판정 결과가 API 페이로드에 그대로 실려 나온다. * * 관리자 정보 탭이 이 값으로 상태 행과 경고 블록을 렌더하므로, 네 가지 상태가 * 각각 구별되어 전달되어야 한다. 특히 loaded_but_disabled(확장은 로드됐으나 * opcache.enable=0)와 ini_get_blocked(null)이 뭉개지면 화면 판단이 무너진다. * * @scenario opcache_state=enabled, surface=system_info_api * * @effects opcache_status_exposed_in_system_info */ #[DataProvider('opcacheStateProvider')] public function test_system_info_reports_each_opcache_state(array $probe): void { $cache = $this->app->make(CacheInterface::class); $cache->forget('settings.system_info.'.app()->getLocale()); $service = new class($this->app, $probe) extends SettingsService { private array $stub; public function __construct($app, array $stub) { parent::__construct( $app->make(ConfigRepositoryInterface::class), $app->make(AttachmentRepositoryInterface::class), $app->make(CacheInterface::class), ); $this->stub = $stub; } protected function getOpcacheStatus(): array { return $this->stub; } }; $this->app->instance(SettingsService::class, $service); $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); $response->assertStatus(200); $this->assertSame($probe, $response->json('data.opcache')); } /** * @return array */ public static function opcacheStateProvider(): array { return [ 'enabled' => [['loaded' => true, 'enabled' => true]], 'loaded_but_disabled' => [['loaded' => true, 'enabled' => false]], 'not_loaded' => [['loaded' => false, 'enabled' => false]], 'ini_get_blocked' => [['loaded' => true, 'enabled' => null]], ]; } /** * OPcache probe 가 실패해도 격리되어 전체 200 이 유지된다. * * ini_get 이 disable_functions 로 차단된 호스팅에서 probe 하나가 죽어 * system-info API 전체가 500 이 나는 것을 방지한다. * * @scenario probe_item=opcache,probe_outcome=throws_error,auth_state=authenticated_with_permission * * @effects failing_probe_isolated_and_api_returns_200 * @effects failed_item_filled_with_unknown_fallback */ public function test_system_info_isolates_failing_opcache_probe(): void { $cache = $this->app->make(CacheInterface::class); $cache->forget('settings.system_info.'.app()->getLocale()); $service = new class($this->app) extends SettingsService { public function __construct($app) { parent::__construct( $app->make(ConfigRepositoryInterface::class), $app->make(AttachmentRepositoryInterface::class), $app->make(CacheInterface::class), ); } protected function getOpcacheStatus(): array { throw new \Error('Call to undefined function ini_get() (disable_functions)'); } }; $this->app->instance(SettingsService::class, $service); $response = $this->authRequest()->getJson('/api/admin/settings/system-info'); $response->assertStatus(200) ->assertJson([ 'success' => true, 'data' => [ 'opcache' => ['loaded' => false, 'enabled' => null], ], ]); } /** * 사용량 조회 실패 시 폴백 배열 구조 검증: unknownUsage 는 total/used/free 를 * 'unknown' 으로, percentage 를 0 으로 채워 memory_usage·disk_usage 형식을 유지한다. * * @scenario probe_item=memory_usage,probe_outcome=throws_error,auth_state=authenticated_with_permission * * @effects usage_array_fallback_keeps_total_used_free_unknown_and_percentage_zero */ public function test_unknown_usage_fallback_keeps_usage_array_shape(): void { $service = $this->app->make(SettingsService::class); $ref = new \ReflectionMethod($service, 'unknownUsage'); $ref->setAccessible(true); $fallback = $ref->invoke($service); $this->assertSame( ['total', 'used', 'free', 'percentage'], array_keys($fallback) ); $this->assertSame(__('common.unknown'), $fallback['total']); $this->assertSame(__('common.unknown'), $fallback['used']); $this->assertSame(__('common.unknown'), $fallback['free']); $this->assertSame(0, $fallback['percentage']); } /** * safeSystemProbe 단위 검증: 콜백이 \Error 를 던지면 전파하지 않고 폴백을 반환한다. */ public function test_safe_system_probe_returns_fallback_on_error(): void { $service = $this->app->make(SettingsService::class); $ref = new \ReflectionMethod($service, 'safeSystemProbe'); $ref->setAccessible(true); $result = $ref->invoke($service, 'cpu_info', function () { throw new \Error('disable_functions'); }, 'FALLBACK'); $this->assertSame('FALLBACK', $result); } // ======================================================================== // 설정 복원 (restore) 검증 — RestoreSettingsRequest 이전 회귀 // ======================================================================== /** * 인증 없이 설정 복원 요청 시 401 반환 */ public function test_restore_returns_401_without_authentication(): void { $response = $this->postJson('/api/admin/settings/restore', ['backup_path' => 'foo']); $response->assertStatus(401); } /** * update 권한 없이 설정 복원 요청 시 403 반환 */ public function test_restore_returns_403_without_update_permission(): void { $user = $this->createAdminUser(['core.settings.read']); $token = $user->createToken('test-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->postJson('/api/admin/settings/restore', ['backup_path' => 'foo']); $response->assertStatus(403); } /** * backup_path 누락 시 FormRequest 검증으로 422 반환 * * 회귀: 검증을 컨트롤러 내 empty() 체크에서 RestoreSettingsRequest 로 이전한 뒤에도 * 백업 경로 미입력이 422 로 차단되는지 확인한다. */ public function test_restore_returns_422_when_backup_path_missing(): void { $response = $this->authRequest()->postJson('/api/admin/settings/restore', []); $response->assertStatus(422) ->assertJsonValidationErrors(['backup_path']); } }