표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이 갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상 대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다. 판정만 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종 등재
188 lines
8.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|