feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인

StorageInterface::url 의 public 하드코딩을 완화하고 core.storage.filter_url 훅을
신설해, 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)의 직접 URL 서빙을 옵트인으로
열었다. 미설정 시 기존 스트리밍 100% 보존.

디스크 결정은 확장 개별 설정 > 코어 전역 > 미설정 순이며, 서빙·삭제·이동은 행에 기록된
disk 를 따라 전환 전후 자산이 혼재해도 각자 실제 저장 위치를 향한다. 고아 disk(공급
플러그인 비활성화)는 스트리밍으로 폴백해 500 이 되지 않는다.

공개 이슈 (@lyg-kaban 건의) 대응.
This commit is contained in:
HeuJung
2026-08-14 08:05:54 +09:00
parent 37fad55333
commit 057e002ad1
125 changed files with 5913 additions and 203 deletions
@@ -9,13 +9,14 @@
### Added
- 환경설정 > 드라이버 > 파일 스토리지에 S3 호환 스토리지 설정이 추가되었습니다 — 엔드포인트 URL 입력칸과 Path-style 주소 토글로 Cloudflare R2, MinIO 등도 연결할 수 있습니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- 환경설정 > 드라이버 탭에 「공개 자산 스토리지」 카드가 추가되었습니다. 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)을 직접 URL(CDN)로 서빙할 디스크를 고를 수 있으며, 기본값(사용 안 함)이면 종전 스트리밍 방식 그대로입니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
### Changed
- S3 리전이 5개 목록 선택에서 자유 입력으로 바뀌었습니다 — 새로 생기는 AWS 리전이나 R2 의 `auto` 값을 그대로 입력할 수 있습니다.
- S3 URL 항목의 이름과 설명을 "공개 URL(CDN)" 용도로 명확히 했습니다 — API 요청 주소는 새로 생긴 엔드포인트 URL 칸에 입력합니다.
- 웹소켓 연결 테스트가 서버 발송용 주소까지 함께 검사하도록 테스트 요청 항목이 확장되었습니다.
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다).
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력, 공개 자산 스토리지 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다).
### Fixed
@@ -0,0 +1,270 @@
/**
* 드라이버 탭 — 공개 자산 스토리지 카드 테스트 (공개#100)
*
* @description
* 신규 "공개 자산 스토리지" 카드는 storage 카드(#99 작업 구역)의 **형제**로 존재해야
* 하며, Select 는 저장 키(drivers.public_asset_disk)와 서버 카탈로그
* (available_drivers.public_asset)를 정확히 바인딩해야 한다. 화면 옵션이 카탈로그를
* 벗어나면 저장 단계(레지스트리 조회 closure rule)에서 422 가 된다.
*
* 계약 단언(JSON 구조 + 저장 규칙 대조)에 더해, 실제 카드 서브트리를
* createLayoutTest 로 렌더링해 옵션 계산식($localized 매핑)과 s3 도움말 조건부
* 렌더링까지 검증한다.
*
* @effects settings_catalog_includes_plugin_registered_disks
*/
import React from 'react';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
import tabDrivers from '../../layouts/partials/admin_settings/_tab_drivers.json';
// ─── 테스트용 컴포넌트 ───
const TestDiv: React.FC<{ className?: string; children?: React.ReactNode; 'data-testid'?: string }> =
({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <h3 className={className}>{children || text}</h3>;
const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <label className={className}>{children || text}</label>;
const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <span className={className}>{children || text}</span>;
const TestSelect: React.FC<{
name?: string; value?: string; className?: string; options?: any[]; disabled?: boolean;
error?: string; onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}> = ({ name, value, className, options, disabled, onChange }) => (
<select data-name={name} value={value} className={className} disabled={disabled} onChange={onChange}>
{options?.map((o) => <option key={String(o.value)} value={o.value}>{o.label}</option>)}
</select>
);
// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다.
// 미등록 시 트리 전체가 렌더되지 않아 "없어야 한다" 계열 단언이 거짓 통과한다.
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
// ─── 다국어 (드라이버 탭 공개 자산 카드가 쓰는 키) ───
const translations = {
admin: {
settings: {
drivers: {
public_asset: {
title: '공개 자산 스토리지',
desc: '완전 공개 자산의 직접 URL 서빙 디스크를 설정합니다.',
disk: '공개 자산 디스크',
s3_help: '스토리지 카드의 S3 URL 설정이 필요합니다.',
},
},
},
},
};
/**
* 저장소 루트를 위로 훑어 찾는다.
*
* @returns 저장소 루트 절대경로
*/
function repositoryRoot(): string {
let current = path.dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 10; depth++) {
if (fs.existsSync(path.join(current, 'config/core.php'))) {
return current;
}
current = path.dirname(current);
}
throw new Error('저장소 루트를 찾지 못했습니다.');
}
/**
* 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다.
*
* @param node 탐색 시작 노드
* @param predicate 노드 판정 함수
* @returns 찾은 노드 또는 null
*/
function findNode(node: any, predicate: (n: any) => boolean): any {
if (!node || typeof node !== 'object') return null;
if (predicate(node)) return node;
for (const child of node.children ?? []) {
const found = findNode(child, predicate);
if (found) return found;
}
return null;
}
// 실제 카드 서브트리를 그대로 렌더 대상으로 쓴다 — 사본 표현식이 아닌 배포 JSON 자체가 SSoT.
const publicAssetCard = (tabDrivers as any).children.find(
(c: any) => c.id === 'card_public_asset_settings',
);
const CATALOG = [
{ id: 'none', label: { ko: '사용 안 함 (스트리밍)', en: 'None (streaming)' } },
{ id: 'public', label: { ko: '로컬 공개 디스크', en: 'Local public disk' } },
{ id: 'fake_cdn', label: { ko: '가짜 CDN', en: 'Fake CDN' } },
];
/**
* 카드 렌더링용 테스트 유틸을 생성한다.
*
* @param selectedDisk 현재 저장된 공개 자산 디스크 값
* @returns createLayoutTest 유틸
*/
function makeUtils(selectedDisk: string) {
return createLayoutTest(
{
version: '1.0.0',
layout_name: 'test_public_asset_storage_card',
components: [publicAssetCard],
} as any,
{
auth: {
isAuthenticated: true,
user: { id: 1, name: 'Admin', role: 'super_admin' },
authType: 'admin',
},
translations,
locale: 'ko',
initialState: {
_local: {
form: {
available_drivers: { public_asset: CATALOG },
drivers: { public_asset_disk: selectedDisk },
},
errors: null,
},
},
},
);
}
describe('드라이버 탭 공개 자산 스토리지 카드 — 계약', () => {
it('storage 카드의 형제로 카드가 존재한다 (#99 s3 블록 비접촉)', () => {
const ids = (tabDrivers as any).children.map((c: any) => c.id);
expect(ids).toContain('card_storage_settings');
expect(ids).toContain('card_public_asset_settings');
// storage 카드 내부가 아닌 최상위 형제 배열에 위치
expect(ids.indexOf('card_public_asset_settings')).toBeGreaterThan(
ids.indexOf('card_storage_settings'),
);
});
it('Select 가 저장 키와 카탈로그를 정확히 바인딩한다', () => {
const select = findNode(publicAssetCard, (n) => n.name === 'Select');
expect(select).not.toBeNull();
expect(select.props.name).toBe('drivers.public_asset_disk');
expect(select.props.options).toContain('available_drivers?.public_asset');
expect(select.props.error).toContain("['drivers.public_asset_disk']");
});
it('s3 선택 시에만 도움말이 노출된다', () => {
const help = findNode(publicAssetCard, (n) => n.id === 'public_asset_s3_help');
expect(help).not.toBeNull();
expect(help.if).toContain("public_asset_disk === 's3'");
});
it('저장 규칙(SaveSettingsRequest)이 같은 키를 검증한다', () => {
const requestSource = fs.readFileSync(
path.join(repositoryRoot(), 'app/Http/Requests/Settings/SaveSettingsRequest.php'),
'utf-8',
);
expect(requestSource).toContain("'drivers.public_asset_disk'");
expect(requestSource).toContain("isDriverAvailable('public_asset'");
});
});
describe('드라이버 탭 공개 자산 스토리지 카드 — 렌더링', () => {
let registry: ComponentRegistry;
let testUtils: ReturnType<typeof createLayoutTest> | null = null;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
testUtils?.cleanup();
testUtils = null;
(registry as any).registry = {};
});
it('카드 제목/라벨이 다국어 해석되어 렌더된다', async () => {
testUtils = makeUtils('public');
const { container } = await testUtils.render();
expect(container.querySelector('h3.card-title')?.textContent ?? '').toContain(
'공개 자산 스토리지',
);
expect(container.querySelector('label.form-label')?.textContent ?? '').toContain(
'공개 자산 디스크',
);
expect(() => testUtils!.assertNoValidationErrors()).not.toThrow();
});
it('Select 옵션이 서버 카탈로그에서 $localized 매핑으로 계산된다', async () => {
testUtils = makeUtils('public');
const { container } = await testUtils.render();
const select = container.querySelector('select[data-name="drivers.public_asset_disk"]');
expect(select).not.toBeNull();
const options = Array.from(select!.querySelectorAll('option'));
expect(options.map((o) => o.getAttribute('value'))).toEqual([
'none',
'public',
'fake_cdn',
]);
// $localized(d.label) — 활성 로케일(ko) 라벨로 해석되어야 한다
expect(options.map((o) => o.textContent)).toEqual([
'사용 안 함 (스트리밍)',
'로컬 공개 디스크',
'가짜 CDN',
]);
});
it('s3 미선택 시 도움말이 렌더되지 않는다', async () => {
testUtils = makeUtils('public');
const { container } = await testUtils.render();
// 긍정 앵커 — 카드가 실제로 렌더됐음을 먼저 확인해야 아래 부정 단언이 의미를 갖는다
expect(container.querySelector('select[data-name="drivers.public_asset_disk"]')).not.toBeNull();
expect(container.textContent ?? '').not.toContain('S3 URL 설정');
});
it('s3 선택 시 도움말이 렌더된다', async () => {
testUtils = makeUtils('s3');
const { container } = await testUtils.render();
expect(container.textContent ?? '').toContain('스토리지 카드의 S3 URL 설정이 필요합니다.');
});
});
@@ -1637,6 +1637,12 @@
"s3_url": "S3 URL (Public URL)",
"s3_url_desc": "CDN or custom domain used to build public file URLs. Enter the API address in the Endpoint URL field instead."
},
"public_asset": {
"title": "Public Asset Storage",
"desc": "Configure the disk for serving fully public assets (product, category, review, and editor images, etc.) via direct URLs. When disabled, the existing streaming method is used.",
"disk": "Disk",
"s3_help": "When selecting S3, the S3 URL (CDN domain) in the File Storage card above must be configured. Without it, streaming is used."
},
"cache": {
"title": "Cache",
"desc": "Configure application cache driver settings.",
@@ -1641,6 +1641,12 @@
"s3_url": "S3 URL (공개 URL)",
"s3_url_desc": "파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인입니다. API 요청 주소는 엔드포인트 URL에 입력하세요."
},
"public_asset": {
"title": "공개 자산 스토리지",
"desc": "완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)의 직접 URL 서빙 디스크를 설정합니다. 사용 안 함이면 기존 스트리밍 방식으로 동작합니다.",
"disk": "디스크",
"s3_help": "S3 선택 시 위 파일 스토리지 카드의 S3 URL(CDN 도메인) 설정이 필요합니다. 미설정 시 스트리밍으로 동작합니다."
},
"cache": {
"title": "캐시",
"desc": "애플리케이션 캐시 드라이버를 설정합니다.",
@@ -557,6 +557,98 @@
}
]
},
{
"id": "card_public_asset_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "card-title"
},
"text": "$t:admin.settings.drivers.public_asset.title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "card-description"
},
"text": "$t:admin.settings.drivers.public_asset.desc"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "field_public_asset_disk",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.public_asset.disk"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "w-full md:w-64"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "drivers.public_asset_disk",
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.public_asset ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['drivers.public_asset_disk']}}",
"props": {
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}"
}
]
}
]
},
{
"id": "public_asset_s3_help",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.drivers?.public_asset_disk === 's3'}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 mt-2"
},
"text": "$t:admin.settings.drivers.public_asset.s3_help"
}
]
}
]
},
{
"id": "card_cache_settings",
"type": "basic",
@@ -0,0 +1,166 @@
/**
* E2E: 공개 자산 스토리지 설정 — 코어 드라이버 탭 카드 (공개#100)
*
* 시나리오 매니페스트: `tests/scenarios/public-asset-cdn.yaml`
* (케이스 마킹은 각 test 의 라인 주석에 있다)
*
* @effects settings_catalog_includes_plugin_registered_disks,
* invalid_disk_rejected_with_422
*
* 드라이버 탭의 "공개 자산 스토리지" 카드가 카탈로그(none/public/s3)를 그리고,
* 선택-저장-재진입 왕복에서 값이 유지되는지 검증한다. 함께 있는 CKEditor5 축은
* 코어 PluginSettingsController 의 카탈로그 부착을 제한 권한에서 검증하는 것이라
* 코어/템플릿 표면에 둔다 — 이커머스 모듈이 소유한 오버라이드 필드는 모듈 spec
* (`modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/`) 이 담당한다.
*
* composite Select 는 네이티브 select 가 아니라 커스텀 드롭다운(root div 에 name 속성,
* Button 토글, portal listbox role=option)이다 — 상호작용은 그 구조를 따른다.
* 저장 후에는 반드시 원값으로 되돌려 사이트 설정을 바꾸지 않는다.
*/
import { test, expect, authenticatePage, issueToken } from '../fixtures/admin-template-auth';
import type { Page } from '@playwright/test';
/** 코어 설정 읽기/쓰기 권한 토큰 (테스트 파일 단위 1회 발급) */
let settingsToken: string;
test.beforeAll(() => {
settingsToken = issueToken('core.settings.read', 'core.settings.update');
});
const DRIVERS_TAB = '/admin/settings?tab=drivers';
const CKEDITOR5_SETTINGS = '/admin/plugins/sirsoft-ckeditor5/settings';
const CORE_SELECT_ROOT = '[name="drivers.public_asset_disk"]';
const PLUGIN_SELECT_ROOT = '[name="public_asset_disk"]';
/**
* 드라이버 탭 진입 — 폼 데이터 적재를 엔진 상태로 확인한다.
*
* DOM attach 만 기다리면 바인딩 전 빈 Select 를 카탈로그 누락으로 오인한다.
*/
async function gotoDriversTab(page: Page): Promise<void> {
await page.goto(DRIVERS_TAB);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect
.poll(
async () =>
await page.evaluate(() => {
const local = (window as any).G7Core?.state?.getLocal?.();
return Array.isArray(local?.form?.available_drivers?.public_asset);
}),
{ timeout: 20_000 },
)
.toBe(true);
}
/**
* 커스텀 드롭다운을 열고 옵션 라벨 목록을 읽는다 (읽은 뒤 드롭다운은 닫지 않음).
*
* @param page Playwright 페이지
* @param rootSelector Select 루트(name 속성) 선택자
* @returns 옵션 라벨 배열
*/
async function openAndReadOptions(page: Page, rootSelector: string): Promise<string[]> {
await page.locator(`${rootSelector} button`).first().click();
const listbox = page.locator('[role="listbox"]');
await expect(listbox).toBeVisible({ timeout: 10_000 });
return listbox.locator('[role="option"]').allInnerTexts();
}
/**
* 엔진 로컬 상태에서 폼 값을 읽는다.
*
* @param page Playwright 페이지
* @param path form 하위 도트 경로 (예: 'drivers.public_asset_disk')
* @returns 현재 폼 값
*/
async function readFormValue(page: Page, path: string): Promise<unknown> {
return page.evaluate((p) => {
const form = (window as any).G7Core?.state?.getLocal?.()?.form;
return p.split('.').reduce((acc: any, key: string) => acc?.[key], form);
}, path);
}
test.describe('공개 자산 스토리지 설정', () => {
// @scenario consumer=product, disk_setting=none, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=legacy_local_row
// @effects settings_catalog_includes_plugin_registered_disks
test('드라이버 탭 카드가 코어 카탈로그(none/public/s3)를 그린다', async ({ page }) => {
await authenticatePage(page, settingsToken);
await gotoDriversTab(page);
await expect(page.locator(CORE_SELECT_ROOT)).toBeVisible();
const labels = await openAndReadOptions(page, CORE_SELECT_ROOT);
expect(labels.join('|')).toContain('사용 안 함');
expect(labels.join('|')).toContain('Public');
expect(labels.join('|')).toContain('Amazon S3');
await page.keyboard.press('Escape');
});
// @scenario consumer=product, disk_setting=public, e2e=save_roundtrip, hook=unregistered, override=module_override, row_state=new_remote_row
// @effects invalid_disk_rejected_with_422
test('선택-저장-재진입 왕복에서 값이 유지되고 원복된다', async ({ page }) => {
test.setTimeout(120_000);
await authenticatePage(page, settingsToken);
await gotoDriversTab(page);
const original = (await readFormValue(page, 'drivers.public_asset_disk')) as string;
// Public 디스크 선택 → 저장 → 성공 토스트
await openAndReadOptions(page, CORE_SELECT_ROOT);
await page.locator('[role="listbox"] [role="option"]', { hasText: 'Public' }).first().click();
await expect.poll(() => readFormValue(page, 'drivers.public_asset_disk')).toBe('public');
await page.locator('#save_button button, button#save_button').first().click();
await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 });
// 재진입 — 서버 저장값이 폼으로 복원되는지 확인 (refetchOnMount)
await gotoDriversTab(page);
await expect.poll(() => readFormValue(page, 'drivers.public_asset_disk'), { timeout: 20_000 }).toBe('public');
// 원복 (사이트 설정 보존) — 원값 라벨('none' → '사용 안 함')로 되돌린다
const restoreLabel = original === 'public' ? 'Public' : original === 's3' ? 'Amazon S3' : '사용 안 함';
await openAndReadOptions(page, CORE_SELECT_ROOT);
await page.locator('[role="listbox"] [role="option"]', { hasText: restoreLabel }).first().click();
await page.locator('#save_button button, button#save_button').first().click();
await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 });
await gotoDriversTab(page);
await expect
.poll(() => readFormValue(page, 'drivers.public_asset_disk'), { timeout: 20_000 })
.toBe(original || 'none');
});
// @scenario consumer=editor, disk_setting=none, e2e=ecommerce_settings_field, hook=unregistered, override=module_override, row_state=legacy_local_row
// @effects settings_catalog_includes_plugin_registered_disks
test('CKEditor5 설정 필드가 코어 설정 권한 없이도 카탈로그를 그린다', async ({ page }) => {
// core.settings.read 를 의도적으로 제외 — 카탈로그는 플러그인 설정 응답에 서버
// 부착되므로, 코어 환경설정 API 권한이 없는 커스텀 역할에서도 선택지가 채워져야
// 한다 (화면 권한과 카탈로그 권한의 표면 단일화 검증)
const pluginOnlyToken = issueToken('core.plugins.read', 'core.plugins.update');
await authenticatePage(page, pluginOnlyToken);
await page.goto(CKEDITOR5_SETTINGS);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect
.poll(
async () =>
await page.evaluate(() => {
const local = (window as any).G7Core?.state?.getLocal?.();
return Array.isArray(local?.form?.available_public_asset_disks);
}),
{ timeout: 20_000 },
)
.toBe(true);
await expect(page.locator(PLUGIN_SELECT_ROOT)).toBeVisible({ timeout: 20_000 });
const labels = await openAndReadOptions(page, PLUGIN_SELECT_ROOT);
expect(labels[0]).toContain('코어 설정 따름');
expect(labels.join('|')).toContain('사용 안 함');
expect(labels.join('|')).toContain('Amazon S3');
await page.keyboard.press('Escape');
});
});