/** * 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 { 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 { 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 { 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 { 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 { 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 { 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 도 같은 값 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); }); });