Files
Gnuboard7/tests/Playwright/specs/admin/settings-static-cache-rebuild.spec.ts
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00

161 lines
7.9 KiB
TypeScript

/**
* E2E: 환경설정 > 일반 — 초기 화면 정적 파일 상태 카드 + 지금 다시 만들기 (#651 D4·D7)
*
* 시나리오 매니페스트: tests/scenarios/static-cache-admin-rebuild.yaml — 마킹은 각 테스트의
* scenario(k=v 조합)·effects 주석이 담당한다.
*
* 배경: 확장 캐시 버전이 만료로 재생성되지 않으므로(영구 번호) 재게시 누락은 무기한 stale 이 된다.
* 관리자 화면의 [지금 다시 만들기] 가 그 안전망이다 — 상태를 보고, 확인 모달을 거쳐, POST 하고,
* 토스트와 카드 재조회로 결과를 확인한다.
*
* 환경 가드: 대상 사이트가 운영 모드(production)가 아니면 카드는 「개발 모드」 배지와 비활성 버튼을
* 보인다 — 그 경우 재게시 케이스는 skip 하고 비활성 계약만 잠근다.
*/
import { test, expect, issueToken, issueScopedToken, authenticatePage } from '../../fixtures/auth';
/** 관리자 환경설정 일반 탭 진입 + 상태 카드 도착 대기 */
async function gotoGeneralTab(page: import('@playwright/test').Page): Promise<void> {
// 배지 문구는 데이터소스 도착 전에는 비어 있다 — 상태 GET 응답을 기다려야 publishable 판정이 실제 값이 된다
const statusArrived = page.waitForResponse(
(r) => r.request().method() === 'GET' && /\/api\/admin\/settings\/static-cache$/.test(new URL(r.url()).pathname),
{ timeout: 30_000 }
);
await page.goto('/admin/settings');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator('#card_static_cache')).toBeAttached({ timeout: 20_000 });
await statusArrived;
await expect(page.locator('#static_cache_status_badge')).not.toHaveText('', { timeout: 20_000 });
}
/** 카드가 「게시됨/미게시」(publishable) 상태인지 — 운영 모드 + kill-switch 켜짐 */
async function isPublishable(page: import('@playwright/test').Page): Promise<boolean> {
const text = (await page.locator('#static_cache_status_badge').textContent()) ?? '';
return /게시됨|미게시|Published|Not published/.test(text);
}
// @scenario permission=granted, publishable=production_enabled, outcome=success
// @effects card_renders_status_rows
test('@smoke #651 - 일반 탭에 초기 화면 정적 파일 카드가 상태 행과 함께 렌더된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
const statusResponse = page.waitForResponse(
(r) => r.request().method() === 'GET' && /\/api\/admin\/settings\/static-cache$/.test(new URL(r.url()).pathname),
{ timeout: 20_000 }
);
await gotoGeneralTab(page);
const response = await statusResponse;
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.data).toHaveProperty('publishable');
expect(body.data).toHaveProperty('version');
// 카드는 에셋 서빙 카드 다음에 있고, 행 텍스트에 다국어 키가 노출되지 않는다
await expect(page.locator('#card_asset_serving')).toBeAttached();
const cardText = (await page.locator('#card_static_cache').textContent()) ?? '';
expect(cardText).not.toContain('$t:');
expect(cardText).not.toContain('admin.settings');
await expect(page.locator('#static_cache_version_row')).toBeAttached();
await expect(page.locator('#static_cache_files_row')).toBeAttached();
await expect(page.locator('#static_cache_published_at_row')).toBeAttached();
await expect(page.locator('#static_cache_process_user_row')).toBeAttached();
});
// @scenario permission=granted, publishable=non_production, outcome=success
// @effects republish_button_disabled_when_not_publishable
test('#651 - 게시가 쓰이지 않는 환경이면 버튼이 비활성이고 사유 힌트가 있다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoGeneralTab(page);
const button = page.locator('#static_cache_republish_button');
await expect(button).toBeAttached();
if (await isPublishable(page)) {
// 운영 모드 사이트 — 버튼은 활성이어야 하고 힌트는 없다 (비활성 분기는 단위 테스트가 덮는다)
await expect(button).toBeEnabled();
await expect(page.locator('#static_cache_not_publishable_hint')).toHaveCount(0);
return;
}
await expect(button).toBeDisabled();
await expect(button).toHaveAttribute('aria-disabled', 'true');
await expect(page.locator('#static_cache_not_publishable_hint')).toBeAttached();
});
// @scenario permission=granted, publishable=production_enabled, outcome=success
// @effects confirm_modal_then_toast_and_refetch
test('#651 - 지금 다시 만들기 → 확인 모달 → POST 200 → 토스트 + 카드 재조회', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoGeneralTab(page);
test.skip(!(await isPublishable(page)), '대상 사이트가 운영 모드가 아니어서 재게시를 수행할 수 없다 (비활성 계약은 별도 케이스가 잠근다)');
const versionBefore = ((await page.locator('#static_cache_version_row').textContent()) ?? '').replace(/\D/g, '');
await page.locator('#static_cache_republish_button').click();
await expect(page.locator('#static_cache_republish_confirm_button')).toBeVisible({ timeout: 10_000 });
const republish = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/api\/admin\/settings\/static-cache\/republish$/.test(new URL(r.url()).pathname),
{ timeout: 300_000 }
);
const refetch = page.waitForResponse(
(r) => r.request().method() === 'GET' && /\/api\/admin\/settings\/static-cache$/.test(new URL(r.url()).pathname),
{ timeout: 300_000 }
);
await page.locator('#static_cache_republish_confirm_button').click();
const response = await republish;
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.data.republished).toBe(true);
expect(Number(body.data.version)).toBeGreaterThan(Number(versionBefore));
// 상태 재조회가 뒤따르고 모달이 닫힌다
expect((await refetch).status()).toBe(200);
await expect(page.locator('#static_cache_republish_confirm_button')).toBeHidden({ timeout: 10_000 });
// 카드에 새 버전이 반영된다
await expect
.poll(async () => ((await page.locator('#static_cache_version_row').textContent()) ?? '').replace(/\D/g, ''), { timeout: 20_000 })
.toBe(String(body.data.version));
// 토스트 문구가 키 그대로 새어 나가지 않는다
const toast = page.locator('[role="alert"], [role="status"], .toast').filter({ hasText: /다시 만들|rebuilt/ }).first();
await expect(toast).toBeVisible({ timeout: 10_000 });
});
// @scenario permission=denied, publishable=production_enabled, outcome=success
// @effects republish_denied_without_settings_update_permission
test('#651 - 읽기 권한만 있으면 상태는 보이지만 재게시 POST 는 403 이다', async ({ page }) => {
const token = issueScopedToken('core.settings.read');
await authenticatePage(page, token);
await gotoGeneralTab(page);
// 읽기 전용 계정에는 버튼이 비활성으로 렌더된다 (isReadOnly)
await expect(page.locator('#static_cache_republish_button')).toBeDisabled();
// 서버 경계 — 화면 비활성과 무관하게 API 자체가 거부한다
const status = await page.evaluate(async (t) => {
const r = await fetch('/api/admin/settings/static-cache/republish', {
method: 'POST',
headers: { Authorization: `Bearer ${t}`, Accept: 'application/json' },
});
return { status: r.status, body: await r.json().catch(() => null) };
}, token);
expect(status.status).toBe(403);
expect(status.body?.success).toBe(false);
expect(JSON.stringify(status.body)).not.toContain('settings.');
});