공개 (@Tuwasduliebst 제보) 대응. - ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰, filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인, 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본 설정 백필 upgrade step - board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI, 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정 - ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정 - page: 임시 첨부 GC - 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자 메뉴 설치·업데이트 자동 동기화(F1·F7) - 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서, hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
1766 lines
63 KiB
PHP
1766 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\AttachmentService;
|
|
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),
|
|
$app->make(AttachmentService::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),
|
|
$app->make(AttachmentService::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),
|
|
$app->make(AttachmentService::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']);
|
|
}
|
|
}
|