Files
Gnuboard7/tests/Playwright/specs/production-sourcemap-exposure.spec.ts
HeuJung 953616552f fix(core,engine,ecommerce,admin_basic): 렌더 경로 판정·파서 단일화로 표현식 비대칭 일괄 해소
표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이
갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상
대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다.

판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은
회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다.

- 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 /
 _computed stale 전파 / 원본 타입 소실)
- 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설
- 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인
- BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환
- iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬
- 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임
- 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지
 못하던 결함 수정
- SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다
- audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소,
 신규 룰 5종 등재
2026-08-04 09:05:00 +09:00

188 lines
8.0 KiB
TypeScript

/**
* E2E: 프로덕션 소스맵 노출 차단 회귀 검증
*
* @scenario production-sourcemap-exposure
* @effects no_sourcemap_requests_from_loaded_bundles,
* page_renders_without_map_related_console_errors,
* module_sourcemap_asset_request_rejected_even_when_file_exists,
* plugin_sourcemap_asset_request_rejected_even_when_file_exists
*
* 배경: 소스맵(.map)에는 원본 TS/TSX 전문이 담긴다. 배포 산출물에서 (1) 맵 생성 자체를
* 막고, (2) 서빙을 거부하며, (3) 저장소 유입을 차단했다.
*
* 이 spec 은 브라우저 실측으로 두 가지를 고정한다:
* - 로드된 코어/확장 번들이 맵을 요청하지 않는다 (sourceMappingURL 주석 부재의 결과)
* - 맵 주석 제거가 번들을 깨뜨리지 않았다 (페이지 정상 렌더 + 콘솔 에러 없음)
*
* 주의: 맵 주석이 남아있는데 .map 만 지운 상태였다면 브라우저가 맵을 요청해 404 가
* 발생한다. 이 spec 은 그 회귀(주석/파일 불일치)도 함께 잡는다.
*/
import { test, expect } from '@playwright/test';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** 소스맵 요청으로 간주할 URL 패턴 */
const MAP_REQUEST = /\.map(\?|$)/;
/**
* 대상 인스턴스의 `APP_ENV` 를 읽습니다 (코어 `.env`).
*
* playwright.config.ts 의 base URL 해석과 같은 방식이다 — 전용 엔드포인트에 의존하지 않는다.
*
* @returns APP_ENV 값 (판독 실패 시 null)
*/
function appEnv(): string | null {
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const envPath = resolve(root, '.env');
if (!existsSync(envPath)) return null;
const match = readFileSync(envPath, { encoding: 'utf-8' }).match(/^APP_ENV=(.*)$/m);
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
}
test.describe('프로덕션 소스맵 노출 차단', () => {
test('@smoke 사용자 페이지 로드 시 소스맵을 요청하지 않는다', async ({ page }) => {
const mapRequests: string[] = [];
page.on('request', (req) => {
if (MAP_REQUEST.test(req.url())) {
mapRequests.push(req.url());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(
mapRequests,
`소스맵 요청이 발생했습니다(산출물에 sourceMappingURL 주석 잔존): ${mapRequests.join(', ')}`,
).toHaveLength(0);
});
test('@smoke 관리자 페이지 로드 시 소스맵을 요청하지 않는다', async ({ page }) => {
const mapRequests: string[] = [];
page.on('request', (req) => {
if (MAP_REQUEST.test(req.url())) {
mapRequests.push(req.url());
}
});
// 로그인 전 진입 화면이라도 코어 번들(template-engine)은 로드된다
await page.goto('/admin');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(
mapRequests,
`관리자 페이지에서 소스맵 요청 발생: ${mapRequests.join(', ')}`,
).toHaveLength(0);
});
test('@smoke 맵 주석 제거 후에도 페이지가 정상 렌더된다', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
// 정적 에셋(JS/CSS/MAP) 로드 실패만 수집한다. 도메인 API 응답(예: 이커머스
// checkout 검증 422)은 소스맵 정책과 무관하므로 이 spec 의 판정 대상이 아니다.
const failedAssets: string[] = [];
page.on('response', (res) => {
if (res.status() >= 400 && /\.(js|css|map)(\?|$)/.test(res.url())) {
failedAssets.push(`${res.url()} → ${res.status()}`);
}
});
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 렌더 성공 = 코어 엔진 + 확장 번들이 모두 정상 파싱/실행됨
await expect(page.getByTestId('nav-home')).toBeVisible({ timeout: 15_000 });
// 맵 주석만 남고 파일이 없으면 404 가 뜬다 (주석/파일 불일치 회귀)
expect(
failedAssets,
`에셋 로드 실패(맵/JS/CSS): ${failedAssets.join(' | ')}`,
).toHaveLength(0);
// 맵 주석 제거가 번들을 깨뜨렸다면 파싱/실행 에러가 뜬다.
const parseErrors = consoleErrors.filter((e) =>
/Unexpected token|SyntaxError|is not defined/i.test(e),
);
expect(parseErrors, `번들 실행 관련 콘솔 에러: ${parseErrors.join(' | ')}`).toHaveLength(0);
});
// 이 테스트는 **프로덕션(비-local) 전제**다.
// `AllowedPluginFileType` / `AllowedModuleFileType` 은 `app()->environment('local')` 일 때만
// 허용 확장자에 `map` 을 덧붙인다 (로컬 dev 빌드 산출물 디버깅용, 의도된 분기). 따라서 로컬에서는
// 200 이 정상이며, 여기서 실패하면 결함이 아니라 환경 불일치다 — 그 환경에서는 건너뛴다.
test('@smoke 확장 소스맵 에셋은 직접 요청해도 서빙되지 않는다', async ({ request }) => {
test.skip(
appEnv() === 'local',
'APP_ENV=local 은 소스맵 서빙을 의도적으로 허용한다 (프로덕션 전제 테스트)',
);
// 디스크에 맵이 없더라도, 허용 확장자에서 제외됐으므로 200 이 나오면 안 된다.
const targets = [
'/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js.map',
'/api/plugins/assets/sirsoft-gdpr/dist/js/plugin.iife.js.map',
];
for (const target of targets) {
const res = await request.get(target, { maxRedirects: 0, failOnStatusCode: false });
expect(res.status(), `${target} 이 200 으로 서빙됨 — 소스맵 노출`).not.toBe(200);
const body = await res.text().catch(() => '');
expect(
body.includes('sourcesContent'),
`${target} 응답에 원본 코드(sourcesContent)가 포함됨`,
).toBe(false);
}
});
test('@smoke 결제 플러그인이 체크아웃 외 페이지에서 체크아웃을 프리페치하지 않는다', async ({ page }) => {
// 플러그인 번들은 모든 페이지에 로드된다. nicepayments 캐시 프리페치가 경로를
// 가리지 않으면 비회원이 모든 페이지에서 422(cart_key_required)를 받는다.
const checkoutRequests: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/sirsoft-ecommerce/checkout')) {
checkoutRequests.push(req.url());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(
checkoutRequests,
`홈에서 체크아웃 프리페치 발생: ${checkoutRequests.join(', ')}`,
).toHaveLength(0);
});
// 이 테스트도 **프로덕션(비-local) 전제**다 — 위 확장 소스맵 테스트와 같은 이유.
// `vite.config.core.js` 는 배포 빌드(`G7_BUILD_SOURCEMAP=0`)에서만 맵 생성을 끄고,
// 로컬 `core:build` 는 디버깅을 위해 의도적으로 맵을 생성한다. 코어 맵은 `public/build/`
// 아래에 있어 웹서버가 정적으로 서빙하므로, 로컬에서 한 번이라도 빌드하면 200 이 나온다.
// 그건 결함이 아니라 환경 불일치이므로 형제 테스트와 동일하게 건너뛴다.
test('@smoke 코어 소스맵은 웹루트에서 서빙되지 않는다', async ({ request }) => {
test.skip(
appEnv() === 'local',
'APP_ENV=local 은 디버깅을 위해 코어 빌드가 소스맵을 생성한다 (프로덕션 전제 테스트)',
);
const targets = [
'/build/core/template-engine.min.js.map',
'/build/core/layout-editor.min.js.map',
'/build/core/devtools.min.js.map',
];
for (const target of targets) {
const res = await request.get(target, { maxRedirects: 0, failOnStatusCode: false });
expect(res.status(), `${target} 이 200 으로 서빙됨 — 원본 코드 노출`).not.toBe(200);
}
});
});