vignette/apps/web/e2e/admin.spec.ts
Yun Chan 085460b5e0 대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
2026-06-27 02:30:46 +09:00

419 lines
15 KiB
TypeScript

import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import {
expectNoHorizontalOverflow,
signInAsLearner,
useRealApi,
withGlobalEngineConfigLock,
} from "./support";
type AdminHealthStatus = "ok" | "degraded" | "down";
interface AdminServiceHealth {
key: string;
name: string;
status: AdminHealthStatus;
detail: string;
metric: string;
load: number;
}
interface AdminHealthResponse {
status: AdminHealthStatus;
environment: string;
engine_mode: string;
services: AdminServiceHealth[];
}
interface AdminManagedUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
affiliation: string;
cohort_ids: string[];
}
interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: AdminManagedUser[];
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signInAsAdmin(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
await expectResponseOk(res);
}
function isAdminHealthResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/health");
}
function isAdminUsersResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
}
function isAdminUserPatch(userId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/users/${userId}`)
);
};
}
function isAdminUserDelete(userId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "DELETE" &&
url.pathname.endsWith(`/admin/users/${userId}`)
);
};
}
function environmentLabel(value: string) {
if (value === "prod") return "운영";
if (value === "staging") return "스테이징";
if (value === "dev") return "개발";
return value;
}
function engineModeLabel(value: string) {
if (value === "claude_cli") return "Claude CLI 게이트웨이";
if (value === "claude_api" || value === "messages_api") return "Anthropic API";
if (value === "openai") return "OpenAI 호환";
if (value === "solar") return "Solar";
return value;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
await page.goto("/admin");
const healthResponse = await healthResponsePromise;
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
expect(health.services.length).toBeGreaterThan(0);
return health;
}
async function openAdminAndReadUsers(page: Page) {
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
await page.goto("/admin");
const usersResponse = await usersResponsePromise;
await expectResponseOk(usersResponse);
return (await usersResponse.json()) as AdminUsersResponse;
}
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const form = page.locator(".ad-user-create");
await expect(form).toBeVisible();
const clippedControls = await form.evaluate((element) => {
const formRect = element.getBoundingClientRect();
const controls = Array.from(element.querySelectorAll<HTMLElement>("input, select, button"));
return controls
.map((control) => {
const rect = control.getBoundingClientRect();
const style = window.getComputedStyle(control);
const tag = control.tagName.toLowerCase();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const outsideForm =
rect.left < formRect.left - 1 ||
rect.right > formRect.right + 1 ||
rect.top < formRect.top - 1 ||
rect.bottom > formRect.bottom + 1;
const contentClipped =
tag === "button" &&
(control.scrollWidth > control.clientWidth + 1 ||
control.scrollHeight > control.clientHeight + 1);
return {
tag,
label: control.getAttribute("aria-label") ?? control.textContent?.replace(/\s+/g, " ").trim(),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
width: Math.ceil(rect.width),
outsideForm,
contentClipped,
visible,
};
})
.filter((control) => control.visible && (control.outsideForm || control.contentClipped));
});
expect(
clippedControls,
`Create-user controls clipped at ${viewportWidth}px: ${JSON.stringify(clippedControls)}`,
).toEqual([]);
}
async function expectVisibleButtonsFit(page: Page, selector: string, context: string) {
const clippedButtons = await page.locator(selector).evaluateAll((buttons) =>
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".ad-user,.ad-user-create") ?? button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const contentClipped =
button.scrollWidth > button.clientWidth + 1 ||
button.scrollHeight > button.clientHeight + 1;
const outsideOwner = ownerRect
? rect.left < ownerRect.left - 1 ||
rect.right > ownerRect.right + 1 ||
rect.top < ownerRect.top - 1 ||
rect.bottom > ownerRect.bottom + 1
: false;
return {
text: button.textContent?.replace(/\s+/g, " ").trim(),
width: Math.ceil(rect.width),
contentClipped,
outsideOwner,
visible,
};
})
.filter((button) => button.visible && (button.contentClipped || button.outsideOwner)),
);
expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]);
}
test.describe("admin route", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("allows an admin to open the live health dashboard", async ({ page }) => {
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const health = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
degraded: health.services.filter((service) => service.status === "degraded").length,
down: health.services.filter((service) => service.status === "down").length,
};
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment));
await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode));
await expect(page.locator(".ad-kpi b")).toHaveText([
String(health.services.length),
String(counts.ok),
String(counts.degraded),
String(counts.down),
]);
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {
const card = serviceCards.filter({ hasText: service.name });
await expect(card).toBeVisible();
await expect(card).toContainText(service.detail);
expect(service.load, `${service.key} load must be normalized`).toBeGreaterThanOrEqual(0);
expect(service.load, `${service.key} load must be normalized`).toBeLessThanOrEqual(1);
if (service.key === "engine" && service.status === "ok") {
await expect(card).toContainText(/\d+ms/);
} else if (service.key === "db" && service.status === "ok") {
await expect(card).toContainText(/풀 \d+\/\d+/);
} else if (service.key === "evaluation") {
await expect(card).toContainText(/대기 \d+건/);
} else if (service.key === "kb" && service.status === "ok") {
await expect(card).toContainText(/활성 세션 \d+건/);
} else {
await expect(card).toContainText(service.metric);
}
}
const byKey = Object.fromEntries(health.services.map((service) => [service.key, service]));
if (byKey.engine?.status === "ok") {
expect(byKey.engine.metric).toMatch(/^\d+ms$/);
}
if (byKey.db?.status === "ok") {
expect(byKey.db.metric).toMatch(/^풀 \d+\/\d+$/);
}
expect(byKey.evaluation?.metric).toMatch(/^대기 \d+건$/);
if (byKey.kb?.status === "ok") {
expect(byKey.kb.metric).toMatch(/^활성 세션 \d+건$/);
}
});
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const email = `admin-users.${slug}@hs.ac.kr`.toLowerCase();
const displayName = `관리 대상 ${testInfo.project.name}`;
const users = await openAdminAndReadUsers(page);
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
await page.getByLabel("사용자 검색").fill(`no-match-${slug}`);
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
await page.getByLabel("새 사용자 역할").selectOption("learner");
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
const createPromise = page.waitForResponse(isAdminUserCreate);
const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse);
await page.getByRole("button", { name: "사용자 등록" }).click();
const createResponse = await createPromise;
await expectResponseOk(createResponse);
await expectResponseOk(await reloadAfterCreatePromise);
const created = (await createResponse.json()) as AdminManagedUser;
expect(created).toMatchObject({
email,
display_name: displayName,
role: "learner",
cohort_ids: [`created-${testInfo.project.name}`],
});
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
const roleSelect = card.getByLabel(`${email} 역할`);
const cohortInput = card.getByLabel(`${email} 코호트`);
const saveButton = card.getByRole("button", { name: "저장" });
const nextCohort = `cohort-${testInfo.project.name}`;
await nameInput.fill(nextName);
await expect(nameInput).toHaveValue(nextName);
await page.evaluate(() => new Promise(requestAnimationFrame));
await roleSelect.selectOption("teacher");
await expect(roleSelect).toHaveValue("teacher");
await cohortInput.fill(nextCohort);
await expect(cohortInput).toHaveValue(nextCohort);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(saveButton).toBeEnabled();
const patchPromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await saveButton.click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminManagedUser;
expect(updated).toMatchObject({
user_id: created.user_id,
email,
display_name: nextName,
role: "teacher",
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(card).toContainText("교수자");
await expect(card).toContainText(nextCohort);
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
await card.getByRole("button", { name: "비활성화" }).click();
const deleteResponse = await deletePromise;
await expectResponseOk(deleteResponse);
await expect(card).toHaveCount(0);
const blockedLogin = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "teacher",
display_name: nextName,
},
});
expect(blockedLogin.status(), await blockedLogin.text()).toBe(403);
});
test("denies learner access to the admin API and UI", async ({ page }) => {
await signInAsLearner(page);
const denied = await page.request.get("/api/admin/health");
expect(denied.status(), await denied.text()).toBe(403);
const deniedUsers = await page.request.get("/api/admin/users");
expect(deniedUsers.status(), await deniedUsers.text()).toBe(403);
await page.goto("/admin");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".ad-root")).toHaveCount(0);
});
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
const users = await openAdminAndReadUsers(page);
const layout = await page.evaluate(() => {
const workspace = document.querySelector<HTMLElement>(".ad-user-workspace");
const form = document.querySelector<HTMLElement>(".ad-user-create");
if (!workspace || !form) throw new Error("admin user workspace was not rendered");
return {
workspaceColumns: window.getComputedStyle(workspace).gridTemplateColumns.split(" ").length,
formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length,
};
});
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, 390);
expect(layout.workspaceColumns).toBe(1);
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});
test("keeps the create-user form contained at tablet widths", async ({ page }) => {
await signInAsAdmin(page);
for (const width of [861, 900, 1024]) {
await page.setViewportSize({ width, height: 900 });
await openAdminAndReadUsers(page);
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, width);
}
});
});