Files
Gnuboard7/tests/Feature/View/BootstrapFallbackDiagnosisTest.php
T
HeuJung b6c5e1f323 feat(core): 리버스 프록시 신뢰 설정 지원 및 미설정 진단
TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성에서 X-Forwarded-* 가 전부
무시되어 화면 백지·IP 왜곡·webhook 403 이 함께 발생했다. 코어에 신뢰 프록시 설정
지점이 아예 없던 것이 원인이다.

- config/trustedproxy.php 로 내장 TrustProxies 미들웨어에 값을 공급한다.
 bootstrap/app.php 는 건드리지 않는다 — withMiddleware 클로저는 .env 로드 전에
 평가되어 env 가 항상 null 이 되는 조용한 no-op 이다.
- 판정은 App\Support\TrustedProxyDiagnostic 단일 SSoT 에서 계산하고 대시보드
 알림·환경설정 고급 탭·설치 마법사·trusted-proxy:status 네 면이 소비한다.
 판정식은 "HTTPS 인식 실패" 가 아니라 "X-Forwarded-* 수신 중 AND 신뢰 프록시
 미설정" 이다 — HTTP 전용 사이트가 프록시 뒤에 있으면 화면은 정상인 채로
 나머지만 조용히 어긋나기 때문이다.
- 값 편집 UI 와 쓰기 엔드포인트는 두지 않는다(잠금 역설 + XFF 위조 경로).
- 혼합 콘텐츠 차단을 부트스트랩 폴백의 별도 사유로 가른다. 새로고침으로 낫지
 않으므로 버튼을 렌더하지 않고, 원인·조치는 콘솔로 운영자에게 보낸다.
- 대시보드 알림을 심각도로 배치한다 — warning 은 상단 배너, 그 외는 하단 카드.
 같은 알림이 두 곳에 뜨지 않으며, 여러 건은 간격을 두고 쌓인다.

공개 이슈: (@lyg-kaban 제보)
2026-08-28 18:09:27 +09:00

252 lines
10 KiB
PHP

