dispatch('POST', '/_boost/g7-debug/dump-state', json_encode([ self::PROBE_SECTION => ['marker' => $marker], ], JSON_UNESCAPED_UNICODE)); $this->assertTrue( $result['routesAreCached'], '라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).' ); $this->assertStringContainsString( 'route-cache-', $result['cachedRoutesPath'], '자식 프로세스가 임시 캐시가 아니라 실 캐시를 사용했습니다.' ); $this->assertSame( 200, $result['status'], "라우트 캐시 상태에서 dump-state 가 200 이어야 합니다.\nbody: {$result['body']}" ); $this->assertStringNotContainsString('undefined function', $result['body']); // 200 만으로는 부족하다 — 페이로드가 비어 있어도 legacy 경로는 200 을 돌려준다. // 위임(DebugDumpWriter)이 실제로 수행됐음을 파일로 확인한다. $probePath = base_path('storage/debug-dump/'.self::PROBE_FILE); $this->assertFileExists($probePath, '덤프 위임이 파일을 쓰지 않았습니다.'); $this->assertStringContainsString( $marker, (string) file_get_contents($probePath), '덤프 파일에 이번 요청의 페이로드가 담기지 않았습니다 (본문이 자식 프로세스에 전달되지 않음).' ); } /** * `_boost/browser-logs` 는 라우트 캐시가 걸린 상태에서도 POST 를 받아야 한다. * * 수정 전: HTTP 405 — boost 프로바이더가 boot() 에서 등록한 라우트가 캐시 교체로 폐기되고, * `routes/web.php` 의 SPA catch-all(GET 전용)만 남아 POST 를 튕겨낸다. */ public function test_boost_browser_logs_survives_route_cache(): void { $result = $this->dispatch('POST', '/_boost/browser-logs', json_encode([ 'logs' => [[ 'type' => 'log', 'timestamp' => '2026-01-01T00:00:00+00:00', 'data' => ['harness'], 'url' => 'https://example.test/harness', 'userAgent' => 'harness', ]], ], JSON_UNESCAPED_UNICODE)); $this->assertTrue( $result['routesAreCached'], '라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).' ); $this->assertStringContainsString('route-cache-', $result['cachedRoutesPath']); $this->assertSame( 200, $result['status'], "라우트 캐시 상태에서 browser-logs 가 200 이어야 합니다.\nbody: {$result['body']}" ); } /** * 캐시된 클로저 안에서 `__()` 번역 헬퍼가 해석되어야 한다. * * 전역 함수와 달리 `__()` 는 Composer 의 `files` 오토로드로 등록되므로 라우트 파일 로드 * 여부와 무관하다 — 다만 "라우트 파일에 있던 것은 캐시에서 사라진다" 는 이 이슈의 성질상 * 실제로 해석되는지를 단언해 둔다. 미해석 시 Laravel 은 오류 없이 키 문자열을 그대로 * 응답에 실으므로, 단언이 없으면 `devtools.connection_ok` 노출을 알아챌 방법이 없다. */ public function test_translation_helper_resolves_inside_cached_closure(): void { $result = $this->dispatch('POST', '/_boost/g7-debug/dump-state', json_encode([ 'test' => true, ], JSON_UNESCAPED_UNICODE)); $this->assertTrue($result['routesAreCached']); $this->assertSame(200, $result['status'], "body: {$result['body']}"); $payload = json_decode($result['body'], true); $this->assertIsArray($payload, "응답 JSON 파싱 실패.\nbody: {$result['body']}"); $this->assertNotSame( 'devtools.connection_ok', $payload['message'] ?? null, '캐시된 클로저 안에서 번역 키가 해석되지 않았습니다 (키 문자열이 그대로 노출됨).' ); $this->assertNotEmpty($payload['message'] ?? null); } /** * 라우트 파일은 전역 함수를 선언하지 않아야 한다 (in-process, audit 룰과 이중화). * * 라우트 캐시가 걸리면 라우트 파일이 로드되지 않으므로, 파일 안에 정의된 전역 함수는 * 존재하지 않는다. 핸들러가 그것을 호출하면 즉사한다. */ public function test_devtools_route_file_declares_no_global_functions(): void { $path = base_path('routes/devtools.php'); $source = file_get_contents($path); $this->assertIsString($source); preg_match_all('/^\s*function\s+(\w+)\s*\(/m', $source, $matches); $this->assertSame( [], $matches[1], 'routes/devtools.php 에 전역 함수가 선언되어 있습니다: '.implode(', ', $matches[1]) .'. 라우트 캐시가 걸리면 이 파일은 로드되지 않아 핸들러가 이 함수를 찾지 못합니다 ' .'(→ Call to undefined function). 클래스로 옮겨 오토로드 대상으로 만드세요.' ); } /** * 자식 프로세스가 실 `bootstrap/cache/routes-v7.php` 를 건드리지 않아야 한다. * * 실 캐시가 testing 기준으로 재생성되면 개발 환경이 통째로 깨진다. */ public function test_real_bootstrap_cache_is_untouched(): void { // 앞선 테스트들이 자식 프로세스를 이미 띄웠음을 보장한다. $this->bakeOnce(); $realCachePath = base_path('bootstrap/cache/routes-v7.php'); $this->assertSame( self::$realCacheExisted, file_exists($realCachePath), '자식 프로세스가 실 라우트 캐시를 생성하거나 삭제했습니다.' ); if (self::$realCacheExisted) { $this->assertSame( self::$realCacheDigest, md5_file($realCachePath), '자식 프로세스가 실 라우트 캐시를 덮어썼습니다 (임시 캐시 경로 주입 실패).' ); } } /** * 그룹 게이트는 라우트 캐시 상태에서도 살아 있어야 한다 (이슈 #128). * * 게이트를 핸들러 안에서 그룹 미들웨어로 올렸으므로, 이제 게이트 적용은 **미들웨어가 * 라우트와 함께 캐시에 구워지는가**에 달려 있다. 구워지지 않으면 캐시된 사이트에서만 * `DELETE clear` 가 다시 열리는데 — 라우트 캐시는 확장 설치·활성화 등 수명주기 지점에서 * 자동 생성되므로 그쪽이 오히려 흔한 상태다 — 예외도 로그도 없이 파괴적 엔드포인트가 * 미인증 200 을 돌려준다. * * `storage/debug-dump` 를 실제로 지우지 않는 것까지 확인한다: 자식 프로세스는 실 * storage 를 쓰므로, 게이트가 없으면 이 테스트 한 번으로 개발자 덤프가 사라진다. */ public function test_debug_gate_blocks_destructive_clear_under_route_cache(): void { $dumpDir = base_path('storage/debug-dump'); $before = is_dir($dumpDir) ? count(glob($dumpDir.'/*') ?: []) : 0; $result = $this->dispatch('DELETE', '/_boost/g7-debug/clear', '', debug: false); $this->assertTrue( $result['routesAreCached'], '라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).' ); $this->assertStringContainsString('route-cache-', $result['cachedRoutesPath']); // 403 이 "debug on 인데도 막혔다" 가 아니라 "debug off 라서 막혔다" 임을 고정한다. $this->assertFalse( $result['appDebug'], '자식 프로세스가 APP_DEBUG=false 로 부팅되지 않았습니다 (하네스 인자 전달 실패).' ); $this->assertSame( 403, $result['status'], "라우트 캐시 상태에서도 DELETE clear 는 403 이어야 합니다. body: {$result['body']}" ); $after = is_dir($dumpDir) ? count(glob($dumpDir.'/*') ?: []) : 0; $this->assertSame( $before, $after, 'DELETE clear 가 차단되었는데도 덤프 파일 수가 변했습니다 (게이트가 삭제 뒤에 걸렸습니다).' ); } /** * 디버그 ON 에서는 캐시 상태에서도 GET 조회가 도달해야 한다 (게이트가 상시 차단이 아님). * * `_boost/g7-debug/state` 는 `routes/web.php` 의 User catch-all 제외 패턴에 `_boost` 가 * 들어가면서 비로소 도달 가능해진 경로다. 캐시된 라우트에도 그 제외가 구워지는지 본다. */ public function test_gated_get_endpoint_is_reachable_under_route_cache_when_debug_enabled(): void { $result = $this->dispatch('GET', '/_boost/g7-debug/state'); $this->assertTrue($result['routesAreCached']); $this->assertTrue($result['appDebug']); $this->assertSame( 200, $result['status'], "라우트 캐시 상태에서 GET state 가 200 이어야 합니다. body: {$result['body']}" ); // 하네스는 본문을 4000자로 자른다 (`state` 덤프는 그보다 훨씬 크다) — 전체 JSON 파싱은 // 원리상 실패하므로 앞머리로 판정한다. 여기서 가려야 하는 것은 "DevTools JSON 인가 // SPA 셸 HTML 인가" 뿐이다. $this->assertMatchesRegularExpression( '/^\{"status":"(success|no_data)"/', $result['body'], 'SPA 셸 HTML 이 아니라 DevTools JSON 이어야 합니다 (catch-all shadow 회귀).' ); $this->assertStringNotContainsString(' 하네스 bake 결과 */ private function bakeOnce(): array { if (self::$bakeResult !== null) { return self::$bakeResult; } [$exitCode, $output] = $this->runHarness(['bake', self::$cacheRelDir]); $payload = $this->parseHarnessOutput($output); // bake 실패를 markTestSkipped 로 덮으면 회귀 방지가 통째로 무력화된다. $this->assertSame( 0, $exitCode, "라우트 캐시 굽기(bake)가 실패했습니다.\noutput:\n{$output}" ); $this->assertTrue( $payload['cacheWritten'] ?? false, "bake 후에도 라우트 캐시 파일이 생성되지 않았습니다.\noutput:\n{$output}" ); return self::$bakeResult = $payload; } /** * 캐시된 라우트로 부팅한 자식 프로세스에 HTTP 요청을 디스패치한다. * * @param string $method HTTP 메서드 * @param string $uri 요청 URI * @param string $body JSON 본문 * @param bool $debug 자식 프로세스의 APP_DEBUG (false 면 DevTools 그룹 게이트가 차단해야 한다) * @return array 하네스 dispatch 결과 */ private function dispatch(string $method, string $uri, string $body = '', bool $debug = true): array { $this->bakeOnce(); // 본문은 base64 로 넘긴다 — Windows 의 escapeshellarg() 가 큰따옴표를 공백으로 // 치환해 JSON 이 조용히 깨지기 때문 (하네스 헤더 주석 참조). [, $output] = $this->runHarness([ 'dispatch', self::$cacheRelDir, $method, $uri, $body === '' ? '' : base64_encode($body), $debug ? 'on' : 'off', ]); return $this->parseHarnessOutput($output); } /** * 하네스 스크립트를 별도 PHP 프로세스로 실행한다. * * @param array $args 하네스 인자 * @return array{0: int, 1: string} [종료 코드, stdout+stderr] */ private function runHarness(array $args): array { if (! function_exists('proc_open')) { $this->markTestSkipped('proc_open 미지원 환경'); } $commandLine = implode(' ', array_map('escapeshellarg', array_merge([ PHP_BINARY, base_path('tests/Fixtures/DevTools/route-cache-harness.php'), ], $args))).' 2>&1'; $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = proc_open($commandLine, $descriptors, $pipes, base_path()); $this->assertIsResource($process, '하네스 프로세스 실행에 실패했습니다.'); fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($process); return [$exitCode, (string) $stdout]; } /** * 하네스 출력에서 마커 뒤 JSON 을 추출한다. * * @param string $output 프로세스 출력 전문 * @return array 파싱된 페이로드 */ private function parseHarnessOutput(string $output): array { $marker = '@@G7HARNESS@@'; $pos = strrpos($output, $marker); $this->assertNotFalse( $pos, "하네스 출력에서 결과 마커를 찾지 못했습니다 (자식 프로세스가 부팅 중 죽었을 가능성).\noutput:\n{$output}" ); $json = trim(substr($output, $pos + strlen($marker))); $decoded = json_decode($json, true); $this->assertIsArray( $decoded, "하네스 결과 JSON 파싱에 실패했습니다.\noutput:\n{$output}" ); return $decoded; } /** * 디렉토리를 재귀적으로 삭제한다 (Laravel 부트스트랩에 의존하지 않는 순수 구현). * * @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); } }