Files
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

216 lines
9.0 KiB
PHP

<?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>] [on|off]
*
* 여섯째 인자는 자식 프로세스의 `APP_DEBUG` 다 (기본 `on`). `off` 는 DevTools 그룹 게이트가
* 라우트 캐시 상태에서도 살아 있는지(미들웨어가 캐시에 함께 구워지는지) 확인하는 축이다 —
* 게이트를 그룹 미들웨어로 올린 뒤 캐시 경로에서만 빠지면 예외 없이 그대로 열린다.
*
* 본문은 **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);
}
// dispatch 의 여섯째 인자로 디버그 모드를 고른다 (bake 는 항상 on — 굽기는 게이트와 무관).
$debugFlag = ($mode === 'dispatch' && ($argvLocal[6] ?? 'on') === 'off') ? 'false' : 'true';
// 함정 2: $_SERVER/$_ENV 에 직접 주입 (Env::disablePutenv 대응).
$envOverrides = [
'APP_ENV' => 'testing',
// 기본값 true — 게이트 단축평가 통과 + 예외 본문을 JSON 으로 확보한다.
// 'off' 로 넘기면 게이트가 실제로 차단하는지(403)를 캐시 상태에서 검증한다.
'APP_DEBUG' => $debugFlag,
'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,
// 부팅된 앱이 실제로 의도한 디버그 상태인지 — 이걸 단언하지 않으면 403 테스트가
// "debug on 인데도 403" 인지 "debug off 라서 403" 인지 구분하지 못한다.
'appDebug' => (bool) config('app.debug'),
'routesAreCached' => $app->routesAreCached(),
'cachedRoutesPath' => str_replace('\\', '/', $app->getCachedRoutesPath()),
'status' => $status,
'body' => mb_substr($responseBody, 0, 4000),
'error' => $error,
]);
exit(0);