fix(core): 라우트 캐시 상태에서 DevTools 덤프·브라우저 로그 수집 복구

route:cache 가 걸리면 RouteServiceProvider 가 캐시 로드로 분기해 라우트 파일이
실행되지 않는다. routes/devtools.php 는 전역 함수 5개를 정의하고 핸들러가 호출했고,
boost 의 browser-logs 는 프로바이더 boot 등록분이라 setCompiledRoutes 교체로
폐기됐다. 7.0.6 의 RouteCacheHelper 가 12개 라이프사이클 지점에서 캐시를 자동
생성하면서, 확장을 한 번이라도 건드린 사이트에서 항상 재현됐다.

로직을 오토로드되는 App\Support\DevTools\* 로 옮기고 browser-logs URI 를 G7 가
직접 소유해 캐시에 함께 구워지게 했다. 게이트가 참조하던 App\Models\Setting 은
저장소에 없는 유물이라 ConfigRepositoryInterface 로 교체했다.

같은 부류의 재발은 정적 검사(route-file-local-function)로 차단하고, 재현이
in-process 로 불가능하므로 서브프로세스 하네스로 회귀를 고정했다.
This commit is contained in:
HeuJung committed 2026-08-07 10:13:52 +09:00
1 parent e80be1d924
commit 4744e7bf85
16 files changed
+1352 -458

No files matched your search

