편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「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 래핑을 읽도록 보정
437 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|