fix(core): 시스템 정보 조회가 일부 호스팅에서 전체 실패하던 문제 수정
disable_functions/open_basedir 제약 호스팅에서 CPU·메모리·디스크 등 개별 probe 실패가 system-info API 전체를 500 으로 떨구던 결함 수정. buildSystemInfo 각 항목을 safeSystemProbe 로 격리해 실패 항목만 unknown 폴백으로 채우고 나머지는 정상 반환. 개별 실패는 Log::warning, 전체 실패는 컨트롤러 Log::error 로 기록. ( 대시보드 격리가 settings 에 미적용되어 재발한 공개 대응) 동반: SettingsController::restore 를 RestoreSettingsRequest 로 이전 (base Request + 컨트롤러 내 검증 제거), 미정의였던 backup_path_required· restore_* lang 키 정의(ko/en/ja).
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
|
||||
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
|
||||
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
|
||||
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.0] - 2026-07-01
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
|
||||
use App\Http\Requests\Settings\RestoreSettingsRequest;
|
||||
use App\Http\Requests\Settings\SaveSettingsRequest;
|
||||
use App\Http\Requests\Settings\TestDriverConnectionRequest;
|
||||
use App\Http\Requests\Settings\TestMailRequest;
|
||||
@@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
@@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController
|
||||
$systemInfo = $this->settingsService->getSystemInfo();
|
||||
|
||||
return $this->success('common.success', $systemInfo);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('common.error_occurred', 500, $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController
|
||||
/**
|
||||
* 백업에서 설정을 복원합니다.
|
||||
*
|
||||
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
|
||||
* @return JsonResponse 복원 결과 JSON 응답
|
||||
*/
|
||||
public function restore(Request $request): JsonResponse
|
||||
public function restore(RestoreSettingsRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$backupPath = $request->input('backup_path');
|
||||
|
||||
if (empty($backupPath)) {
|
||||
return $this->error('settings.backup_path_required', 422);
|
||||
}
|
||||
$backupPath = $request->validated('backup_path');
|
||||
|
||||
$result = $this->settingsService->restoreSettings($backupPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 설정 복원 요청 FormRequest
|
||||
*
|
||||
* 백업에서 설정을 복원할 때 백업 경로를 검증합니다.
|
||||
* 권한은 라우트의 permission 미들웨어(core.settings.update)가 담당합니다.
|
||||
*/
|
||||
class RestoreSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 요청 권한을 확인합니다.
|
||||
*
|
||||
* 권한 검사는 permission 미들웨어 체인에 위임하므로 항상 true 를 반환합니다.
|
||||
*
|
||||
* @return bool 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'backup_path' => 'required|string',
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 오류 메시지를 반환합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'backup_path.required' => __('settings.backup_path_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -822,30 +822,74 @@ class SettingsService
|
||||
private function buildSystemInfo(): array
|
||||
{
|
||||
return [
|
||||
'os_info' => php_uname('s').' '.php_uname('r'),
|
||||
// php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다.
|
||||
'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')),
|
||||
'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'mysql_version' => $this->getDatabaseInfo(),
|
||||
'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')),
|
||||
'g7_version' => config('app.version', '1.0.0'),
|
||||
'g7_release_year' => config('app.release_year', '2026'),
|
||||
'laravel_version' => app()->version(),
|
||||
'environment' => app()->environment(),
|
||||
'cpu_info' => $this->getCpuInfo(),
|
||||
'memory_usage' => $this->getMemoryUsage(),
|
||||
'disk_usage' => $this->getDiskUsage(),
|
||||
'php_memory_limit' => ini_get('memory_limit'),
|
||||
'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'),
|
||||
'upload_max_filesize' => ini_get('upload_max_filesize'),
|
||||
'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')),
|
||||
'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()),
|
||||
'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()),
|
||||
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
|
||||
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
|
||||
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
|
||||
'install_path' => base_path(),
|
||||
'config_path' => storage_path('app/settings'),
|
||||
'log_path' => storage_path('logs'),
|
||||
'upload_path' => storage_path('app/public'),
|
||||
'php_extensions' => $this->getPhpExtensions(),
|
||||
'database_config' => $this->getDatabaseConfig(),
|
||||
'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]),
|
||||
'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]),
|
||||
'timezone' => config('app.timezone'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 정보 probe 를 안전하게 실행합니다.
|
||||
*
|
||||
* probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를
|
||||
* 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집
|
||||
* 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다.
|
||||
*
|
||||
* @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info')
|
||||
* @param callable $cb 실행할 probe 콜백
|
||||
* @param mixed $fallback 실패 시 반환할 폴백 값
|
||||
* @return mixed probe 결과 또는 폴백
|
||||
*/
|
||||
private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed
|
||||
{
|
||||
try {
|
||||
return $cb();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('시스템 정보 수집 실패', [
|
||||
'item' => $label,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다.
|
||||
*
|
||||
* total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다.
|
||||
*
|
||||
* @return array 사용량 폴백 배열
|
||||
*/
|
||||
private function unknownUsage(): array
|
||||
{
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
@@ -949,11 +993,16 @@ class SettingsService
|
||||
try {
|
||||
$connection = DB::connection();
|
||||
$driver = $connection->getDriverName();
|
||||
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown';
|
||||
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown');
|
||||
|
||||
return ucfirst($driver).' '.$version;
|
||||
} catch (\Exception $e) {
|
||||
return 'Unknown';
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('시스템 정보 수집 실패', [
|
||||
'item' => 'mysql_version',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return __('common.unknown');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,12 +1048,7 @@ class SettingsService
|
||||
}
|
||||
|
||||
if ($total <= 0) {
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
return $this->unknownUsage();
|
||||
}
|
||||
|
||||
$used = max(0, $total - $free);
|
||||
@@ -1034,12 +1078,7 @@ class SettingsService
|
||||
$free = is_numeric($freeRaw) ? (int) $freeRaw : 0;
|
||||
|
||||
if ($total <= 0) {
|
||||
return [
|
||||
'total' => __('common.unknown'),
|
||||
'used' => __('common.unknown'),
|
||||
'free' => __('common.unknown'),
|
||||
'percentage' => 0,
|
||||
];
|
||||
return $this->unknownUsage();
|
||||
}
|
||||
|
||||
$used = max(0, $total - $free);
|
||||
@@ -1077,7 +1116,7 @@ class SettingsService
|
||||
*
|
||||
* @return string CPU 정보 문자열
|
||||
*/
|
||||
private function getCpuInfo(): string
|
||||
protected function getCpuInfo(): string
|
||||
{
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- 설정 복원 관련 안내 메시지 일본어 번역 추가 (`settings.backup_path_required`·`restore_success`·`restore_failed`·`restore_error`) — 백업 경로 미입력 안내와 설정 복원 성공/실패 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -145,4 +145,8 @@ return [
|
||||
'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
|
||||
'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
|
||||
],
|
||||
'backup_path_required' => 'バックアップパスを入力してください。',
|
||||
'restore_success' => '設定が正常に復元されました。',
|
||||
'restore_failed' => '設定の復元に失敗しました。',
|
||||
'restore_error' => '設定の復元中にエラーが発生しました。',
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -21,6 +21,10 @@ return [
|
||||
'backup_success' => 'Database backup started successfully.',
|
||||
'backup_failed' => 'Failed to start database backup.',
|
||||
'backup_error' => 'An error occurred while backing up database.',
|
||||
'backup_path_required' => 'Please enter a backup path.',
|
||||
'restore_success' => 'Settings restored successfully.',
|
||||
'restore_failed' => 'Failed to restore settings.',
|
||||
'restore_error' => 'An error occurred while restoring settings.',
|
||||
'save_individual_failed' => 'Settings save failed: :error',
|
||||
|
||||
// App key related messages
|
||||
|
||||
@@ -21,6 +21,10 @@ return [
|
||||
'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.',
|
||||
'backup_failed' => '데이터베이스 백업에 실패했습니다.',
|
||||
'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.',
|
||||
'backup_path_required' => '백업 경로를 입력해주세요.',
|
||||
'restore_success' => '설정이 성공적으로 복원되었습니다.',
|
||||
'restore_failed' => '설정 복원에 실패했습니다.',
|
||||
'restore_error' => '설정 복원 중 오류가 발생했습니다.',
|
||||
'save_individual_failed' => '설정 저장에 실패했습니다: :error',
|
||||
|
||||
// 앱 키 관련 메시지
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
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;
|
||||
@@ -732,6 +734,10 @@ class SettingsControllerTest extends TestCase
|
||||
|
||||
/**
|
||||
* 시스템 정보 응답 구조 검증
|
||||
*
|
||||
* @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
|
||||
{
|
||||
@@ -1365,4 +1371,210 @@ class SettingsControllerTest extends TestCase
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 시스템 정보 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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 조회 실패 시 폴백 배열 구조 검증: 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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# audit:allow test-scenario-coverage reason: axes cross product 중 의미적으로 핵심인 격리/폴백/전체성공 케이스만 docblock 매핑. probe_item 축의 각 항목(os_info/cpu_info/memory_usage/disk_usage 등)은 모두 동일한 safeSystemProbe 격리 경로를 타므로 cpu_info 1건으로 대표 검증(나머지는 형식적 확장). E2E axis 없음 — 순수 백엔드 JSON API 로 레이아웃/프론트 렌더 변경 없음.
|
||||
|
||||
feature: 관리자 시스템 정보(system-info) probe 실패 격리
|
||||
|
||||
description: |
|
||||
`GET /api/admin/settings/system-info` 는 OS·CPU·메모리·디스크·PHP 확장·DB 등
|
||||
여러 시스템 정보를 수집한다. 이 중 일부 probe (php_uname / shell_exec / disk_*_space
|
||||
등) 는 호스팅의 disable_functions / open_basedir 제약으로 예외·Error 를 던질 수 있다.
|
||||
|
||||
SettingsService::safeSystemProbe 가 각 항목 수집을 개별 격리하여, 한 항목의 실패가
|
||||
전체 API 를 500 으로 떨어뜨리지 않고 해당 항목만 'unknown'(사용량 배열은 percentage 0)
|
||||
폴백으로 채우도록 한다. 개별 실패는 Log::warning, 격리를 뚫은 전체 실패는 컨트롤러
|
||||
Log::error 로 기록된다.
|
||||
|
||||
공개 이슈 gnuboard/g7#59 의 회귀 가드 — 공개 #40(대시보드 리소스 API) 격리가
|
||||
DashboardService 에만 적용되고 SettingsService::buildSystemInfo 에는 disk 한 곳만
|
||||
부분 보강되어, CPU/메모리 등 다른 probe 로 재발한 결함의 재발 차단.
|
||||
|
||||
axes:
|
||||
probe_item: [cpu_info, os_info, memory_usage, disk_usage, php_memory_limit, mysql_version]
|
||||
probe_outcome: [success, throws_error, throws_exception]
|
||||
auth_state: [authenticated_with_permission, authenticated_without_permission, unauthenticated]
|
||||
|
||||
exclusions:
|
||||
- { probe_item: os_info, probe_outcome: throws_exception, reason: "각 probe_item 은 동일 safeSystemProbe 경로 — cpu_info 대표 검증으로 커버, 나머지는 형식적 확장" }
|
||||
- { auth_state: unauthenticated, probe_outcome: throws_error, reason: "인증 가드가 probe 실행 전에 401/403 으로 차단 — probe_outcome 과 무관" }
|
||||
|
||||
effects:
|
||||
- failing_probe_isolated_and_api_returns_200
|
||||
- failed_item_filled_with_unknown_fallback
|
||||
- usage_array_fallback_keeps_total_used_free_unknown_and_percentage_zero
|
||||
- all_probes_success_returns_full_payload
|
||||
- unauthenticated_returns_401
|
||||
- without_permission_returns_403
|
||||
|
||||
# E2E axis 없음: system-info 는 순수 백엔드 JSON API. 레이아웃 JSON / 컴포넌트 TSX /
|
||||
# 엔진 TS / 핸들러 변경이 없으므로 Playwright spec 동반 의무(frontend-change-requires-e2e)
|
||||
# 대상 아님. 격리 검증의 본체는 Feature/Unit 테스트가 담당.
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Api/Admin/SettingsControllerTest.php
|
||||
Reference in New Issue
Block a user