Files
Gnuboard7/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts
T
HeuJung 72f46a6468 fix(core): 레이아웃 첨부 URL 을 사이트 상대 경로로 발급하고 저장 게이트가 자기 주소를 외부로 보지 않게 수정
편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은
허용되지 않습니다」 422 로 거부되던 결함. 프록시 서빙 URL 이 절대 형태로 발급되어
NoExternalUrls 가 서버 자신의 주소를 외부로 차단했고, 배경 이미지는 style 로 들어가
스캔 밖이어서 로고(propValue)에서만 드러났다.

- proxyUrl 을 사이트 상대 경로로 발급 (발급 시점 도메인·스킴에 묶이지 않는다)
- SiteAssetHosts 신설: app.url host + 운영자가 선언한 공개 자산 디스크 host 를
 브라우저와 같은 정규화 뒤 등가 비교. 요청 Host 헤더는 근거로 쓰지 않고,
 흉내 host·protocol-relative·위험 스킴은 종전대로 차단
- 회귀 테스트 4클래스(71 passed) + 시나리오 매니페스트 + Playwright 2케이스,
 공개 문서(SECURITY·security·storage-driver·api)와 규정 표 갱신

함께 담긴 릴리즈 정비:
- 7.0.11 미출시 섹션의 릴리즈 날짜를 09-09 로 갱신 (코어·board·page·admin_basic·basic·ja 언어팩 3종)
- 배포본에 실리는 주석·독블록·시나리오의 내부 호칭을 중립 표현으로 정리하고,
 동봉 vendor 자산의 우연한 토큰은 누출 검사에서 경로 규칙으로만 제외
- 버전 bump 검사: github_url 없는 번들을 언어팩과 같은 origin/release 페이로드
 기준선으로 판정(상시 경고 제거), 코어 버전 판독이 env 래핑을 읽도록 보정
2026-09-09 09:20:29 +09:00

437 lines
20 KiB
TypeScript

/**
* Layout Editor — 레이아웃 첨부 URL 발급 모드 왕복 (공개 #134).
*
* 결함: 업로드 응답의 `url` 이 스토리지 설정과 무관하게 항상 공개 서빙 라우트(프록시)로
* 발급되어, 공개 자산 디스크(S3+CDN)를 설정해도 방문자 요청이 매번 오리진 PHP 를 거쳤다.
*
* 이 spec 은 첨부를 **실제로 업로드해 왕복**한다 — 기존 background-image-render.spec 은
* URL 을 손으로 입력할 뿐 업로드 경로를 타지 않아 이 결함을 원리상 포착할 수 없었다.
* 기본 설치(공개 자산 디스크 미설정) 상태에서 업로드 응답 URL 이 프록시 형태이고 그
* 주소가 인증 없이 200 으로 열리는지, 그리고 그 URL 이 위젯 값·캔버스에 그대로
* 반영되는지를 본다. 직접 URL(CDN) 축은 서버 설정 변경이 필요하므로 브라우저 실측
* 매트릭스가 담당하고, 여기서는 서버 응답 형태를 가공 없이 보존하는지를 고정한다.
*
* 축 요약(마커 아님 — 평문): public_asset_disk=unset, row_disk=attachments,
* filter_url_hook=absent.
*
* 뒤의 두 테스트는 별도 매니페스트(layout-attachment-url-storage-gate.yaml)의 축이다 —
* 발급 url 이 사이트 상대 경로여야 헤더 「로고 이미지」 같은 propValue 컨트롤의 저장이
* 외부 URL 차단 규칙에 걸리지 않는다. 절대 URL 이던 시절 그 저장은 422 였다.
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
import { bodyRootPath } from '../../fixtures/layout-editor';
import { SANDBOX_ROOT_ID, SANDBOX_ROUTE, sandboxRouteParam } from '../../fixtures/seed-layout';
import type { Page } from '@playwright/test';
/** 1x1 투명 PNG (base64) — 업로드 픽스처 */
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
/**
* 캔버스의 배경이 보일 만한 박스형 Div 영역을 골라 path 를 돌려준다.
* 좌측 라우트 트리 패널과 겹치지 않도록 캔버스 안쪽(left > 360)으로 한정한다.
*/
async function pickBoxAreaPath(page: Page): Promise<string | null> {
return page.evaluate(() => {
const cands = Array.from(
document.querySelectorAll('[data-editor-path][data-editor-name="Div"]'),
).filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 200 && r.width < 800 && r.height > 80 && r.height < 320 && r.left > 360;
});
return cands[0]?.getAttribute('data-editor-path') ?? null;
});
}
/** 캔버스 위임 click 핸들러로 노드를 선택한다(오버레이가 좌표를 가로채는 것 회피). */
async function selectNode(page: Page, path: string): Promise<void> {
await page.evaluate((p) => {
const el = document.querySelector(`[data-editor-path="${p}"]`);
if (!el) return;
el.scrollIntoView({ block: 'center' });
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const types = [
'pointerover',
'pointermove',
'pointerdown',
'mousedown',
'pointerup',
'mouseup',
'click',
];
for (const type of types) {
el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy }));
}
}, path);
}
async function openStyleTab(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 10_000 });
await page.getByTestId('g7le-overlay-info-button').click();
await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 });
await page.getByTestId('g7le-context-menu-edit-props').click();
await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 10_000 });
await page.getByTestId('g7le-property-tab-style').click();
}
/** ⓘ → 속성 편집 → props 탭 (헤더 「로고 이미지」 컨트롤이 있는 탭). */
async function openPropsTab(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 10_000 });
await page.getByTestId('g7le-overlay-info-button').click();
await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 });
await page.getByTestId('g7le-context-menu-edit-props').click();
await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 10_000 });
await page.getByTestId('g7le-property-tab-props').click();
}
async function enterEditor(page: Page): Promise<void> {
await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, {
timeout: 20_000,
});
}
/** 첨부 1건을 업로드하고 `{ id, url }` 을 돌려준다. */
async function uploadAttachment(
page: Page,
token: string,
filename: string,
): Promise<{ id: number | null; url: string | null; status: number }> {
return page.evaluate(
async ({ b64, bearer, name }) => {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
const form = new FormData();
form.append('file', new File([bytes], name, { type: 'image/png' }), name);
form.append('layout_name', 'home');
const res = await fetch('/api/admin/templates/sirsoft-basic/layout-attachments', {
method: 'POST',
headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
body: form,
credentials: 'same-origin',
});
const body = await res.json().catch(() => null);
return { status: res.status, id: body?.data?.id ?? null, url: body?.data?.url ?? null };
},
{ b64: PNG_BASE64, bearer: token, name: filename },
);
}
/** 이 spec 이 만든 첨부를 지운다 (공유 사이트에 잔여물을 남기지 않는다). */
async function deleteAttachment(page: Page, token: string, id: number | null): Promise<void> {
if (id === null) return;
await page.evaluate(
async ({ attachmentId, bearer }) => {
await fetch(`/api/admin/templates/layout-attachments/${attachmentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
credentials: 'same-origin',
});
},
{ attachmentId: id, bearer: token },
);
}
test.describe('@layout-editor 레이아웃 첨부 URL 발급 모드', () => {
/**
* @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
*
* @effects proxy_url_otherwise
*/
test('업로드 → 응답 URL 이 프록시 형태이고 인증 없이 열린다', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
// 이 축은 서버 응답 형태만 보므로 편집기 진입이 필요 없다 — same-origin 페이지면 충분하다.
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const uploaded = await uploadAttachment(page, token, 'e2e-url-mode.png');
expect(uploaded.status).toBe(200);
expect(uploaded.url).toBeTruthy();
// 공개 자산 디스크 미설정 = 기본 설치 → 공개 서빙 라우트(프록시) 형태
expect(uploaded.url as string).toContain(`/layout-attachments/${uploaded.id}/file`);
// 사이트 상대 경로 — 절대 URL 이면 저장 게이트(NoExternalUrls)가 서버 자신의 주소를 외부로 차단한다
expect(uploaded.url as string).toMatch(/^\/api\//);
// 그 주소는 인증 없이 이미지로 열려야 한다 (발행 배경은 방문자에게 로드된다)
const served = await page.evaluate(async (url: string) => {
const res = await fetch(url, { credentials: 'omit' });
return { status: res.status, type: res.headers.get('content-type') };
}, uploaded.url as string);
expect(served.status).toBe(200);
expect(served.type ?? '').toContain('image/');
await deleteAttachment(page, token, uploaded.id);
});
/**
* 서버가 준 URL 문자열이 위젯 값과 캔버스 배경에 가공 없이 반영돼야 한다.
* 상대 경로를 가정해 접두사를 붙이면 직접 URL(CDN) 모드에서 주소가 깨진다.
*
* @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
*
* @effects editor_thumbnail_renders_cross_origin
*/
test('업로드한 첨부 URL 이 위젯 값·캔버스 배경에 그대로 반영된다', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await enterEditor(page);
const uploaded = await uploadAttachment(page, token, 'e2e-bind.png');
expect(uploaded.url).toBeTruthy();
const targetPath = await pickBoxAreaPath(page);
expect(targetPath).not.toBeNull();
await selectNode(page, targetPath as string);
await openStyleTab(page);
const urlInput = page.getByTestId('g7le-image-url');
await urlInput.fill(uploaded.url as string);
await urlInput.blur();
// 입력칸이 서버 URL 을 그대로 보존
await expect(urlInput).toHaveValue(uploaded.url as string);
// 캔버스 inline background-image 에도 같은 주소가 실린다
await expect
.poll(
async () =>
page.evaluate((p) => {
const el = document.querySelector(`[data-editor-path="${p}"]`);
return el ? getComputedStyle(el).backgroundImage : '';
}, targetPath),
{ timeout: 8_000 },
)
.toContain(`/layout-attachments/${uploaded.id}/file`);
// 저장하지 않았으므로 레이아웃은 그대로. 첨부만 삭제한다.
await deleteAttachment(page, token, uploaded.id);
});
/**
* 저장까지 왕복해 **방문자 화면이 실제로 요청하는 주소**를 확인한다.
*
* 앞의 두 테스트는 업로드 응답과 편집기 화면까지만 본다. 이 결함의 최종 증상은 방문자
* 요청이 어디로 나가느냐이므로, 저장된 레이아웃이 그리는 화면에서 그 주소가 실제로
* 요청되고 200 으로 응답하는지까지 봐야 축이 닫힌다.
*
* 저장(PUT)은 편집 결과가 그대로 영속되므로 제품 화면(`home`)이 아니라 E2E 전용 시드
* 화면(`e2e_sandbox`)을 대상으로 한다 — globalSetup 이 매 실행 fixture 원본으로 덮어쓰므로
* 원복 절차가 필요 없다(원복 자체가 또 한 번의 저장이라 실패 시 잔여물이 남는다).
*
* @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
*
* @effects proxy_url_otherwise, saved_layout_url_is_what_visitor_requests
*/
test('배경 적용 → 저장 → 방문자 화면이 그 주소를 요청한다', async ({ page }) => {
test.setTimeout(90_000); // 업로드 + 저장 + 방문자 렌더 합산
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto(`/admin/layout-editor/sirsoft-basic?route=${sandboxRouteParam()}`);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, {
timeout: 20_000,
});
const uploaded = await uploadAttachment(page, token, 'e2e-save-roundtrip.png');
expect(uploaded.url).toBeTruthy();
// 시드 화면은 본문 컨테이너 id 가 고정이라 후보 순회 없이 곧바로 지목할 수 있다.
const rootPath = await bodyRootPath(page, SANDBOX_ROOT_ID);
await selectNode(page, rootPath);
await openStyleTab(page);
const urlInput = page.getByTestId('g7le-image-url');
await urlInput.fill(uploaded.url as string);
await urlInput.blur();
await expect(urlInput).toHaveValue(uploaded.url as string);
// 모달을 확실히 닫은 뒤 툴바 저장 — 모달이 열린 채면 안쪽 버튼이 잡혀 PUT 이 안 난다.
await page
.getByTestId('g7le-property-modal-done')
.click({ timeout: 2_000 })
.catch(() => undefined);
await page
.getByRole('button', { name: /^(닫기|Close)$/ })
.first()
.click({ timeout: 3_000 })
.catch(() => undefined);
await page.waitForTimeout(300);
const savePromise = page.waitForResponse(
(r) =>
/\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) &&
r.request().method() === 'PUT',
{ timeout: 20_000 },
);
await page.getByTestId('g7le-toolbar-save').click();
const saveRes = await savePromise;
expect(saveRes.status()).toBe(200);
// 방문자 화면 — 저장된 문자열이 실제 요청으로 나가는지 본다.
const visitor = await page.context().newPage();
// 발급 url 은 사이트 상대 경로다 — 응답 URL(절대)과는 pathname 으로 비교한다.
const uploadedPath = new URL(uploaded.url as string, 'https://placeholder.invalid').pathname;
const attachmentRequests: number[] = [];
visitor.on('response', (res) => {
if (new URL(res.url()).pathname === uploadedPath) attachmentRequests.push(res.status());
});
await visitor.goto(SANDBOX_ROUTE['sirsoft-basic']);
await visitor.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect
.poll(() => attachmentRequests.length, { timeout: 15_000 })
.toBeGreaterThan(0);
expect(attachmentRequests.every((s) => s === 200)).toBe(true);
await visitor.close();
// 시드 화면은 다음 실행에서 fixture 로 덮이므로 첨부만 정리한다.
await deleteAttachment(page, token, uploaded.id);
});
/**
* 헤더 「로고 이미지」는 값 슬롯이 하나뿐인 propValue 컨트롤이라 업로드 응답 url 이 그대로
* props 에 들어간다. 그 값이 사이트 상대 경로여야 저장 게이트를 통과한다 — 절대 URL 이던
* 시절 이 흐름은 "업로드는 되는데 저장은 422" 였다.
*
* 공통 레이아웃(_user_base)은 제품 화면이므로 여기서는 저장하지 않는다 — 위젯 값과 캔버스
* src 까지만 본다. 저장 왕복은 다음 테스트가 샌드박스 화면에서 잠근다.
*/
// @scenario url_host=site_relative_path
// @effects editor_logo_control_receives_site_relative_url, proxy_url_is_site_relative
test('헤더 로고 컨트롤에서 파일 업로드 → 위젯 값·캔버스 src 가 사이트 상대 경로다', async ({ page }) => {
test.setTimeout(90_000);
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F&edit=__base__%2F_user_base');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, {
timeout: 20_000,
});
const headerPath = await page.evaluate(
() =>
document
.querySelector('[data-editor-path][data-editor-name="Header"]')
?.getAttribute('data-editor-path') ?? null,
);
expect(headerPath, '공통 레이아웃에 Header 노드가 있어야 합니다').not.toBeNull();
await selectNode(page, headerPath as string);
await openPropsTab(page);
// 로고 값이 환경설정과 연결된 표현식이면 「직접 지정으로 바꾸기」로 열어야 업로드가 가능하다.
const replace = page.getByTestId('g7le-image-expression-replace');
if (await replace.isVisible().catch(() => false)) {
await replace.click();
}
const uploadPromise = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/layout-attachments$/.test(new URL(r.url()).pathname),
{ timeout: 20_000 },
);
await page
.locator('[data-testid="g7le-property-modal"] input[type="file"]')
.setInputFiles({ name: 'e2e-logo.png', mimeType: 'image/png', buffer: Buffer.from(PNG_BASE64, 'base64') });
const uploadRes = await uploadPromise;
expect(uploadRes.status()).toBe(200);
const body = (await uploadRes.json()) as { data?: { id?: number; url?: string } };
const url = body?.data?.url ?? null;
const id = body?.data?.id ?? null;
expect(url).not.toBeNull();
expect(url as string).toMatch(/^\/api\//);
// 위젯 값 = 발급 url (가공 없음), 캔버스 Header <img src> 도 같은 값
await expect(page.getByTestId('g7le-image-url')).toHaveValue(url as string);
await expect
.poll(
() =>
page.evaluate(
(p) => document.querySelector(`[data-editor-path="${p}"] img`)?.getAttribute('src') ?? null,
headerPath,
),
{ timeout: 8_000 },
)
.toBe(url);
await deleteAttachment(page, token, id);
});
/**
* 발급 url 을 이미지 prop(`Img.src` — 로고와 같은 props sink)에 넣고 저장하면 200 이어야 하고,
* 그 화면을 연 방문자가 같은 주소를 200 으로 받아야 한다. 절대 URL 이던 시절 이 PUT 은
* 「HTTPS 프로토콜 URL은 허용되지 않습니다」 422 였다.
*
* 저장은 E2E 전용 시드 화면(e2e_sandbox)에 한다 — globalSetup 이 매 실행 fixture 로 덮는다.
*/
// @scenario url_host=site_relative_path
// @effects issued_asset_url_passes_storage_gate, saved_logo_renders_for_visitor
test('발급 url 을 이미지 prop 에 넣고 저장 → 200, 방문자 화면이 그 주소를 200 으로 받는다', async ({ page }) => {
test.setTimeout(90_000);
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const uploaded = await uploadAttachment(page, token, 'e2e-prop-gate.png');
expect(uploaded.status).toBe(200);
expect(uploaded.url as string).toMatch(/^\/api\//);
const saved = await page.evaluate(
async ({ bearer, url }) => {
const headers = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' };
const show = await fetch('/api/admin/templates/sirsoft-basic/layouts/e2e_sandbox', { headers });
const showBody = await show.json().catch(() => null);
const data = showBody?.data ?? {};
// show 응답의 content 는 편집 페이지용 JSON 문자열이다 (LayoutResource).
const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content;
if (!content?.slots?.content?.[0]) return { status: -1, reason: 'sandbox content shape' };
const root = content.slots.content[0];
root.children = Array.isArray(root.children) ? root.children : [];
root.children.push({
id: 'e2e_prop_gate_img',
type: 'basic',
name: 'Img',
props: { src: url, alt: 'e2e prop gate', className: 'h-6' },
});
const put = await fetch('/api/admin/templates/sirsoft-basic/layouts/e2e_sandbox', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_lock_version: data.lock_version ?? 0, content }),
credentials: 'same-origin',
});
const putBody = await put.json().catch(() => null);
return { status: put.status, message: putBody?.message ?? null };
},
{ bearer: token, url: uploaded.url as string },
);
expect(saved, saved.message ?? '').toMatchObject({ status: 200 });
// 방문자 — 저장된 문자열이 실제 요청으로 나가고 200 으로 응답하는지
const visitor = await page.context().newPage();
const uploadedPath = new URL(uploaded.url as string, 'https://placeholder.invalid').pathname;
const statuses: number[] = [];
visitor.on('response', (res) => {
if (new URL(res.url()).pathname === uploadedPath) statuses.push(res.status());
});
await visitor.goto(SANDBOX_ROUTE['sirsoft-basic']);
await visitor.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect.poll(() => statuses.length, { timeout: 15_000 }).toBeGreaterThan(0);
expect(statuses.every((s) => s === 200)).toBe(true);
await expect(visitor.locator(`img[src="${uploaded.url}"]`)).toHaveCount(1);
await visitor.close();
await deleteAttachment(page, token, uploaded.id);
});
});