Files
Gnuboard7/tests/Playwright/specs/smoke/bootstrap-parse-failure.spec.ts
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

311 lines
12 KiB
TypeScript

/**
* Smoke: 부팅 실패 사유별 안내 분기 (공개 #121)
*
* 번들을 **받았는데 실행되지 않는** 경우와 **끝내 받지 못한** 경우는 사용자가 취해야 할
* 행동이 정반대다. 전자는 새로고침해도 영원히 낫지 않으므로 "네트워크가 불안정하다 /
* 새로고침하라" 는 거짓 진단이고, 사용자는 자기 회선을 탓하게 된다.
*
* 기존 `network-resilience.spec.ts` 는 `route.abort()` 로 **네트워크 실패만** 모사한다.
* "다운로드는 되지만 파싱에 실패" 경로를 덮는 spec 이 없었던 것이 이번 결함(iOS 15 전
* 기기 부팅 불능)이 통과한 이유다.
*
* 파싱 실패 모사: 응답을 가로채 **파싱 불가한 정규식 리터럴**을 본문으로 돌려준다.
* 구형 Safari 가 lookbehind 를 거부하던 것과 같은 종류의 실패다 — 스크립트 태그는
* `error` 가 아니라 `load` 를 발생시키므로 재시도조차 일어나지 않고 곧장 tryInit 으로 간다.
*
* 진입점 두 곳(사용자·관리자)을 모두 잰다. 한쪽만 보면 blade 계층이 갈린 지점의
* 회귀를 놓친다.
*/
import { test, expect, type Page } from '@playwright/test';
/** 코어 엔진 번들 URL 패턴 */
const CORE_BUNDLE = '**/build/core/template-engine.min.js*';
/**
* 파싱 불가한 스크립트 본문.
*
* `(?<@x)` 는 유효하지 않은 캡처 그룹 이름이라 정규식 **리터럴** 검증 단계에서 거부된다
* (구형 WebKit 이 lookbehind 를 거부하던 지점과 동일). 파일 전체가 실행되지 않으므로
* 앞줄의 대입조차 수행되지 않고 `window.G7Core` 는 정의되지 않는다.
*/
const UNPARSEABLE_JS = 'window.__g7ParseProbe = 1;\nvar broken = /(?<@x)y/;\n';
/**
* 폴백 안내가 그려질 때까지 기다린 뒤 그 상태를 읽는다.
*
* 부재 단언은 **존재를 확정한 뒤**에만 의미가 있다 — 폴백 컨테이너가 뜨기 전에
* 버튼 수를 세면 렌더 전이라 0 이 나와 그냥 통과한다.
*
* @param page Playwright page
* @return 폴백 마커·문구·버튼 수
*/
async function readFallback(page: Page): Promise<{ reason: string | null; title: string; buttons: number }> {
const container = page.locator('#app [data-g7-bootstrap-fallback]');
await expect(container).toBeVisible({ timeout: 20_000 });
return page.evaluate(() => {
const el = document.querySelector('#app [data-g7-bootstrap-fallback]');
return {
reason: el ? el.getAttribute('data-g7-bootstrap-fallback') : null,
title: el?.querySelector('h1')?.textContent?.trim() ?? '',
buttons: el ? el.querySelectorAll('button').length : 0,
};
});
}
/**
* 파싱 실패 케이스 본문 — 진입점만 다르다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertParseFailure(page: Page, url: string): Promise<void> {
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(String(e)));
await page.route(CORE_BUNDLE, (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: UNPARSEABLE_JS })
);
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const fallback = await readFallback(page);
expect(fallback.reason).toBe('incompatible');
expect(fallback.title.length).toBeGreaterThan(0);
// 존재를 확정한 뒤의 부재 단언 — 새로고침 버튼이 없어야 한다
expect(fallback.buttons).toBe(0);
const state = await page.evaluate(() => ({
hasCore: typeof (window as any).G7Core !== 'undefined',
syntaxError: (window as any).__g7Bootstrap?.syntaxError,
}));
expect(state.hasCore).toBe(false);
expect(state.syntaxError).toBe(true);
expect(pageErrors.join(' | ')).toMatch(/SyntaxError/);
}
/**
* 네트워크 실패 케이스 본문 — 기존 문구·버튼·재시도 상한이 그대로여야 한다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertNetworkFailure(page: Page, url: string): Promise<void> {
let attempts = 0;
await page.route(CORE_BUNDLE, (route) => {
attempts += 1;
return route.abort('failed');
});
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const fallback = await readFallback(page);
expect(fallback.reason).toBe('network');
expect(fallback.buttons).toBe(1);
// 재시도 상한(초기 1 + 재시도 2) 불변
expect(attempts).toBe(3);
}
/**
* 혼합 콘텐츠 차단 케이스 본문 — 회선은 멀쩡한데 브라우저가 요청을 거부하는 실패다 (공개 #124).
*
* 문서 HTML 의 코어 스크립트 URL 만 `http://` 로 바꾸면 재현된다 — TLS 종단 프록시도
* 인증서도 필요 없다. 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 서버가 만들어 내는
* 것이 바로 이 HTML 이기 때문이다.
*
* 판정은 `location.protocol === 'https:'` 를 보므로, 기대값은 실행 시점 base URL 의
* 스킴에서 파생한다 — http base 에서는 이 실패가 그냥 도달 불가이므로 `network` 가 정답이다.
* 분기 자체의 방출은 스킴과 무관하게 BootstrapFallbackDiagnosisTest 가 단언한다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertMixedContentBlocked(page: Page, url: string): Promise<void> {
const consoleErrors: string[] = [];
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text());
});
// 문서 응답을 가로채 코어 번들 URL 만 절대 http:// 로 바꾼다.
// 서버는 이 URL 을 절대(`https://host/build/core/…`) 또는 상대(`/build/core/…`) 어느
// 형태로도 낼 수 있으므로 두 경우를 모두 정규화해 다룬다 — 한 형태만 가정하면 치환이
// 조용히 0건이 되어 테스트가 아무것도 재현하지 않은 채 실패한다.
let rewritten = 0;
await page.route('**/*', async (route) => {
if (route.request().resourceType() !== 'document') return route.fallback();
const response = await route.fetch();
const body = await response.text();
const origin = new URL(route.request().url()).origin;
const patched = body.replace(
/src="([^"]*\/build\/core\/template-engine\.min\.js[^"]*)"/g,
(whole, src: string) => {
const abs = new URL(src, origin);
if (abs.protocol !== 'https:') return whole;
abs.protocol = 'http:';
rewritten += 1;
return `src="${abs.toString()}"`;
}
);
return route.fulfill({ response, body: patched });
});
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const isHttpsBase = new URL(page.url()).protocol === 'https:';
if (isHttpsBase) {
// 치환이 실제로 일어났는지 확인한다 — 0 건이면 아래 단언은 결함이 아니라
// 재현 실패를 보고하게 된다.
expect(rewritten, '코어 번들 URL 치환이 0 건입니다 — 재현되지 않았습니다').toBeGreaterThan(0);
}
const isHttps = isHttpsBase;
const fallback = await readFallback(page);
expect(fallback.reason).toBe(isHttps ? 'blocked' : 'network');
expect(fallback.title.length).toBeGreaterThan(0);
// 차단은 새로고침으로 낫지 않는다 — 버튼을 두면 그것 자체가 다시 거짓 안내가 된다.
expect(fallback.buttons).toBe(isHttps ? 0 : 1);
if (isHttps) {
// 화면에서 뺀 원인·조치는 콘솔에서 운영자에게 도달해야 한다.
const joined = consoleErrors.join(' | ');
expect(joined).toMatch(/Blocked as mixed content/);
expect(joined).toMatch(/TRUSTED_PROXIES/);
}
}
/**
* 정상 부팅 케이스 본문 — 폴백이 오발동하지 않아야 한다.
*
* @param page Playwright page
* @param url 진입 경로
* @return {Promise<void>}
*/
async function assertNormalBoot(page: Page, url: string): Promise<void> {
await page.goto(url);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 20_000 }
);
const state = await page.evaluate(() => ({
failed: (window as any).__g7Bootstrap?.failed,
syntaxError: (window as any).__g7Bootstrap?.syntaxError,
hasCore: typeof (window as any).G7Core !== 'undefined',
fallbacks: document.querySelectorAll('#app [data-g7-bootstrap-fallback]').length,
}));
expect(state.hasCore).toBe(true);
expect(state.failed).toBe(false);
expect(state.syntaxError).toBe(false);
expect(state.fallbacks).toBe(0);
}
test.describe('부팅 실패 사유별 안내 분기 (공개 #121)', () => {
/**
* @scenario entrypoint=user, failure_mode=parse_error
* @effects syntax_error_renders_incompatible_notice, incompatible_notice_omits_reload_button
*/
test('@smoke [user] 번들 파싱 실패 → 브라우저 비호환 안내 + 새로고침 버튼 부재', async ({ page }) => {
await assertParseFailure(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=parse_error
* @effects syntax_error_renders_incompatible_notice, incompatible_notice_omits_reload_button
*/
test('@smoke [admin] 번들 파싱 실패 → 브라우저 비호환 안내 + 새로고침 버튼 부재', async ({ page }) => {
await assertParseFailure(page, '/admin');
});
/**
* @scenario entrypoint=user, failure_mode=network_exhausted
* @effects network_failure_renders_network_notice, network_notice_keeps_reload_button
*/
test('@smoke [user] 번들 상시 부재 → 기존 네트워크 안내 + 새로고침 버튼 유지', async ({ page }) => {
await assertNetworkFailure(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=network_exhausted
* @effects network_failure_renders_network_notice, network_notice_keeps_reload_button
*/
test('@smoke [admin] 번들 상시 부재 → 기존 네트워크 안내 + 새로고침 버튼 유지', async ({ page }) => {
await assertNetworkFailure(page, '/admin');
});
/**
* @scenario entrypoint=user, failure_mode=mixed_content_blocked
* @effects mixed_content_blocked_renders_blocked_notice, blocked_notice_omits_reload_button
*/
test('@smoke [user] 코어 번들이 http 로 차단 → 사이트 설정 안내 + 새로고침 버튼 부재', async ({ page }) => {
await assertMixedContentBlocked(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=mixed_content_blocked
* @effects mixed_content_blocked_renders_blocked_notice, blocked_notice_omits_reload_button
*/
test('@smoke [admin] 코어 번들이 http 로 차단 → 사이트 설정 안내 + 새로고침 버튼 부재', async ({ page }) => {
await assertMixedContentBlocked(page, '/admin');
});
/**
* @scenario entrypoint=user, failure_mode=none
* @effects normal_boot_renders_no_fallback
*/
test('@smoke [user] 정상 부팅 → 폴백 미렌더', async ({ page }) => {
await assertNormalBoot(page, '/');
});
/**
* @scenario entrypoint=admin, failure_mode=none
* @effects normal_boot_renders_no_fallback
*/
test('@smoke [admin] 정상 부팅 → 폴백 미렌더', async ({ page }) => {
await assertNormalBoot(page, '/admin');
});
/**
* 서빙되는 코어 엔진 번들에 lookbehind 가 없다.
*
* 저장소 산출물은 정적 검사가 덮지만, **실제로 서빙되는 것**이 그 산출물인지는
* 별개 축이다(배포 경로·캐시·리버스 프록시). 브라우저가 받는 바이트를 직접 센다.
*
* @effects shipped_core_bundle_has_no_lookbehind
*/
test('@smoke 서빙 중인 코어 엔진 번들에 정규식 lookbehind 가 없다', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const probe = await page.evaluate(async () => {
const el = Array.from(document.querySelectorAll('script[src]')).find((s) =>
/template-engine\.min\.js/.test((s as HTMLScriptElement).src)
) as HTMLScriptElement | undefined;
if (!el) return { found: false, bytes: 0, lookbehind: -1 };
const body = await fetch(el.src).then((r) => r.text());
return { found: true, bytes: body.length, lookbehind: (body.match(/\(\?<[!=]/g) || []).length };
});
expect(probe.found).toBe(true);
expect(probe.bytes).toBeGreaterThan(1000);
expect(probe.lookbehind).toBe(0);
});
});