Files
Gnuboard7/tests/Playwright/specs/admin/settings-drivers-s3-endpoint.spec.ts
T
HeuJung ea58c606a2 fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원
공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이
동작하지 않던 결함의 전면 수정.

- S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함
 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단
 (부트 시 확장 부재 감지 → predis 자동 폴백)
- storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환
 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙)
- 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체
 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500
 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control)
- S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정
 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와
 동일 설정(endpoint/path-style)으로 정렬
- 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께
 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정)
- 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만
 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소
- env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을
 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리)
- 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및
 websocket 유령 설정 키 제거
- 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
2026-08-13 15:15:19 +09:00

193 lines
8.7 KiB
TypeScript

/**
* E2E: 환경설정 > 드라이버 — S3 리전 자유입력·엔드포인트·path-style (#99/#563)
*
* 시나리오 매니페스트: tests/scenarios/s3-storage-driver.yaml (마킹은 각 테스트의
* @scenario k=v 조합 + @effects 가 담당 — 헤더 요약 마킹은 파서 형식이 아님)
*
* 배경: S3 리전이 5개 하드코딩 Select 라 Cloudflare R2(auto)·신규 AWS 리전을 입력할 수
* 없었고, S3 호환 스토리지의 API 주소(endpoint)를 넣을 자리가 없어 s3_url(공개 URL)에
* 잘못 넣는 혼란이 있었다. 리전 Input 전환 + endpoint/path-style 신설을 브라우저에서
* 확인한다.
*
* 검증:
* 1. s3 선택 시 리전이 자유 입력 Input(Select 아님)·endpoint Input·path-style Toggle 이
* 번역문과 함께 마운트된다
* 2. 불량 형식 리전은 저장 버튼 클릭이 422 로 거부되고 리전 필드에 inline 에러가
* 표시된다 (s3 저장 게이트가 요구하는 연결 테스트만 route 스텁으로 성공 처리 —
* 외부 S3 실연결이 E2E 환경에 없기 때문이며, 저장 요청·에러 렌더는 전부 실경로)
* 3. local 드라이버 저장 왕복(외부 연결 불요 경로)이 신규 키를 보존한다
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
/** 관리자 환경설정 드라이버 탭 진입 + s3 선택 */
async function gotoDriversTabWithS3(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=drivers');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 템플릿 Select 는 options 모드에서 네이티브 select 가 아니라
// 커스텀 드롭다운(button[aria-haspopup=listbox] + role=option)을 렌더한다.
const storageSelect = page.locator('[name="drivers.storage_driver"]').first();
await expect(storageSelect).toBeAttached({ timeout: 20_000 });
await storageSelect.locator('button[aria-haspopup="listbox"]').first().click();
await page.getByRole('option', { name: 'Amazon S3' }).click();
await expect(page.locator('#s3_settings')).toBeAttached({ timeout: 10_000 });
}
// @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
// @effects region_free_input_mounted
test('@smoke #99 - s3 선택 시 리전이 자유 입력 Input 으로 마운트된다 (Select 아님)', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoDriversTabWithS3(page);
expect(page.url()).not.toMatch(/\/admin\/login/);
// 존재 확정: 리전 필드는 자유 입력 input 이다 (종전에는 커스텀 드롭다운이라 input 자체가 없었다)
const regionInput = page.locator('input[name="drivers.s3_region"]').first();
await expect(regionInput).toBeAttached();
// 부재 단언: 종전 리전 드롭다운(커스텀 Select 루트)이 제거되었다
await expect(page.locator('[name="drivers.s3_region"] button[aria-haspopup="listbox"]')).toHaveCount(0);
// 자유 입력이 실제로 동작한다 (R2 표준값)
await regionInput.fill('auto');
await expect(regionInput).toHaveValue('auto');
});
// @scenario region_shape=custom_minio,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
// @effects endpoint_field_mounted, path_style_toggle_mounted
test('#99 - endpoint Input 과 path-style Toggle 이 번역문과 함께 마운트된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoDriversTabWithS3(page);
const endpointField = page.locator('#s3_endpoint_field');
await expect(endpointField).toBeAttached();
await expect(endpointField.locator('input[name="drivers.s3_endpoint"]')).toBeAttached();
const toggleField = page.locator('#s3_use_path_style_field');
await expect(toggleField).toBeAttached();
await expect(toggleField.locator('input[type="checkbox"]').first()).toBeAttached();
// 다국어 키 미해석 회귀 가드 — s3 블록 전체에 "$t:" 원문이 노출되면 안 된다
const blockText = (await page.locator('#s3_settings').innerText()).trim();
expect(blockText).not.toContain('$t:');
expect(blockText.length).toBeGreaterThan(0);
});
// @scenario region_shape=invalid_format,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
// @effects invalid_region_rejected
test('#99 - 불량 형식 리전은 저장 클릭이 422 로 거부되고 리전 필드에 오류가 표시된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
// s3 저장 게이트는 연결 테스트 성공을 요구한다. 외부 S3 실연결이 E2E 환경에
// 없으므로 연결 테스트 응답만 성공으로 스텁한다 — 저장 요청 자체는 실서버로
// 나가 422 계약과 inline 에러 렌더를 실경로로 검증한다.
await page.route('**/api/admin/settings/test-driver', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: { results: { s3: { success: true, message: 'stubbed' } } },
}),
})
);
await gotoDriversTabWithS3(page);
// s3 필드 입력 — 불량 형식 리전 포함 (대문자·특수문자)
await page.locator('input[name="drivers.s3_bucket"]').fill('bucket');
await page.locator('input[name="drivers.s3_region"]').fill('S3!bad');
await page.locator('input[name="drivers.s3_access_key"]').fill('key');
await page.locator('input[name="drivers.s3_secret_key"]').fill('secret');
// 연결 테스트(스텁 성공) → 저장 게이트 해제 → 저장 버튼 활성 전이
await page.locator('#s3_test_section button').click();
const saveButton = page.locator('#save_button');
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
// 저장 클릭 → 실서버 422
const [saveResponse] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
r.url().includes('/api/admin/settings') &&
!r.url().includes('test-driver')
),
saveButton.click(),
]);
expect(saveResponse.status()).toBe(422);
// 서버가 돌려준 리전 오류 문구가 리전 필드 inline 에러로 렌더된다
const body = await saveResponse.json();
const regionMessage = body?.errors?.['drivers.s3_region']?.[0];
expect(regionMessage).toBeTruthy();
await expect(page.locator('input[name="drivers.s3_region"]')).toHaveClass(/input-error/);
await expect(page.locator('#s3_settings')).toContainText(regionMessage);
});
// @scenario region_shape=auto,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
// @effects local_roundtrip_preserves_new_keys
test('#99 - local 드라이버 저장 왕복이 신규 키(s3_endpoint/path-style)를 보존한다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await page.goto('/admin/settings?tab=drivers');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const result = await page.evaluate(async (bearer) => {
const headers = {
Authorization: `Bearer ${bearer}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
// 현재 값을 읽어 그대로 되돌려 놓는 왕복 — 검수 서버 상태를 바꾸지 않는다
const before = await (
await fetch('/api/admin/settings', { headers })
).json();
const drivers = before?.data?.drivers ?? {};
const save = await fetch('/api/admin/settings', {
method: 'POST',
headers,
body: JSON.stringify({
_tab: 'drivers',
drivers: {
...drivers,
storage_driver: 'local',
s3_region: 'auto',
s3_endpoint: 'http://127.0.0.1:9000',
s3_use_path_style: true,
},
}),
});
const after = await (
await fetch('/api/admin/settings', { headers })
).json();
// 원상 복구 (검수 서버 상태 보존)
await fetch('/api/admin/settings', {
method: 'POST',
headers,
body: JSON.stringify({ _tab: 'drivers', drivers }),
});
return {
saveStatus: save.status,
savedEndpoint: after?.data?.drivers?.s3_endpoint,
savedPathStyle: after?.data?.drivers?.s3_use_path_style,
savedRegion: after?.data?.drivers?.s3_region,
};
}, token);
expect(result.saveStatus).toBe(200);
expect(result.savedRegion).toBe('auto');
expect(result.savedEndpoint).toBe('http://127.0.0.1:9000');
expect(Boolean(result.savedPathStyle)).toBe(true);
});