<?php
namespace Tests\Feature\View;
use Tests\TestCase;
/**
* 부팅 실패 사유별 안내 분기 (공개 #121).
*
* 번들을 받았는데 실행되지 않는 경우와 끝내 받지 못한 경우는 사용자가 취할 행동이
* 정반대다. 전자에 "네트워크가 불안정하다 / 새로고침하라" 를 띄우면 새로고침해도
* 낫지 않으므로 사용자는 자기 회선을 탓하게 된다.
*
* 인라인 부트스트랩은 코어 번들이 없어도 동작해야 하므로 ES5 로만 작성한다 —
* 화살표 함수·const·템플릿 리터럴·옵셔널 체이닝이 섞이면 이 계층까지 구형
* 브라우저에서 죽어 안내조차 뜨지 않는다.
*
* @scenario entrypoint=user, failure_mode=parse_error
*
* @effects bootstrap_partial_emits_reason_branch, incompatible_strings_defined_per_locale
*/
class BootstrapFallbackDiagnosisTest extends TestCase
{
/**
* 부트스트랩 partial 을 렌더합니다.
*
* @param string $templateType 'user' 또는 'admin'
* @return string 렌더된 HTML
*/
private function render(string $templateType = 'user'): string
{
return view('partials.bootstrap-scripts', [
'templateType' => $templateType,
'coreEngineSrc' => '/build/core/template-engine.min.js',
'componentsSrc' => '/api/templates/assets/sirsoft-basic/js/components.iife.js',
'initConfig' => ['templateId' => 'sirsoft-basic', 'locale' => 'ko'],
])->render();
}
public function test_두_진입점_모두_사유별_분기를_렌더한다(): void
{
foreach (['user', 'admin'] as $templateType) {
$html = $this->render($templateType);
$this->assertStringContainsString(
"? 'incompatible' : 'corrupt'",
$html,
"[{$templateType}] 전역 부재 경로가 SyntaxError 관측 결과로 사유를 가르지 않는다"
);
$this->assertStringContainsString(
"renderFallback(blocked ? 'blocked' : 'network')",
$html,
"[{$templateType}] 재시도 소진 경로가 차단/네트워크 사유를 가르지 않는다"
);
$this->assertStringContainsString(
'data-g7-bootstrap-fallback',
$html,
"[{$templateType}] 폴백 사유 마커가 없다"
);
}
}
/**
* HTTPS 페이지가 http 자산을 요청해 차단된 경우를 별도 사유로 가른다 (#124).
*
* 이 실패는 회선이 멀쩡하고 새로고침으로도 낫지 않으므로, 네트워크 문구 + 새로고침
* 버튼은 두 겹의 거짓 안내가 된다.
*
* @effects blocked_branch_is_emitted_with_https_http_judgment
*/
public function test_혼합_콘텐츠_차단을_별도_사유로_가른다(): void
{
foreach (['user', 'admin'] as $templateType) {
$html = $this->render($templateType);
$this->assertStringContainsString(
"location.protocol === 'https:'",
$html,
"[{$templateType}] 차단 판정이 페이지 스킴을 보지 않는다"
);
$this->assertStringContainsString(
"indexOf('http://') === 0",
$html,
"[{$templateType}] 차단 판정이 자산 스킴을 보지 않는다"
);
$this->assertStringContainsString(
"blocked ? 'blocked' : 'network'",
$html,
"[{$templateType}] 폴백 마커가 blocked 값을 낼 수 없다"
);
$this->assertStringContainsString(
'(incompatible || blocked)',
$html,
"[{$templateType}] 차단 경로에서 새로고침 버튼이 생략되지 않는다"
);
}
}
/**
* 차단 안내는 화면(방문자)과 콘솔(운영자)로 대상을 나눈다 (#124).
*
* 화면 문구에 서버 설정 지시를 쓰면 방문자에게 실행 불가능한 지시를 주는 셈이고,
* 원인을 통째로 감추면 운영자가 도달할 통로가 사라진다.
*
* @effects blocked_notice_splits_visitor_screen_and_operator_console
*/
public function test_차단_안내는_화면과_콘솔로_대상을_나눈다(): void
{
$html = $this->render();
// 콘솔에는 운영자 기준 원인·조치가 영문으로 남는다 (기존 로그와 같은 계열).
$this->assertStringContainsString('Blocked as mixed content', $html);
$this->assertStringContainsString('TRUSTED_PROXIES', $html);
$this->assertStringContainsString('docs/backend/reverse-proxy.md', $html);
$original = app()->getLocale();
try {
foreach (['ko', 'en'] as $locale) {
app()->setLocale($locale);
foreach (['blocked_title', 'blocked_message'] as $key) {
$value = __("errors.bootstrap.{$key}");
$this->assertNotSame("errors.bootstrap.{$key}", $value, "[{$locale}] errors.bootstrap.{$key} 미정의");
$this->assertNotSame('', trim($value), "[{$locale}] errors.bootstrap.{$key} 가 비어 있다");
}
// 사유가 다르면 문구도 달라야 한다 — 같으면 분기가 무의미하다.
$this->assertNotSame(
__('errors.bootstrap.message'),
__('errors.bootstrap.blocked_message'),
"[{$locale}] 차단 문구가 네트워크 문구와 동일하다"
);
// 화면 문구는 방문자 기준 — 서버 설정 지시를 담지 않는다.
$this->assertStringNotContainsString(
'TRUSTED_PROXIES',
__('errors.bootstrap.blocked_message'),
"[{$locale}] 방문자 화면에 서버 설정 지시가 노출된다"
);
$this->assertStringNotContainsString(
'.env',
__('errors.bootstrap.blocked_message'),
"[{$locale}] 방문자 화면에 서버 설정 지시가 노출된다"
);
}
} finally {
app()->setLocale($original);
}
}
public function test_파싱_오류_관측_리스너가_설치된다(): void
{
$html = $this->render();
// 파싱 실패는 <script> 의 error 가 아니라 load 를 발생시키므로 element
// 이벤트로는 구분할 수 없다. window 의 error 이벤트로만 관측된다.
$this->assertStringContainsString('bundleSrcs', $html);
$this->assertStringContainsString('SyntaxError', $html);
$this->assertStringContainsString('syntaxError', $html);
}
public function test_비호환_안내_문구가_지원_로케일마다_정의된다(): void
{
$original = app()->getLocale();
try {
foreach (['ko', 'en'] as $locale) {
app()->setLocale($locale);
foreach (['incompatible_title', 'incompatible_message'] as $key) {
$value = __("errors.bootstrap.{$key}");
$this->assertNotSame(
"errors.bootstrap.{$key}",
$value,
"[{$locale}] errors.bootstrap.{$key} 미정의 — 화면에 키 문자열이 그대로 노출된다"
);
$this->assertNotSame('', trim($value), "[{$locale}] errors.bootstrap.{$key} 가 비어 있다");
}
// 사유가 다르면 문구도 달라야 한다 — 같으면 분기가 무의미하다
$this->assertNotSame(
__('errors.bootstrap.message'),
__('errors.bootstrap.incompatible_message'),
"[{$locale}] 비호환 문구가 네트워크 문구와 동일하다"
);
}
} finally {
app()->setLocale($original);
}
}
public function test_번들_일본어_언어팩에도_비호환_문구가_있다(): void
{
$path = base_path('lang-packs/_bundled/g7-core-ja/backend/ja/errors.php');
if (! file_exists($path)) {
$this->markTestSkipped('번들 ja 언어팩 미존재');
}
$ja = require $path;
$this->assertArrayHasKey('bootstrap', $ja);
$this->assertArrayHasKey('incompatible_title', $ja['bootstrap']);
$this->assertArrayHasKey('incompatible_message', $ja['bootstrap']);
$this->assertArrayHasKey('blocked_title', $ja['bootstrap']);
$this->assertArrayHasKey('blocked_message', $ja['bootstrap']);
$this->assertNotSame(
$ja['bootstrap']['message'],
$ja['bootstrap']['incompatible_message'],
'ja 비호환 문구가 네트워크 문구와 동일하다'
);
$this->assertNotSame(
$ja['bootstrap']['message'],
$ja['bootstrap']['blocked_message'],
'ja 차단 문구가 네트워크 문구와 동일하다'
);
}
public function test_인라인_부트스트랩은_구형_브라우저_문법만_사용한다(): void
{
$html = $this->render();
preg_match('/<script>(.*?)<\/script>/s', $html, $m);
$this->assertNotEmpty($m, '인라인 부트스트랩 스크립트를 찾지 못했다');
// 주석 안의 백틱·화살표는 문법이 아니다 — 블록 주석과 줄 주석을 먼저 걷어낸다.
// 콜론 뒤의 `//` 는 URL 스킴이므로 주석으로 보지 않는다.
$js = preg_replace('#/\*.*?\*/#s', '', $m[1]);
$js = preg_replace('#(^|[^:])//[^\n]*#', '$1', $js);
// 코어 번들이 없어도 이 계층은 살아 있어야 한다 — ES2015+ 문법 금지.
$forbidden = [
'화살표 함수' => '/\)\s*=>/',
'const 선언' => '/(^|[^\w$])const\s+[A-Za-z_$]/m',
'let 선언' => '/(^|[^\w$])let\s+[A-Za-z_$]/m',
'템플릿 리터럴' => '/`/',
'옵셔널 체이닝' => '/\?\./',
];
foreach ($forbidden as $label => $pattern) {
$this->assertSame(
0,
preg_match($pattern, $js),
"인라인 부트스트랩에 {$label} 사용 — 구형 브라우저에서 안내 화면조차 뜨지 않는다"
);
}
}
}