Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
304
apps/web/e2e/admin.spec.ts
Normal file
304
apps/web/e2e/admin.spec.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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("does not horizontally overflow at a mobile viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAsAdmin(page);
|
||||
|
||||
await openAdminAndReadHealth(page);
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue