Files
Gnuboard7/tests/Playwright/specs/layout-editor/empty-container-form-propcontrols.spec.ts
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

151 lines
7.6 KiB
TypeScript

/**
* Layout Editor — 빈 컨테이너 첫 자식 삽입 + 폼 컴포넌트 propControl 편집.
*
* 결함#1 (빈 컨테이너 첫 자식 삽입 accepts 오필터):
* - "+ 요소 추가" 글로벌 팔레트는 선택 노드가 컨테이너이면 그 children 끝에 삽입한다.
* - 컨테이너 여부를 children 배열 존재로 판정하면(`Array.isArray(node.children)`) 자식이
* 아직 없는 빈 컨테이너(Div/Form 등)는 컨테이너로 인식되지 못해 부모 accepts 로 필터된다.
* - 수정: nesting.containers 정의 기준(isContainerComponent — 드롭 경로와 동일)으로 판정.
* 빈 Div 선택 시 팔레트가 Div 자신의 accepts(폼 컴포넌트 포함)로 필터되어야 한다.
*
* 폼 컴포넌트 propControl → 실 DOM 반영 (1-b-verify-2):
* - Input/Label 을 빈 Div 안에 추가하고 속성 탭에서 propControl 을 편집하면
* 캔버스 실 DOM 에 반영되어야 한다(Input type/placeholder, Label htmlFor).
*
* 단위(Vitest): resolveGlobalInsertionTarget(nestingRules.test.ts) 가 빈 컨테이너 판정 로직을,
* PasswordInput.test.tsx 가 data-editor-* 루트 부착을 잠근다. 본 Playwright 는 실제 편집기
* 팔레트 필터 연동 + propControl→DOM 라운드트립을 브라우저로 검증한다.
*
* 축 요약(마커 아님 — 평문): empty_container_first_child, form_propcontrol_edit.
* 효과 요약(마커 아님 — 평문): empty_container_global_palette_filters_by_own_accepts_not_parent, palette_filters_by_parent_accepts.
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
import type { Page } from '@playwright/test';
import { editorPath as resolveEditorPath } from '../../fixtures/layout-editor';
async function gotoEditor(page: Page, route = '%2F'): Promise<void> {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto(`/admin/layout-editor/sirsoft-basic?route=${route}`);
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,
});
}
/** 합성 pointer/click 으로 노드 선택 → overlay info 버튼 등장 대기. */
async function selectByPath(page: Page, path: string): Promise<boolean> {
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 + Math.min(r.width / 2, 10);
const cy = r.top + Math.min(r.height / 2, 10);
for (const type of ['pointerover', 'pointermove', 'pointerdown', 'pointerup', 'click']) {
el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy }));
}
}, path);
return page
.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 5_000 })
.then(() => true)
.catch(() => false);
}
/** "+ 요소 추가" 팔레트 열고 표시된 항목 이름 목록 반환. */
async function openPaletteItems(page: Page): Promise<string[]> {
await page.waitForFunction(
() => !document.querySelector('[data-testid="g7le-toolbar-add-element"]')?.hasAttribute('disabled'),
{ timeout: 10_000 },
);
await page.getByTestId('g7le-toolbar-add-element').click();
await page.waitForSelector('[data-testid^="g7le-palette-item-"]', { timeout: 10_000 });
return page.evaluate(() =>
Array.from(document.querySelectorAll('[data-testid^="g7le-palette-item-"]'))
.map((e) => (e.getAttribute('data-testid') ?? '').replace('g7le-palette-item-', ''))
.filter((n) => n && !n.endsWith('-tag') && !n.endsWith('-badge')),
);
}
async function clickPaletteItem(page: Page, name: string): Promise<void> {
await page.getByTestId(`g7le-palette-item-${name}`).click();
await page.waitForTimeout(400);
}
/** 검증 컨테이너(빈 Div) 를 home content root 안에 추가하고 그 path 를 반환. */
async function addEmptyDiv(page: Page): Promise<string> {
// 첫 세그먼트는 베이스 레이아웃 루트 인덱스라 베이스에 컴포넌트가 추가되면 밀린다 —
// 리터럴 대신 본문 루트 id 로 해석한다.
const contentRoot = await resolveEditorPath(page, 'children.5.children.0.children.0');
expect(await selectByPath(page, contentRoot)).toBe(true);
await openPaletteItems(page);
await clickPaletteItem(page, 'Div');
return page.evaluate((C) => {
const re = new RegExp('^' + C.replace(/\./g, '\\.') + '\\.children\\.\\d+$');
const idxs = Array.from(document.querySelectorAll('[data-editor-path]'))
.map((e) => e.getAttribute('data-editor-path') ?? '')
.filter((p) => re.test(p))
.map((p) => parseInt(p.split('.').pop() as string, 10));
return C + '.children.' + Math.max(...idxs);
}, contentRoot);
}
test.describe('@layout-editor 빈 컨테이너 첫 자식 + 폼 propControl', () => {
/** @effects empty_container_global_palette_filters_by_own_accepts_not_parent, palette_filters_by_parent_accepts */
test('빈 Div 선택 시 팔레트가 그 Div 의 accepts(폼 포함)로 필터된다 (결함#1)', async ({ page }) => {
await gotoEditor(page);
const vdiv = await addEmptyDiv(page);
// 빈 Div 선택 (children 없음)
expect(await selectByPath(page, vdiv)).toBe(true);
const items = await openPaletteItems(page);
// 빈 Div 자신의 accepts 로 필터 → 폼 컴포넌트가 보여야 함 (수정 전엔 부모 Container accepts 라 미표시)
for (const form of ['Label', 'Input', 'Textarea', 'Select', 'Checkbox', 'PasswordInput', 'FileUploader', 'AvatarUploader']) {
expect(items, `빈 Div 팔레트에 ${form} 노출`).toContain(form);
}
// Div accepts(44) 수준 — 부모 Container accepts(23)보다 넓음
expect(items.length).toBeGreaterThan(30);
});
test('빈 Div 에 Input/Label 추가 후 propControl 편집이 캔버스 DOM 에 반영', async ({ page }) => {
await gotoEditor(page);
const vdiv = await addEmptyDiv(page);
// Input 추가
expect(await selectByPath(page, vdiv)).toBe(true);
await openPaletteItems(page);
await clickPaletteItem(page, 'Input');
const inputPath = vdiv + '.children.0';
// Input 속성 탭 → inputType=email, placeholder 편집
expect(await selectByPath(page, inputPath)).toBe(true);
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-tab-props"]', { timeout: 10_000 });
await page.getByTestId('g7le-property-tab-props').click();
// inputType select → email
await page.evaluate(() => {
const ctrl = document.querySelector('[data-testid="g7le-control-inputType"]');
const sel = ctrl?.querySelector('select') as HTMLSelectElement | null;
if (sel) {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!;
setter.call(sel, 'email');
sel.dispatchEvent(new Event('input', { bubbles: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.waitForTimeout(250);
// 캔버스 Input 의 type 속성이 email 로 반영
const inputType = await page.evaluate(
(p) => document.querySelector(`[data-editor-path="${p}"]`)?.getAttribute('type'),
inputPath,
);
expect(inputType).toBe('email');
});
});