전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영

- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
This commit is contained in:
Yun Chan 2026-07-27 14:25:24 +09:00
parent 0ae94499f2
commit 4511383cd9
55 changed files with 9333 additions and 573 deletions

View file

@ -0,0 +1,433 @@
/* =====================================================================
full-sweep-auth.spec.ts E2E (2026-07-27) auth .
docs/ops/e2e-full-sweep-2026-07-27.md 1 "검증: 신규 spec 필요"
:
- auth-login-generic-google-button
- auth-login-brand-domain-badges
- auth-onboarding-avatar-upload
- auth-onboarding-legal-doc-details
- auth-onboarding-error-notice
- auth-pending-status-panel
- auth-pending-refresh-button
- auth-pending-signout-button
- auth-guard-pending-approval-gate
원칙: API(dev-login··legal-docs· ) ,
(OAuth redirect , ,
5xx) page.route fixture로 . AI .
===================================================================== */
import { expect, test, type Page } from "@playwright/test";
import { completeOnboarding, useRealApi } from "./support";
const CONFIG_PATTERN = "**/api/auth/config";
const ME_PATTERN = "**/api/auth/me";
/** 로컬 redirect_uri라서 두 OAuth 버튼이 모두 활성화되는 구성 fixture. */
const READY_AUTH_CONFIG = {
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
{
provider: "saml",
configured: false,
enabled: false,
login_path: "/auth/login?provider=saml",
},
],
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: true,
};
let sweepEmailCounter = 0;
function uniqueEmail(prefix: string) {
sweepEmailCounter += 1;
return `${prefix}.${Date.now()}.${sweepEmailCounter}@hs.ac.kr`;
}
/** 온보딩 미완료 learner 계정을 만들고 세션 쿠키를 심는다. */
async function loginFreshLearner(page: Page, prefix: string): Promise<string> {
const email = uniqueEmail(prefix);
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role: "learner", display_name: "Full Sweep Auth" },
});
expect(res.ok(), await res.text()).toBeTruthy();
return email;
}
/** 온보딩까지 끝낸 learner 계정을 만든다(승인 대기 화면 fixture의 원본 계정). */
async function loginOnboardedLearner(page: Page, prefix: string): Promise<string> {
const email = await loginFreshLearner(page, prefix);
await completeOnboarding(page, {
legal_name: "전수 순회 학습자",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-0000-0000",
contact_address: "경기도 오산시 한신대학교",
});
return email;
}
/** 실 세션의 /auth/me 응답 원본을 가져온다(page.request는 route fixture를 우회한다). */
async function fetchRealMe(page: Page): Promise<Record<string, unknown>> {
const res = await page.request.get("/api/auth/me");
expect(res.ok(), await res.text()).toBeTruthy();
return (await res.json()) as Record<string, unknown>;
}
/** GET /auth/me만 지정한 본문으로 고정한다. 그 외 메서드/요청은 실 API로 보낸다. */
async function mockAuthMe(page: Page, body: () => Record<string, unknown>) {
await page.route(ME_PATTERN, async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body()),
});
});
}
async function fulfillConfig(page: Page, body: unknown) {
await page.route(CONFIG_PATTERN, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
});
}
test.describe("full sweep auth", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// 검증 checklist: auth-login-generic-google-button
test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({
page,
}) => {
await fulfillConfig(page, READY_AUTH_CONFIG);
await page.route("**/api/auth/login**", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: "<html><body>oauth-start-stub</body></html>",
});
});
await page.goto("/login");
const secondary = page.locator(".lg-obtn.secondary");
await expect(secondary).toBeEnabled();
await expect(secondary).toContainText("Google 계정으로 계속");
// 서브라벨은 두 번째 허용 도메인을 표시한다.
await expect(secondary.locator(".sub")).toHaveText("@twentyoz.kr");
// primary 버튼과 동일하게 첫 번째 도메인은 primary 서브라벨에 있다.
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
// 클릭 시 primary와 동일한 provider=google 시작 URL로 이동한다.
await Promise.all([
page.waitForURL(/\/api\/auth\/login\?provider=google&next=%2F/),
secondary.click(),
]);
});
// 검증 checklist: auth-login-brand-domain-badges
test("brand panel domain badges distinguish checking, unset, and loaded states", async ({
page,
}) => {
// 1) 설정 조회가 끝나지 않은 동안: "확인 중" 배지.
await page.route(CONFIG_PATTERN, () => {
/* 응답을 보류해 조회 중 상태를 고정한다 */
});
await page.goto("/login");
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
await expect(page.locator(".lg-policy b")).toHaveText("확인 중");
// 2) 설정 조회 실패: "설정 필요" 배지.
await page.unroute(CONFIG_PATTERN);
await page.route(CONFIG_PATTERN, async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "config unavailable" }),
});
});
await page.goto("/login");
await expect(page.locator(".lg-policy b")).toHaveText("설정 필요");
// 3) 정상 로드: allowed_email_domains를 배지로 나열.
await page.unroute(CONFIG_PATTERN);
await fulfillConfig(page, READY_AUTH_CONFIG);
await page.goto("/login");
const badges = page.locator(".lg-policy b");
await expect(badges).toHaveCount(2);
await expect(badges.nth(0)).toHaveText("hs.ac.kr");
await expect(badges.nth(1)).toHaveText("twentyoz.kr");
});
// 검증 checklist: auth-onboarding-avatar-upload
test("avatar upload stores avatar_url immediately and shows the 3MB notice on failure", async ({
page,
}) => {
await loginFreshLearner(page, "sweep-avatar");
await page.goto("/onboarding");
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
// 성공 경로(실 API): 파일 선택 즉시 업로드되어 avatar_url이 폼과 미리보기에 반영된다.
await page.getByLabel("아바타 이미지").setInputFiles({
name: "sweep-avatar.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
"base64",
),
});
await expect(page.getByText("다른 이미지 선택")).toBeVisible();
await expect(page.locator(".ob-avatar__preview img")).toHaveAttribute(
"src",
/\/uploads\/profile-avatars\//,
);
// 실패 경로(fixture): 서버가 거절하면 3MB·형식 안내를 role=alert로 띄운다.
await page.route("**/api/users/me/avatar", async (route) => {
await route.fulfill({
status: 413,
contentType: "application/json",
body: JSON.stringify({ detail: "file too large" }),
});
});
await page.getByLabel("아바타 이미지").setInputFiles({
name: "sweep-avatar-too-big.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
"base64",
),
});
const avatarAlert = page.locator(".ob-avatar .ob-error[role='alert']");
await expect(avatarAlert).toBeVisible();
await expect(avatarAlert).toContainText("아바타 업로드에 실패했습니다");
await expect(avatarAlert).toContainText("3MB");
});
// 검증 checklist: auth-onboarding-legal-doc-details
test("legal document details expand real docs and fall back when docs fail to load", async ({
page,
}) => {
await loginFreshLearner(page, "sweep-legal");
const docsRes = await page.request.get("/api/users/legal-docs");
expect(docsRes.ok(), await docsRes.text()).toBeTruthy();
const docs = (await docsRes.json()) as {
terms: { title: string; version: string; body: string };
privacy: { title: string; version: string; body: string };
};
const termsNeedle = docs.terms.body.trim().split(/\s+/).slice(0, 3).join(" ");
const privacyNeedle = docs.privacy.body.trim().split(/\s+/).slice(0, 3).join(" ");
await page.goto("/onboarding");
const details = page.locator(".ob-legal details");
await expect(details).toHaveCount(2);
// 제목·버전이 실 legal-docs 응답과 일치한다.
await expect(details.nth(0).locator("summary")).toContainText(docs.terms.title);
await expect(details.nth(0).locator("summary b")).toHaveText(docs.terms.version);
await expect(details.nth(1).locator("summary")).toContainText(docs.privacy.title);
await expect(details.nth(1).locator("summary b")).toHaveText(docs.privacy.version);
// 펼치면 본문이 보인다.
await details.nth(0).locator("summary").click();
await expect(details.nth(0).locator(".ob-doc-text")).toBeVisible();
await expect(details.nth(0).locator(".ob-doc-text")).toContainText(termsNeedle);
await details.nth(1).locator("summary").click();
await expect(details.nth(1).locator(".ob-doc-text")).toContainText(privacyNeedle);
// 문서 로드 실패 시 제목·버전·본문이 폴백 문구로 대체된다.
await page.route("**/api/users/legal-docs", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "legal docs unavailable" }),
});
});
await page.goto("/onboarding");
const fallbackDetails = page.locator(".ob-legal details");
await expect(fallbackDetails.nth(0).locator("summary")).toContainText("서비스 이용약관");
await expect(fallbackDetails.nth(0).locator("summary b")).toHaveText("확인 중");
await expect(fallbackDetails.nth(1).locator("summary")).toContainText("개인정보 처리방침");
await fallbackDetails.nth(0).locator("summary").click();
await expect(fallbackDetails.nth(0).locator(".ob-doc-text")).toContainText(
"문서를 불러오는 중입니다.",
);
});
// 검증 checklist: auth-onboarding-error-notice
test("onboarding shows role=alert notices for load and save failures", async ({ page }) => {
await loginFreshLearner(page, "sweep-ob-error");
// 로드 실패: GET /users/me 5xx → 폼 하단 role=alert.
await page.route("**/api/users/me", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "profile unavailable" }),
});
});
await page.goto("/onboarding");
const loadAlert = page.locator(".ob-form > .ob-error[role='alert']");
await expect(loadAlert).toBeVisible();
await expect(loadAlert).toContainText("온보딩 정보를 불러오지 못했습니다.");
// 저장 실패: 폼을 모두 채운 뒤 POST /users/me/onboarding 5xx → role=alert, 페이지 잔류.
await page.unroute("**/api/users/me");
await page.goto("/onboarding");
await page.getByLabel("닉네임").fill("전수 순회");
await page.getByLabel("자기소개").fill("온보딩 저장 오류 알림을 검증합니다.");
await page.getByLabel("이름").fill("전수 순회 학습자");
await page.getByLabel("소속").fill("한신대학교");
await page.getByLabel("학과/부서").fill("상담심리학과");
await page.getByLabel("학년/직위").fill("3학년");
await page.getByLabel("연락처").fill("010-1234-5678");
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
await page.route("**/api/users/me/onboarding", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "save failed" }),
});
});
await page.getByRole("button", { name: /가입 설정 완료/ }).click();
const saveAlert = page.locator(".ob-form > .ob-error[role='alert']");
await expect(saveAlert).toBeVisible();
await expect(saveAlert).toContainText("온보딩 저장에 실패했습니다.");
await expect(page).toHaveURL(/\/onboarding(?:$|[/?#])/);
});
// 검증 checklist: auth-pending-status-panel
test("pending panel shows waiting vs suspended kicker with the account email", async ({
page,
}) => {
const email = await loginOnboardedLearner(page, "sweep-pending-panel");
const realMe = await fetchRealMe(page);
let accountStatus = "pending";
await mockAuthMe(page, () => ({
...realMe,
account_status: accountStatus,
approval_required: true,
}));
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
await expect(page.locator(".pa-copy")).toContainText(email);
const status = page.locator(".pa-status[role='status']");
await expect(status).toBeVisible();
await expect(status).toContainText("접속 권한 확인 중");
await expect(status).toContainText("승인되면 새로고침 후 온보딩 또는 역할 홈으로 이동합니다.");
// suspended 계정은 '접속 보류' 키커로 구분 표시된다.
accountStatus = "suspended";
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("접속 보류");
});
// 검증 checklist: auth-pending-refresh-button
test("pending refresh button re-checks the session and routes an approved learner home", async ({
page,
}) => {
await loginOnboardedLearner(page, "sweep-pending-refresh");
const realMe = await fetchRealMe(page);
let pendingNow = true;
await mockAuthMe(page, () =>
pendingNow
? { ...realMe, account_status: "pending", approval_required: true }
: realMe,
);
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
const refreshButton = page.getByRole("button", { name: "승인 상태 새로고침" });
await expect(refreshButton).toBeEnabled();
// 승인 후 새로고침 → GET /auth/me 재조회 → 역할 홈(/learn)으로 이동.
pendingNow = false;
await refreshButton.click();
await page.waitForURL(/\/learn(?:$|[/?#])/);
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible();
});
// 검증 checklist: auth-pending-signout-button
test("pending sign-out button ends the session and returns to the login screen", async ({
page,
}) => {
await loginOnboardedLearner(page, "sweep-pending-signout");
const realMe = await fetchRealMe(page);
await mockAuthMe(page, () => ({
...realMe,
account_status: "pending",
approval_required: true,
}));
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await page.getByRole("button", { name: "다른 계정으로 로그인" }).click();
await page.waitForURL(/\/login(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
// 실 세션이 실제로 종료됐는지 확인한다(page.request는 fixture를 우회한다).
const me = await page.request.get("/api/auth/me");
expect(me.status(), await me.text()).toBe(401);
});
// 검증 checklist: auth-guard-pending-approval-gate
test("pending approval gate recalls unapproved users from every route and releases approved users", async ({
page,
}) => {
await loginOnboardedLearner(page, "sweep-pending-gate");
const realMe = await fetchRealMe(page);
let pendingNow = true;
await mockAuthMe(page, () =>
pendingNow
? { ...realMe, account_status: "pending", approval_required: true }
: realMe,
);
for (const blockedPath of ["/learn", "/learn/practice", "/settings", "/login"]) {
await page.goto(blockedPath);
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
}
// 승인된 사용자가 /pending에 오면 승인 홈으로 되돌린다.
pendingNow = false;
await page.goto("/pending");
await page.waitForURL(/\/learn(?:$|[/?#])/);
});
});