/* ===================================================================== uc-auth-onboarding.spec.ts — 로그인·온보딩·승인대기·라우트 가드 유스케이스. 목적: 실제 사용자가 겪는 인증 여정(로그인 화면 확인 → 딥링크 가드 → 온보딩 입력 검증 → 승인 대기 → 역할별 홈 진입 → 세션 복원)을 route fixture만으로 검증한다. 실 API·dev-login 헬퍼를 쓰지 않으며, 실제 AI 엔진 턴 생성은 없다. 근거: - src/pages/Login.tsx + login/LoginBrand.tsx + login/LoginPanel.tsx (허용 도메인 배지, 비치료 고지 문구, OAuth 준비 상태) - src/pages/Onboarding.tsx (canSubmit 필수 입력·동의 검증, isValidPhone, 약관 details, source_note) - src/pages/PendingApproval.tsx (승인 상태 새로고침, 아직 승인 전 안내) - src/App.tsx (RequireAuth / OnboardingGate / PendingApprovalGate / RootRedirect / 미정의 경로 → /) - src/lib/auth.tsx (userFromMe 필드, initialPathForUser, roleHomePath) 중복 회피: auth.spec.ts(실 API 도메인 정책·실 온보딩 완주), full-sweep-auth.spec.ts(아바타 업로드·문서 폴백·pending 새로고침 승인 경로), full-sweep-shell.spec.ts(로그아웃·미정의 경로의 role-aware 루트)와 겹치지 않도록 이 파일은 입력 검증·미승인 유지 경로·역할별 루트·관리자 온보딩 우회 분기 등 다른 각도만 다룬다. ===================================================================== */ import { test, expect, type Page } from "@playwright/test"; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body) }; } /** catch-all: fixture로 지정하지 않은 API는 404. 반드시 가장 먼저 등록. */ async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); } /** OAuth·dev 로그인 구성 fixture (Login.tsx가 GET /auth/config로 조회). */ 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: false, }; async function routeAuthConfig(page: Page, body: unknown = READY_AUTH_CONFIG) { await page.route("**/api/auth/config", (route) => route.fulfill(jsonRoute(body))); } /** userFromMe(auth.tsx)가 읽는 필드를 빠짐없이 채운 /auth/me 응답. */ function makeMe(overrides: Record = {}) { return { user_id: "e2e-uc-auth-1", email: "learner@hs.ac.kr", role: "learner", display_name: "학습자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "학습자", self_introduction: "", avatar_url: "", ...overrides, }; } /** GET /auth/me를 동적 본문으로 고정한다(나중 등록이 catch-all보다 우선). */ async function routeMe(page: Page, body: () => Record | null) { await page.route("**/api/auth/me", (route) => { const current = body(); if (current === null) { return route.fulfill(jsonRoute({ detail: "not_authenticated" }, 401)); } return route.fulfill(jsonRoute(current)); }); } async function routeUnauthenticated(page: Page) { await routeMe(page, () => null); } /** 온보딩 화면이 GET /users/me로 읽는 미완료 학습자 프로필 fixture. */ const FRESH_PROFILE = { user_id: "e2e-uc-auth-1", email: "learner@hs.ac.kr", role: "learner", display_name: "", legal_name: "", affiliation: "", department: "", grade_level: "", phone: "", contact_address: "", nickname: "", self_introduction: "", avatar_url: "", cohort_ids: [], }; /** 온보딩 화면이 GET /users/legal-docs로 읽는 약관 fixture. */ const LEGAL_DOCS = { terms: { kind: "terms", status: "draft", title: "서비스 이용약관", version: "v0.9-draft", body: "제1조(목적) 이 약관은 Vignette 교육용 시뮬레이션 서비스의 이용 조건을 규정합니다.", }, privacy: { kind: "privacy", status: "draft", title: "개인정보 처리방침", version: "v0.9-draft", body: "제1조(수집 항목) 이름, 소속, 연락처를 가입 확인 목적으로 수집합니다.", }, source_note: "운영 전 초안 문서입니다.", }; async function routeOnboardingData(page: Page) { await page.route("**/api/users/me", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill(jsonRoute(FRESH_PROFILE)); }); await page.route("**/api/users/legal-docs", (route) => route.fulfill(jsonRoute(LEGAL_DOCS)), ); } /** 온보딩 미완료 learner로 인증된 상태 + 온보딩 데이터 fixture. */ async function setupFreshLearnerOnOnboarding(page: Page) { await routeUnmockedApi(page); await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" })); await routeOnboardingData(page); } /** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */ async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") { await page.getByLabel("닉네임").fill("비넷 유스케이스"); await page.getByLabel("자기소개").fill("상담 시뮬레이션에서 경청 반응을 연습합니다."); await page.getByLabel("이름").fill("유스케이스 학습자"); await page.getByLabel("소속").fill("한신대학교"); await page.getByLabel("학과/부서").fill("상담심리학과"); await page.getByLabel("학년/직위").fill("2학년"); await page.getByLabel("연락처").fill(phone); await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교"); } test.describe("uc auth-onboarding", () => { // usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·허용 도메인·비치료 고지를 읽는다 test("로그인 화면에서 서비스 성격과 허용 도메인, 비치료 고지를 확인한다", async ({ page }) => { await routeUnmockedApi(page); await routeUnauthenticated(page); await routeAuthConfig(page); await page.goto("/login"); await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible(); await expect(page.locator(".lg-lead")).toContainText( "학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.", ); // 허용 도메인 안내: 브랜드 패널 배지 + 버튼 서브라벨. await expect(page.locator(".lg-policy")).toContainText("허용 도메인"); await expect(page.locator(".lg-policy b").first()).toHaveText("hs.ac.kr"); await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr"); // 비치료 고지: 교육용 도구임을 명시한다. await expect(page.locator(".lg-note")).toContainText( "교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다.", ); }); // usecase: 로그인하지 않은 사용자가 보호 경로 딥링크(북마크)로 들어가면 로그인 화면으로 보낸다 test("비인증 사용자가 보호 경로 딥링크로 들어가면 /login으로 이동한다", async ({ page }) => { await routeUnmockedApi(page); await routeUnauthenticated(page); await routeAuthConfig(page); for (const deepLink of ["/learn/practice", "/teach/supervision", "/settings"]) { await page.goto(deepLink); await page.waitForURL(/\/login(?:$|[/?#])/); await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible(); } }); // usecase: 이미 로그인한 학습자가 /login 북마크를 다시 열면 자신의 홈으로 되돌린다 test("로그인된 학습자가 /login을 다시 열면 학습자 홈으로 튕긴다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe()); await page.goto("/login"); await page.waitForURL(/\/learn(?:$|[/?#])/); await expect(page).not.toHaveURL(/\/login/); }); // usecase: 신규 사용자가 온보딩에서 아무것도 입력하지 않거나 닉네임만 넣고 제출하려 한다 test("온보딩 필수 입력이 비어 있으면 제출 버튼이 비활성 상태로 막힌다", async ({ page }) => { await setupFreshLearnerOnOnboarding(page); await page.goto("/onboarding"); await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible(); const submit = page.getByRole("button", { name: /가입 설정 완료/ }); await expect(submit).toBeDisabled(); // 닉네임만 채워도 나머지 필수 입력이 비어 있으면 여전히 제출할 수 없다. await page.getByLabel("닉네임").fill("절반만 입력"); await expect(submit).toBeDisabled(); }); // usecase: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다 test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => { await setupFreshLearnerOnOnboarding(page); await page.goto("/onboarding"); await fillOnboardingTextFields(page); const submit = page.getByRole("button", { name: /가입 설정 완료/ }); await expect(submit).toBeDisabled(); // 약관 하나만 동의해도 아직 부족하다. await page.getByLabel("서비스 이용약관에 동의합니다.").check(); await expect(submit).toBeDisabled(); // 개인정보 동의까지 체크하면 비로소 제출할 수 있다. await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check(); await expect(submit).toBeEnabled(); }); // usecase: 신규 사용자가 동의 전에 약관·개인정보 문서를 펼쳐 실제 내용을 읽어 본다 test("온보딩 약관·개인정보 문서를 펼쳐 제목·버전·본문을 읽는다", async ({ page }) => { await setupFreshLearnerOnOnboarding(page); await page.goto("/onboarding"); const details = page.locator(".ob-legal details"); await expect(details).toHaveCount(2); await expect(details.nth(0).locator("summary")).toContainText("서비스 이용약관"); await expect(details.nth(0).locator("summary b")).toHaveText("v0.9-draft"); await details.nth(0).locator("summary").click(); await expect(details.nth(0).locator(".ob-doc-text")).toContainText("제1조(목적)"); await expect(details.nth(1).locator("summary")).toContainText("개인정보 처리방침"); await details.nth(1).locator("summary").click(); await expect(details.nth(1).locator(".ob-doc-text")).toContainText("제1조(수집 항목)"); // 문서 출처 주석(source_note)도 함께 표시된다. await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다."); }); // usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다 test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => { await setupFreshLearnerOnOnboarding(page); let onboardingPosted = false; await page.route("**/api/users/me/onboarding", (route) => { onboardingPosted = true; return route.fulfill(jsonRoute(FRESH_PROFILE)); }); await page.goto("/onboarding"); await fillOnboardingTextFields(page, "12"); // 숫자 9자리 미만 → isValidPhone 실패 await page.getByLabel("서비스 이용약관에 동의합니다.").check(); await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check(); await page.getByRole("button", { name: /가입 설정 완료/ }).click(); const alert = page.locator(".ob-form > .ob-error[role='alert']"); await expect(alert).toBeVisible(); await expect(alert).toContainText("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요"); await expect(page).toHaveURL(/\/onboarding(?:$|[/?#])/); expect(onboardingPosted).toBe(false); }); // usecase: 승인 대기 중인 학습자가 새로고침 버튼을 눌러 보지만 아직 승인 전이다 test("승인 대기 화면에서 새로고침해도 아직 승인 전이면 시각과 함께 안내한다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe({ account_status: "pending", approval_required: true })); await page.goto("/"); await page.waitForURL(/\/pending(?:$|[/?#])/); await expect(page.locator(".pa-kicker")).toHaveText("승인 대기"); await page.getByRole("button", { name: "승인 상태 새로고침" }).click(); await expect(page.locator(".pa-status")).toContainText( "기준 아직 승인 전입니다. 잠시 후 다시 확인해 주세요.", ); await expect(page).toHaveURL(/\/pending(?:$|[/?#])/); }); // usecase: 승인 대기 계정이 온보딩·학습 딥링크를 직접 열어도 승인 대기 화면으로 강제 이동한다 test("pending 계정은 온보딩·학습 딥링크에서도 /pending으로 강제 이동한다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe({ account_status: "pending", approval_required: true, onboarding_completed_at: null }), ); for (const deepLink of ["/onboarding", "/learn", "/settings"]) { await page.goto(deepLink); await page.waitForURL(/\/pending(?:$|[/?#])/); await expect( page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }), ).toBeVisible(); } }); // usecase: 승인·온보딩을 마친 학습자가 루트 주소로 들어와 자신의 홈으로 안내받는다 test("학습자가 루트(/)로 들어오면 /learn으로 리디렉트된다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe()); await page.goto("/"); await page.waitForURL(/\/learn(?:$|[/?#])/); await expect(page.locator("body")).toHaveAttribute("data-role", "learner"); }); // usecase: 교수자가 루트 주소로 들어와 교수 콘솔로 안내받는다 test("교수자가 루트(/)로 들어오면 /teach로 리디렉트된다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe({ user_id: "e2e-uc-auth-teacher", email: "teacher@hs.ac.kr", role: "teacher", display_name: "교수자", nickname: "교수자", }), ); await page.goto("/"); await page.waitForURL(/\/teach(?:$|[/?#])/); await expect(page.locator("body")).toHaveAttribute("data-role", "instructor"); }); // usecase: 관리자가 루트 주소로 들어와 운영 콘솔로 안내받는다 test("관리자가 루트(/)로 들어오면 /admin으로 리디렉트된다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe({ user_id: "e2e-uc-auth-admin", email: "admin@twentyoz.kr", role: "admin", display_name: "관리자", nickname: "관리자", admin_access: true, }), ); await page.goto("/"); await page.waitForURL(/\/admin(?:$|[/?#])/); await expect(page.locator("body")).toHaveAttribute("data-role", "admin"); }); // usecase: 학습자가 브라우저를 새로고침해도 저장된 세션(auth/me)으로 로그인 상태가 복원된다 test("새로고침 후에도 auth/me 기반으로 세션이 복원되어 /learn에 머문다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe()); await page.goto("/learn"); await expect(page).toHaveURL(/\/learn(?:$|[/?#])/); await expect(page.locator("body")).toHaveAttribute("data-role", "learner"); await page.reload(); await expect(page).toHaveURL(/\/learn(?:$|[/?#])/); await expect(page.locator("body")).toHaveAttribute("data-role", "learner"); await expect(page).not.toHaveURL(/\/login/); }); // usecase: 사용자가 잘못된 주소를 입력하면 상태에 맞는 화면(/login 또는 /pending)으로 정리된다 test("알 수 없는 경로는 비인증이면 /login, 승인 대기면 /pending으로 정리된다", async ({ page }) => { await routeUnmockedApi(page); await routeAuthConfig(page); // 비인증 사용자의 오타 주소 → 루트 정리 → /login. let me: Record | null = null; await routeMe(page, () => me); await page.goto("/no-such-route"); await page.waitForURL(/\/login(?:$|[/?#])/); await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible(); // 승인 대기 계정의 오타 주소 → /pending. me = makeMe({ account_status: "pending", approval_required: true }); await page.goto("/typo/deeper/route"); await page.waitForURL(/\/pending(?:$|[/?#])/); await expect(page.locator(".pa-kicker")).toHaveText("승인 대기"); }); // usecase: 온보딩을 아직 안 끝낸 관리자가 운영 콘솔 주소로 바로 들어간다(관리자 우회 분기) test("온보딩 미완료 관리자는 /admin 작업 공간에 온보딩 없이 바로 접근한다", async ({ page }) => { await routeUnmockedApi(page); await routeMe(page, () => makeMe({ user_id: "e2e-uc-auth-admin-fresh", email: "admin@twentyoz.kr", role: "admin", display_name: "관리자", nickname: "관리자", admin_access: true, onboarding_completed_at: null, }), ); await page.goto("/admin"); // OnboardingGate의 isAdminWorkspacePath 분기: /onboarding으로 밀려나지 않는다. await expect(page).toHaveURL(/\/admin(?:$|[/?#])/); await expect(page).not.toHaveURL(/\/onboarding/); await expect(page.locator("body")).toHaveAttribute("data-role", "admin"); }); });