Files
Gnuboard7/tests/Feature/Api/Admin/SettingsControllerTest.php
T
HeuJung ea58c606a2 fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원
공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이
동작하지 않던 결함의 전면 수정.

- S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함
 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단
 (부트 시 확장 부재 감지 → predis 자동 폴백)
- storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환
 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙)
- 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체
 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500
 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control)
- S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정
 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와
 동일 설정(endpoint/path-style)으로 정렬
- 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께
 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정)
- 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만
 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소
- env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을
 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리)
- 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및
 websocket 유령 설정 키 제거
- 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
2026-08-13 15:15:19 +09:00

1762 lines
63 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Models\Attachment;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\SettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* AdminSettingsController 테스트
*
* 시스템 설정 관리 API 엔드포인트를 테스트합니다.
*/
class SettingsControllerTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->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<string, array{0: array{loaded: bool, enabled: bool|null}}>
*/
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']);
}
}