Files
Gnuboard7/tests/Feature/DevTools/DevtoolsRouteCacheTest.php
HeuJung 5d14d01d50 fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무
전수조사에서 확정한 결함군을 닫는다.

## 디버그 라우트 게이트 (F1~F5)

게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에
`File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고,
production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은
User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴
구조였다.

게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로
올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다.
`withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는
제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치
우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다.

## 확장 자산 CSS 상대 참조 (범위 추가 — 결정)

`asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준
디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가
존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘
약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서
통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로
제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다.

둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이
정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류
갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 —
갈라지면 한쪽만 고쳐진 채 남는다.

이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가
정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다.

## 회귀 잠금

되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입,
`channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들
치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다.

게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과
구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환
배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은
목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
2026-09-03 07:42:28 +09:00

472 lines
19 KiB
PHP

<?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()` 고정).
* 개발자가 브라우저에서 받아 둔 덤프를 테스트가 덮어쓰지 않도록 이 파일만 스냅샷·복원한다.
*/
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);
}
// 개발자의 덤프 데이터를 원상 복구 (자식 프로세스가 실 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),
'자식 프로세스가 실 라우트 캐시를 덮어썼습니다 (임시 캐시 경로 주입 실패).'
);
}
}
/**
* 그룹 게이트는 라우트 캐시 상태에서도 살아 있어야 한다 (이슈 #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('<!DOCTYPE', $result['body']);
}
/**
* 임시 경로에 라우트 캐시를 굽는다 (클래스당 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 본문
* @param bool $debug 자식 프로세스의 APP_DEBUG (false 면 DevTools 그룹 게이트가 차단해야 한다)
* @return array<string, mixed> 하네스 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<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);
}
}