/* ===================================================================== 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, expectNoHorizontalOverflow, 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 { 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 { 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> { const res = await page.request.get("/api/auth/me"); expect(res.ok(), await res.text()).toBeTruthy(); return (await res.json()) as Record; } /** GET /auth/me만 지정한 본문으로 고정한다. 그 외 메서드/요청은 실 API로 보낸다. */ async function mockAuthMe(page: Page, body: () => Record) { 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-mobile-scrollport test("login root follows the narrow document scrollport without horizontal overflow", async ({ page, }) => { await page.route(ME_PATTERN, async (route) => { await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ detail: "not_authenticated" }), }); }); await fulfillConfig(page, READY_AUTH_CONFIG); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/login"); await expect(page.locator(".lg-root")).toBeVisible(); await expectNoHorizontalOverflow(page); const widths = await page.evaluate(() => { const documentRoot = document.documentElement; const loginRoot = document.querySelector(".lg-root"); if (!loginRoot) throw new Error("login root missing"); return { documentClientWidth: documentRoot.clientWidth, documentScrollWidth: documentRoot.scrollWidth, loginRight: loginRoot.getBoundingClientRect().right, loginWidth: loginRoot.getBoundingClientRect().width, }; }); expect(widths.documentScrollWidth).toBeLessThanOrEqual(widths.documentClientWidth + 1); expect(widths.loginRight).toBeLessThanOrEqual(widths.documentClientWidth + 1); expect(widths.loginWidth).toBeLessThanOrEqual(widths.documentClientWidth + 1); }); // 검증 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 page.route(ME_PATTERN, async (route) => { await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ detail: "not_authenticated" }), }); }); 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: "oauth-start-stub", }); }); await page.goto("/login"); await page.setViewportSize({ width: 320, height: 568 }); await page.addStyleTag({ content: "html { overflow-y: scroll; scrollbar-gutter: stable; }", }); await expectNoHorizontalOverflow(page); 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(?:$|[/?#])/); }); });