false]); $this->mockConfigRepositoryDebugMode(true); $response = $this->postJson('/_boost/g7-debug/dump-state', ['test' => true]); $response->assertStatus(200); $response->assertJson(['status' => 'success']); } /** * 양쪽 모두 꺼져 있으면 403 이어야 한다 (500 이 아니라). */ public function test_dump_state_returns_403_when_debug_disabled(): void { config(['app.debug' => false]); $this->mockConfigRepositoryDebugMode(false); $response = $this->postJson('/_boost/g7-debug/dump-state', ['test' => true]); $response->assertStatus(403); $response->assertJson(['status' => 'error']); // 번역 키가 해석되지 않으면 Laravel 은 키 문자열을 그대로 돌려준다 — 오류 없이 // 사용자에게 `devtools.debug_disabled` 가 노출되므로 해석 여부를 단언한다. $this->assertNotSame( 'devtools.debug_disabled', $response->json('message'), '번역 키가 해석되지 않았습니다 (lang/{ko,en}/devtools.php 누락).' ); } /** * `browser-logs` 도 동일 게이트를 사용한다. */ public function test_browser_logs_returns_403_when_debug_disabled(): void { config(['app.debug' => false]); $this->mockConfigRepositoryDebugMode(false); $response = $this->postJson('/_boost/browser-logs', ['logs' => []]); $response->assertStatus(403); } /** * `.env` 의 `APP_DEBUG=true` 만으로도 통과한다 (환경설정 조회 불필요). */ public function test_gate_passes_on_app_debug_without_settings_lookup(): void { config(['app.debug' => true]); $repository = Mockery::mock(ConfigRepositoryInterface::class); $repository->shouldNotReceive('get'); $this->app->instance(ConfigRepositoryInterface::class, $repository); $this->assertTrue(DebugGate::isEnabled()); } /** * `DELETE clear` 는 디버그 OFF 에서 403 이어야 하고, 덤프 파일을 지우지 않아야 한다. * * 수정 전: 미인증 200 + `storage/debug-dump` 전체 삭제 (게이트 전무). * 상태 코드만 단언하면 "403 을 돌려주면서 이미 지운" 회귀를 놓치므로 파일 불변까지 본다. */ public function test_clear_returns_403_and_keeps_dump_files_when_debug_disabled(): void { config(['app.debug' => false]); $this->mockConfigRepositoryDebugMode(false); $sentinel = $this->seedDumpSentinel(); $before = count(File::allFiles(storage_path('debug-dump'))); $response = $this->deleteJson('/_boost/g7-debug/clear'); $response->assertStatus(403); $response->assertJson(['status' => 'error']); $this->assertNotSame( 'devtools.debug_disabled', $response->json('message'), '번역 키가 해석되지 않았습니다 (lang/{ko,en}/devtools.php 누락).' ); $this->assertNotEmpty($response->json('message')); $this->assertFileExists( $sentinel, 'DELETE clear 가 403 을 돌려주기 전에 이미 덤프를 삭제했습니다 (게이트가 핸들러 안에 있으면 발생).' ); $this->assertSame( $before, count(File::allFiles(storage_path('debug-dump'))), 'DELETE clear 가 403 임에도 덤프 파일 수가 변했습니다.' ); } /** * `DELETE clear` 는 디버그 ON 에서는 종전대로 동작해야 한다 (게이트 이관이 기능을 죽이지 않는다). * * 실 `storage/debug-dump` 를 비우지만 클래스 teardown 이 백업본으로 복원한다. */ public function test_clear_succeeds_when_debug_enabled(): void { config(['app.debug' => true]); $sentinel = $this->seedDumpSentinel(); $response = $this->deleteJson('/_boost/g7-debug/clear'); $response->assertStatus(200); $response->assertJson(['status' => 'success']); $this->assertFileDoesNotExist($sentinel, 'debug ON 인데 clear 가 덤프를 지우지 않았습니다.'); } /** * MCP 조회용 GET 4종도 디버그 OFF 에서 403 JSON 이어야 한다. * * 수정 전: 게이트 부재 + `routes/web.php` User catch-all shadow 로 **HTML 404**. * "404 니까 안전하다" 는 우연이었다 — 제외 패턴이 `_boost` 를 빠뜨린 결과일 뿐, * catch-all 이 바뀌면 그대로 열린다. 그래서 403 JSON 을 단언해 게이트 도달을 증명한다. */ public function test_get_endpoints_return_403_json_when_debug_disabled(): void { config(['app.debug' => false]); $this->mockConfigRepositoryDebugMode(false); foreach (['state', 'actions', 'cache', 'change-detection'] as $endpoint) { $response = $this->getJson('/_boost/g7-debug/'.$endpoint); $response->assertStatus(403); $response->assertJson(['status' => 'error']); $this->assertNotSame( 'devtools.debug_disabled', $response->json('message'), "번역 키가 해석되지 않았습니다 ({$endpoint})." ); } } /** * GET 4종은 디버그 ON 에서 정상 응답한다 (게이트만 걸고 기능은 유지 — 설계 결정). */ public function test_get_endpoints_respond_when_debug_enabled(): void { config(['app.debug' => true]); foreach (['state', 'actions', 'cache', 'change-detection'] as $endpoint) { $response = $this->getJson('/_boost/g7-debug/'.$endpoint); $response->assertStatus(200); $this->assertContains( $response->json('status'), ['success', 'no_data'], "{$endpoint} 응답의 status 가 예상 밖입니다." ); } } /** * `log` 엔드포인트도 동일 게이트를 사용한다 (POST 3종 중 나머지 하나). */ public function test_log_returns_403_when_debug_disabled(): void { config(['app.debug' => false]); $this->mockConfigRepositoryDebugMode(false); $response = $this->postJson('/_boost/g7-debug/log', ['type' => 'debug', 'data' => []]); $response->assertStatus(403); $response->assertJson(['status' => 'error']); } /** * 덤프 디렉토리에 이번 테스트용 표식 파일을 만든다. * * @return string 생성된 표식 파일 절대경로 */ private function seedDumpSentinel(): string { $dir = storage_path('debug-dump'); if (! File::isDirectory($dir)) { File::makeDirectory($dir, 0755, true); } $path = $dir.'/gate-sentinel.json'; File::put($path, json_encode(['sentinel' => true])); return $path; } /** * `ConfigRepositoryInterface` 를 `debug.mode` 고정값으로 대체한다. * * @param bool $enabled debug.mode 값 * @return void */ private function mockConfigRepositoryDebugMode(bool $enabled): void { $repository = Mockery::mock(ConfigRepositoryInterface::class); $repository->shouldReceive('get') ->with('debug.mode', false) ->andReturn($enabled); $repository->shouldReceive('get')->andReturnUsing( static fn (string $key, mixed $default = null): mixed => $default ); $this->app->instance(ConfigRepositoryInterface::class, $repository); } /** * 디렉토리를 재귀 복사한다 (Laravel 부트스트랩에 의존하지 않는 순수 구현). * * @param string $from 원본 디렉토리 * @param string $to 대상 디렉토리 * @return void */ private static function copyDirectory(string $from, string $to): void { if (! is_dir($from)) { return; } if (! is_dir($to) && ! @mkdir($to, 0755, true) && ! is_dir($to)) { return; } foreach (scandir($from) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $src = $from.'/'.$entry; $dst = $to.'/'.$entry; is_dir($src) ? self::copyDirectory($src, $dst) : @copy($src, $dst); } } /** * 디렉토리를 재귀 삭제한다. * * @param string $dir 삭제할 디렉토리 절대경로 * @return void */ private static function removeDirectory(string $dir): void { if (! is_dir($dir)) { return; } foreach (scandir($dir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir.'/'.$entry; is_dir($path) ? self::removeDirectory($path) : @unlink($path); } @rmdir($dir); } }