@@ -0,0 +1,116 @@
<?php
namespace Tests\Feature\DevTools;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Support\DevTools\DebugGate;
use Mockery;
use Tests\TestCase;
/**
* DevTools 엔드포인트의 디버그 모드 게이트 회귀 테스트.
*
* 배경:
* 게이트는 원래 `config('app.debug') || \App\Models\Setting::getValue('debug.mode', false)`
* 였는데 `App\Models\Setting` 은 저장소에 존재하지 않는 유물이었다. `||` 단축평가 덕에
* `APP_DEBUG=true` 인 동안에는 두 번째 항이 평가되지 않아 드러나지 않았을 뿐이고,
* `APP_DEBUG=false` + settings JSON 의 `debug` 카테고리 미동기화가 겹치면
* **403 이어야 할 응답이 `Class "App\Models\Setting" not found` 500 이 됐다.**
*
* 검증 축:
* 1. app.debug=false + debug.mode=true → 200 (두 번째 항이 실제로 평가되어야 한다)
* 2. app.debug=false + debug.mode=false → 403 (500 이 아니어야 한다)
* 3. app.debug=true → 200 (환경설정 조회 없이 통과)
*/
class DevtoolsDebugGateTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
/**
* `debug.mode` 환경설정만 켜져 있어도 게이트를 통과해야 한다.
*
* 수정 전: HTTP 500 `Class "App\Models\Setting" not found`
*/
public function test_dump_state_passes_gate_via_settings_debug_mode(): void
{
config(['app.debug' => 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());
}
/**
* `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);
}
}
@@ -0,0 +1,392 @@
<?php
namespace Tests\Feature\DevTools;
use Tests\TestCase;
/**
* 라우트 캐시(`bootstrap/cache/routes-v7.php`)가 걸린 상태에서도 DevTools 덤프와
* boost browser-logs 엔드포인트가 동작함을 실증한다.
*
* 배경:
* `route:cache` 가 걸리면 `RouteServiceProvider::boot()` 이 `loadCachedRoutes()` 로 분기해
* **라우트 파일 자체가 로드되지 않는다**. 클로저는 `SerializableClosure` 로 복원되지만
* 그 클로저가 참조하는 전역 함수는 정의되지 않는다 (오토로드 대상이 아니므로).
* 또한 `Router::setCompiledRoutes()` 가 라우트 컬렉션을 통째로 교체하므로,
* 벤더 프로바이더가 `boot()` 에서 등록한 라우트는 조건 충족 여부와 무관하게 폐기된다.
*
* 왜 서브프로세스인가:
* 같은 프로세스 안에서는 재현 자체가 불가능하다 — 상세 근거는
* `tests/Fixtures/DevTools/route-cache-harness.php` 헤더 주석 참조.
*
* 선례: tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php (PHP_BINARY + escapeshellarg + proc_open)
*/
class DevtoolsRouteCacheTest extends TestCase
{
/** 자식 프로세스가 사용할 캐시 임시 디렉토리 (base_path 상대 POSIX 경로) */
private static ?string $cacheRelDir = null;
/** 클래스당 1회만 굽는다 (bake 는 수 초가 걸린다) */
private static ?array $bakeResult = null;
/** 실 라우트 캐시의 최초 md5 (미존재 시 null) */
private static ?string $realCacheDigest = null;
private static bool $realCacheExisted = false;
/**
* 덤프 위임이 실제로 수행됐는지 확인할 때 쓰는 섹션.
*
* 하네스 자식 프로세스는 실 `storage/debug-dump` 에 쓴다 (경로가 `storage_path()` 고정).
* PO 가 브라우저에서 받아 둔 덤프를 테스트가 덮어쓰지 않도록 이 파일만 스냅샷·복원한다.
*/
private const PROBE_SECTION = 'logs';
private const PROBE_FILE = 'logs-latest.json';
/** 덤프 파일의 최초 내용 (미존재 시 null) */
private static ?string $probeBackup = null;
private static bool $probeExisted = false;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$repoRoot = dirname(__DIR__, 3);
$testingDir = $repoRoot.'/storage/framework/testing';
// 이전 실행이 비정상 종료해 남긴 잔여물 정리 (1시간 이상 된 것만).
if (is_dir($testingDir)) {
foreach (glob($testingDir.'/route-cache-*') ?: [] as $stale) {
if (is_dir($stale) && filemtime($stale) < time() - 3600) {
self::removeDirectory($stale);
}
}
}
self::$cacheRelDir = 'storage/framework/testing/route-cache-'.uniqid();
$realCachePath = $repoRoot.'/bootstrap/cache/routes-v7.php';
self::$realCacheExisted = file_exists($realCachePath);
self::$realCacheDigest = self::$realCacheExisted ? md5_file($realCachePath) : null;
$probePath = $repoRoot.'/storage/debug-dump/'.self::PROBE_FILE;
self::$probeExisted = file_exists($probePath);
self::$probeBackup = self::$probeExisted ? file_get_contents($probePath) : null;
}
public static function tearDownAfterClass(): void
{
// PHPUnit 은 테스트 실패·예외와 무관하게 이 훅을 호출한다.
if (self::$cacheRelDir !== null) {
self::removeDirectory(dirname(__DIR__, 3).'/'.self::$cacheRelDir);
}
// PO 의 덤프 데이터를 원상 복구 (자식 프로세스가 실 storage 에 썼다).
$probePath = dirname(__DIR__, 3).'/storage/debug-dump/'.self::PROBE_FILE;
if (self::$probeExisted && self::$probeBackup !== null) {
file_put_contents($probePath, self::$probeBackup);
} elseif (! self::$probeExisted && file_exists($probePath)) {
@unlink($probePath);
}
self::$cacheRelDir = null;
self::$bakeResult = null;
self::$realCacheDigest = null;
self::$probeBackup = null;
parent::tearDownAfterClass();
}
/**
* `dump-state` 는 라우트 캐시가 걸린 상태에서도 200 을 돌려주어야 한다.
*
* 수정 전: HTTP 500 `Call to undefined function handleLegacyDump()`
*/
public function test_dump_state_survives_route_cache(): void
{
$marker = 'harness-'.uniqid();
$result = $this->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),
'자식 프로세스가 실 라우트 캐시를 덮어썼습니다 (임시 캐시 경로 주입 실패).'
);
}
}
/**
* 임시 경로에 라우트 캐시를 굽는다 (클래스당 1회).
*
* @return array<string, mixed> 하네스 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 본문
* @return array<string, mixed> 하네스 dispatch 결과
*/
private function dispatch(string $method, string $uri, string $body = ''): array
{
$this->bakeOnce();
// 본문은 base64 로 넘긴다 — Windows 의 escapeshellarg() 가 큰따옴표를 공백으로
// 치환해 JSON 이 조용히 깨지기 때문 (하네스 헤더 주석 참조).
[, $output] = $this->runHarness([
'dispatch',
self::$cacheRelDir,
$method,
$uri,
$body === '' ? '' : base64_encode($body),
]);
return $this->parseHarnessOutput($output);
}
/**
* 하네스 스크립트를 별도 PHP 프로세스로 실행한다.
*
* @param array<int, string> $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<string, mixed> 파싱된 페이로드
*/
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);
}
}
@@ -0,0 +1,204 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
/**
* 라우트 캐시 회귀 검증용 서브프로세스 하네스.
*
* 왜 서브프로세스인가:
* 이 결함은 **같은 프로세스 안에서 재현되지 않는다**. `routes/devtools.php` 가 한 번이라도
* 로드되면 그 안의 전역 함수는 PHP 프로세스 전역에 남고, `Artisan::call('route:cache')` 는
* `getFreshApplication()` 으로 라우트 파일을 다시 로드해 오히려 함수를 정의해 버린다.
* 게다가 `RouteCacheHelper::rebuild()` 는 testing 환경에서 캐시를 굽지 않는다.
* → in-process 테스트는 수정 전에도 통과하는 가짜 green 이 된다.
*
* 사용법:
* php tests/Fixtures/DevTools/route-cache-harness.php bake <cacheRelDir>
* php tests/Fixtures/DevTools/route-cache-harness.php dispatch <cacheRelDir> <METHOD> <URI> [<base64Body>]
*
* 본문은 **base64 로 인코딩해서** 넘긴다. Windows 의 `escapeshellarg()` 는 인자 안의
* 큰따옴표를 공백으로 치환하므로 JSON 을 그대로 넘기면 `{"test":true}` 가 `{ test :true}` 로
* 깨져 도착한다. 파싱 오류도 나지 않고 그저 입력이 비어 있는 요청이 되므로, 200 만 단언하는
* 테스트는 페이로드가 전달되지 않은 채로 통과한다 (실측으로 발견).
*
* 출력:
* 마지막 줄에 `@@G7HARNESS@@` 마커 + JSON 1줄.
* 부팅 중 deprecation 경고가 stdout 에 섞여도 파싱이 깨지지 않고, 실패 시 원문 전체를
* assertion 메시지로 붙일 수 있다.
* - bake → exit code 전파 (캐시 굽기 실패를 프로세스 실패로 드러낸다)
* - dispatch → 항상 exit 0 (HTTP 500 과 프로세스 실패를 분리)
*
* 함정 1 — 캐시 경로는 base_path 상대 POSIX 경로만 받는다.
* `Application::normalizeCachePath()`(vendor/laravel/framework/src/Illuminate/Foundation/Application.php:1379)
* 는 `$absoluteCachePathPrefixes = ['/', '\\']`(:209) 로 시작하는 값만 절대경로로 취급한다.
* Windows 의 `C:\...` 는 여기 걸리지 않아 `basePath($env)` 로 이어붙어 엉뚱한 위치를 가리킨다.
*
* 함정 2 — 환경변수는 프로세스 env 가 아니라 `$_SERVER`/`$_ENV` 에 직접 넣는다.
* `bootstrap/app.php:62` 의 `Env::disablePutenv()` 때문에 리더가 `$_SERVER`/`$_ENV` 뿐이고,
* CLI 의 `variables_order` 기본값 `"GPCS"` 에는 `E` 가 없어 `proc_open($env)` 이 조용히
* 무시될 수 있다. 그러면 자식이 **실 캐시를 읽고 덮어쓴다**.
*
* 5개 캐시 경로를 전부 임시 디렉토리로 돌린다:
* - APP_ROUTES_CACHE 검증 대상
* - APP_CONFIG_CACHE 살아 있으면 자식이 개발 DB 로 부팅한다
* - APP_PACKAGES_CACHE / APP_SERVICES_CACHE / APP_EVENTS_CACHE
* 자식이 실 경로에 쓰는 것을 차단
* (tests/Unit/Extension/ExtensionAutoloadCacheGuardTest.php 규율)
*/
const HARNESS_MARKER = '@@G7HARNESS@@';
$repoRoot = dirname(__DIR__, 3);
$argvLocal = $_SERVER['argv'] ?? [];
$mode = $argvLocal[1] ?? '';
$cacheRelDir = $argvLocal[2] ?? '';
if (! in_array($mode, ['bake', 'dispatch'], true) || $cacheRelDir === '') {
fwrite(STDERR, "usage: route-cache-harness.php bake|dispatch <cacheRelDir> [...]\n");
exit(2);
}
// 함정 1: 상대 POSIX 경로만 수용한다.
if (preg_match('#^([A-Za-z]:|[/\\\\])#', $cacheRelDir) === 1 || str_contains($cacheRelDir, '\\')) {
fwrite(STDERR, "cacheRelDir 는 base_path 상대 POSIX 경로여야 합니다: {$cacheRelDir}\n");
exit(2);
}
$cacheAbsDir = $repoRoot.'/'.$cacheRelDir;
if (! is_dir($cacheAbsDir) && ! @mkdir($cacheAbsDir, 0755, true) && ! is_dir($cacheAbsDir)) {
fwrite(STDERR, "캐시 임시 디렉토리 생성 실패: {$cacheAbsDir}\n");
exit(2);
}
// 함정 2: $_SERVER/$_ENV 에 직접 주입 (Env::disablePutenv 대응).
$envOverrides = [
'APP_ENV' => 'testing',
// 게이트 단축평가 통과 + 예외 본문을 JSON 으로 확보한다.
'APP_DEBUG' => 'true',
'APP_ROUTES_CACHE' => $cacheRelDir.'/routes-v7.php',
'APP_CONFIG_CACHE' => $cacheRelDir.'/config.php',
'APP_PACKAGES_CACHE' => $cacheRelDir.'/packages.php',
'APP_SERVICES_CACHE' => $cacheRelDir.'/services.php',
'APP_EVENTS_CACHE' => $cacheRelDir.'/events.php',
];
foreach ($envOverrides as $key => $value) {
$_SERVER[$key] = $value;
$_ENV[$key] = $value;
}
require $repoRoot.'/vendor/autoload.php';
/**
* 결과 JSON 을 마커와 함께 출력한다.
*
* @param array<string, mixed> $payload 출력 페이로드
* @return void
*/
function harnessEmit(array $payload): void
{
echo "\n".HARNESS_MARKER.json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n";
}
if ($mode === 'bake') {
/** @var Application $app */
$app = require $repoRoot.'/bootstrap/app.php';
/** @var Kernel $kernel */
$kernel = $app->make(Kernel::class);
$kernel->bootstrap();
$exitCode = 0;
$error = null;
try {
// `route:cache` 는 route:clear 후 fresh application 을 부팅해 라우트를 수집한다.
// fresh application 도 같은 프로세스의 $_SERVER 를 보므로 캐시 경로는 임시 위치 그대로다.
$exitCode = Artisan::call('route:cache');
echo Artisan::output();
} catch (Throwable $e) {
$exitCode = 1;
$error = get_class($e).': '.$e->getMessage();
}
$cachedPath = $app->getCachedRoutesPath();
// 여기서 `$app->routesAreCached()` 를 쓰면 안 된다 — `Application::routesAreCached()`
// (vendor Foundation/Application.php:1327) 는 결과를 컨테이너에 `routes.cached` 로
// **메모이즈**한다. 부팅 시 `RouteServiceProvider::boot()` 이 이미 호출했고 그때는 캐시가
// 없었으므로, 방금 1MB 짜리 캐시를 구웠어도 이 프로세스는 영원히 false 를 돌려준다.
// bake 가 확인해야 하는 것은 "부팅이 캐시를 썼는가" 가 아니라 "파일이 생겼는가" 다.
clearstatcache(true, $cachedPath);
$cacheWritten = is_file($cachedPath);
harnessEmit([
'mode' => 'bake',
'exitCode' => $exitCode,
'error' => $error,
'cacheWritten' => $cacheWritten,
'cacheBytes' => $cacheWritten ? filesize($cachedPath) : 0,
'cachedRoutesPath' => str_replace('\\', '/', $cachedPath),
]);
exit($exitCode === 0 && $cacheWritten ? 0 : 1);
}
// ── dispatch ──────────────────────────────────────────────────────────────
$method = strtoupper($argvLocal[3] ?? 'GET');
$uri = $argvLocal[4] ?? '/';
$encodedBody = $argvLocal[5] ?? '';
$body = '';
if ($encodedBody !== '') {
$decoded = base64_decode($encodedBody, true);
if ($decoded === false) {
fwrite(STDERR, "본문은 base64 로 인코딩해서 넘겨야 합니다.\n");
exit(2);
}
$body = $decoded;
}
/** @var Application $app */
$app = require $repoRoot.'/bootstrap/app.php';
/** @var Illuminate\Contracts\Http\Kernel $httpKernel */
$httpKernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$server = [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
];
$request = Request::create($uri, $method, [], [], [], $server, $body === '' ? null : $body);
$status = 0;
$responseBody = '';
$error = null;
try {
$response = $httpKernel->handle($request);
$status = $response->getStatusCode();
$responseBody = (string) $response->getContent();
$httpKernel->terminate($request, $response);
} catch (Throwable $e) {
$error = get_class($e).': '.$e->getMessage();
}
harnessEmit([
'mode' => 'dispatch',
'method' => $method,
'uri' => $uri,
'routesAreCached' => $app->routesAreCached(),
'cachedRoutesPath' => str_replace('\\', '/', $app->getCachedRoutesPath()),
'status' => $status,
'body' => mb_substr($responseBody, 0, 4000),
'error' => $error,
]);
exit(0);