Files
Gnuboard7/tests/Playwright/specs/extension-bundle-loading.spec.ts
T
HeuJung f506c040c1 fix(core): 공개 확장 번들의 캐시 우선 서빙 + SEO 봇 캐시 상한
공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는
(type, kind, version) 인자만으로 계산되는데 "병합 결과가 비면 파일을 만들지
않는다" 는 규칙을 먼저 두느라 빌드를 앞세운 것이 원인이다. 그래서 캐시 적중
경로에도 활성 확장 열거와 산출물 전량 읽기가 붙었고, 원본이 소실되면 멀쩡한
캐시를 두고 빈 경로가 반환되어 503 이 됐다. 응답은 정상 200 이라 타이밍 말고는
드러나는 증상이 없다.

프로덕션에서 캐시 존재를 병합보다 먼저 확인하고, 캐시 미스는 같은 키의 잠금으로
1회 빌드에 수렴시킨 뒤 잠금 뒤 캐시를 재확인한다. 잠금 대기 초과·저장소 장애는
실패로 바꾸지 않고 각자 빌드로 폴백한다.

병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시를
만들어 정적 게시까지 보낸다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해
방문자의 모든 페이지 로드가 PHP 를 거치고, 그 요청마다 컨트롤러가 열거를 세 번
반복한다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이라
응답 계약은 바뀌지 않는다 — 컨트롤러와 트레이트는 손대지 않았다.

같은 결의 결함이 검색봇 캐시에도 있었다. 봇 판정은 User-Agent 문자열뿐인데 캐시
키가 경로 + 전체 쿼리여서, 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 되고 그
미스마다 레이아웃 병합·표현식 평가·자기 API 루프백 호출이 일어나며 결과가 무제한
저장됐다. 키를 정규화하고(시스템 파라미터 제외·개수/길이 상한), IP 당 분당 미스
렌더 예산과 저장 규모 상한을 뒀다. 초과분은 차단이 아니라 일반 SPA 응답을 받는다
— 봇에게 오류를 주면 그 URL 이 색인에서 빠지기 때문이다.

저장 상한은 만료 항목을 걷어낸 뒤 판정한다. 인덱스는 페이지보다 오래 살아
(30일 vs 기본 2시간) 정리 없이 세면 상한이 "지금 저장된 양"이 아니라 "과거에
저장한 적이 있는 양"을 재게 되어 일방향 래치가 된다. 정리는 인덱스 전체를
훑으므로 최소 60초 간격으로만 수행한다. put 과 putWithLayout 은 같은 자원을
쓰므로 단일 저장 경로로 합쳤다 — 한쪽만 상한 밖이면 그쪽이 우회로가 되고,
인터페이스는 확장에 열려 있어 "지금 호출부가 없다" 는 방어가 되지 않는다.

캐시 적중·미적중을 기록하는 호출처가 없어 관리자 SEO 통계와 seo:stats 가 항상
0 이었던 것도 함께 고쳤다. 기록 자체에도 IP 당 상한을 둬 통계 테이블이 새 증식
축이 되지 않게 했다.

(KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
2026-09-08 00:31:20 +09:00

130 lines
6.0 KiB
TypeScript

/**
* E2E: 확장 프론트엔드 병합 번들 로딩 회귀 검증
*
* @scenario extension-bundle-loading
* @effects individual_iife_requests_absent_in_browser, bundle_url_same_origin_only,
* gdpr_interceptor_still_active_after_merge, extension_handlers_registered_no_console_error
*
* 배경: 활성 모듈/플러그인 IIFE 를 서버측에서 종류별 1개 번들로 병합 서빙한다.
* 실제 페이지 로드 시 (1) 개별 `*.iife.js` 요청이 사라지고 `bundle.js` 로 대체되는지,
* (2) gdpr preblocker 가 여전히 유효한지(병합 후 race 회귀 없음), (3) 확장 자가등록
* (핸들러/레지스트리)이 정상 동작하는지 실측한다.
*/
import { test, expect } from '@playwright/test';
test.describe('확장 병합 번들 로딩', () => {
test('@smoke 홈페이지 로드 시 개별 iife 대신 병합 번들이 요청된다', async ({ page }) => {
const requestedUrls: string[] = [];
page.on('request', (req) => {
requestedUrls.push(req.url());
});
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const moduleBundle = requestedUrls.filter((u) => /\/api\/modules\/bundle[./]js/.test(u));
const pluginBundle = requestedUrls.filter((u) => /\/api\/plugins\/bundle[./]js/.test(u));
// 활성 확장이 있으면 번들이 요청되고, 종류별로 1건씩만 나가야 한다
const individualIife = requestedUrls.filter((u) => /\/api\/(modules|plugins)\/assets\/.*\.iife\.js/.test(u));
// 개별 iife 직접 요청은 0건 (번들로 대체)
expect(individualIife, `개별 iife 요청이 남아있음: ${individualIife.join(', ')}`).toHaveLength(0);
// 번들이 요청됐다면 종류별 최대 1건 (중복 가드)
expect(moduleBundle.length, '모듈 번들 중복 요청').toBeLessThanOrEqual(1);
expect(pluginBundle.length, '플러그인 번들 중복 요청').toBeLessThanOrEqual(1);
});
test('@smoke 병합 번들 응답이 정상(200/304)이고 same-origin 이다', async ({ page }) => {
const bundleResponses: { url: string; status: number }[] = [];
page.on('response', (res) => {
if (/\/api\/(modules|plugins)\/bundle[./](js|css)/.test(res.url())) {
bundleResponses.push({ url: res.url(), status: res.status() });
}
});
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
for (const r of bundleResponses) {
expect([200, 304], `번들 응답 비정상: ${r.url} → ${r.status}`).toContain(r.status);
// same-origin (/api/...) — CDN/외부 origin 금지 (gdpr preblocker 자기차단 방지)
const origin = new URL(page.url()).origin;
expect(r.url.startsWith(origin), `번들 URL 이 same-origin 아님: ${r.url}`).toBe(true);
}
});
test('@smoke 확장 병합 로드 후 페이지가 정상 렌더된다 (자가등록 계약)', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 홈 네비게이션이 렌더되면 템플릿 엔진 + 확장 자가등록이 정상 작동한 것
await expect(page.getByTestId('nav-home')).toBeVisible({ timeout: 15_000 });
// 번들 파싱 에러(ASI 경계 붕괴)가 있으면 "Unexpected token" 등 콘솔 에러가 뜬다
const parseErrors = consoleErrors.filter((e) =>
/Unexpected token|SyntaxError|is not defined|Unknown action handler/i.test(e),
);
expect(parseErrors, `번들 실행 관련 콘솔 에러: ${parseErrors.join(' | ')}`).toHaveLength(0);
});
/**
* 정상 구성에서는 자산 실패 안내가 뜨지 않는다.
*
* 스타일이 비어 있는 확장(0바이트 CSS)만 설치된 기본 구성에서 번들 CSS 가 503 이 되어
* 사용자 화면마다 안내 배너가 떴다. 서버가 그 상태를 정상 빈 200 으로 판정한 뒤로는
* 배너가 없어야 한다.
*
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=present_empty
*
* @effects empty_result_with_present_empty_artifacts_returns_200
*/
test('@smoke 정상 로드 시 자산 실패 안내가 없다', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await expect(page.locator('#g7-asset-failure-notice')).toHaveCount(0);
const failures = await page.evaluate(
() => (window as any).G7Core?.assets?.getFailures?.() ?? [],
);
expect(failures, `자산 실패가 남아있음: ${JSON.stringify(failures)}`).toHaveLength(0);
});
/**
* 진짜 소실(503)일 때의 안내 항목명은 사용자 어휘여야 한다.
*
* 종전에는 번들 구분 키가 그대로 나와 "module을(를) 불러오지 못했습니다" 로 보였다.
*
* // @scenario ext_type=module, asset_kind=css, active_combo=one, file_state=one_missing
*
* @effects bundle_css_failure_banner_uses_user_vocabulary
*/
test('번들 CSS 가 503 이면 안내 항목명이 사용자 어휘다', async ({ page }) => {
// 번들 CSS 는 구성에 따라 정적 게시본(`/build/ext/{v}/bundles/*.css`) 또는 API 로
// 나간다 — 빈 번들도 0바이트로 게시되므로 기본 구성은 정적 URL 이다. 한쪽만 가로채면
// 그 구성에서 이 시나리오가 발화하지 않은 채 통과한다.
await page.route(
/(\/api\/(modules|plugins)\/bundle[./]css|\/build\/ext\/\d+\/bundles\/(modules|plugins)\.css)/,
(route) => route.fulfill({ status: 503, contentType: 'text/css', body: '' }),
);
await page.goto('/');
const notice = page.locator('#g7-asset-failure-notice');
await expect(notice).toBeVisible({ timeout: 30_000 });
const text = (await notice.innerText()).replace(/\s+/g, ' ');
expect(text, `배너 문구: ${text}`).toContain('모듈 스타일');
expect(text, `내부 구분 키가 노출됨: ${text}`).not.toContain('module을(를)');
});
});