공개 제보 — 파일 스토리지에서 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)
1762 lines
63 KiB
PHP
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']);
|
|
}
|
|
}
|