vignette/apps/web/e2e/uc-auth-onboarding.spec.ts

510 lines
22 KiB
TypeScript

/* =====================================================================
uc-auth-onboarding.spec.ts — 로그인·온보딩·승인대기·라우트 가드 유스케이스.
목적: 실제 사용자가 겪는 인증 여정(로그인 화면 확인 → 딥링크 가드 →
온보딩 입력 검증 → 승인 대기 → 역할별 홈 진입 → 세션 복원)을
route fixture만으로 검증한다. 실 API·dev-login 헬퍼를 쓰지 않으며,
실제 AI 엔진 턴 생성은 없다.
근거:
- src/pages/Login.tsx + login/LoginBrand.tsx + login/LoginPanel.tsx
(제한 없는 Google 계정 배지, 비치료 고지 문구, 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: [],
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<string, unknown> = {}) {
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<string, unknown> | 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, legalDocs = LEGAL_DOCS) {
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(legalDocs)),
);
}
/** 온보딩 미완료 learner로 인증된 상태 + 온보딩 데이터 fixture. */
async function setupFreshLearnerOnOnboarding(page: Page) {
await routeUnmockedApi(page);
await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" }));
await routeOnboardingData(page);
}
const LONG_LEGAL_DOCS = {
...LEGAL_DOCS,
terms: {
...LEGAL_DOCS.terms,
title: "Vignette 서비스 이용약관 초안",
version: "terms-draft-2026-06-27",
},
privacy: {
...LEGAL_DOCS.privacy,
title: "Vignette 개인정보 처리방침 초안",
version: "privacy-draft-2026-06-27",
},
};
/** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */
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: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·Google 계정 정책·비치료 고지를 읽는다
test("로그인 화면에서 서비스 성격과 Google 계정 정책, 비치료 고지를 확인한다", 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 계정이면 도메인이나 사전등록 없이 바로 시작할 수 있습니다.",
);
// 제한 없는 Google 계정 안내: 브랜드 패널 배지 + 버튼 서브라벨.
await expect(page.locator(".lg-policy")).toContainText("Google 로그인");
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("모든 Google 계정");
// 비치료 고지: 교육용 도구임을 명시한다.
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: 다른 Google 계정을 잘못 선택한 사용자가 현재 이메일을 확인하고 로그인 화면으로 빠져나간다
test("온보딩 최상단에서 현재 Google 계정을 확인하고 다른 계정으로 로그인한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () =>
makeMe({
email: "yunchan8804@gmail.com",
onboarding_completed_at: null,
nickname: "",
}),
);
await routeOnboardingData(page);
await routeAuthConfig(page);
let logoutRequested = false;
await page.route("**/api/auth/logout", (route) => {
logoutRequested = route.request().method() === "POST";
return route.fulfill(jsonRoute({ ok: true }));
});
await page.goto("/onboarding");
const account = page.getByLabel("현재 로그인 계정");
await expect(account).toBeVisible();
await expect(account.getByText("현재 Google 계정", { exact: true })).toBeVisible();
await expect(account.getByText("yunchan8804@gmail.com", { exact: true })).toBeVisible();
await account.getByRole("button", { name: "다른 계정으로 로그인" }).click();
await page.waitForURL(/\/login(?:$|[/?#])/);
expect(logoutRequested).toBe(true);
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
// 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("운영 전 초안 문서입니다.");
});
test("좁은 화면에서도 약관 제목·버전을 모두 읽고 문서를 펼친다", async ({ page }) => {
for (const viewport of [
{ width: 320, height: 844 },
{ width: 390, height: 844 },
{ width: 1440, height: 900 },
]) {
await page.setViewportSize(viewport);
await routeUnmockedApi(page);
await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" }));
await routeOnboardingData(page, LONG_LEGAL_DOCS);
await page.goto("/onboarding");
const details = page.locator(".ob-legal details");
const summaries = details.locator("summary");
await expect(summaries).toHaveCount(2);
for (let index = 0; index < 2; index += 1) {
const summary = summaries.nth(index);
const title = summary.locator(".ob-legal__title");
const version = summary.locator(".ob-legal__version");
await expect(title).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.title : LONG_LEGAL_DOCS.privacy.title);
await expect(version).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.version : LONG_LEGAL_DOCS.privacy.version);
const geometry = await summary.evaluate((element) => {
const title = element.querySelector<HTMLElement>(".ob-legal__title");
const version = element.querySelector<HTMLElement>(".ob-legal__version");
if (!title || !version) throw new Error("약관 제목 또는 버전 요소가 없습니다.");
const summaryRect = element.getBoundingClientRect();
const titleRect = title.getBoundingClientRect();
const versionRect = version.getBoundingClientRect();
return {
summaryHeight: summaryRect.height,
titleScrollWidth: title.scrollWidth,
titleWidth: title.clientWidth,
versionTop: versionRect.top,
titleBottom: titleRect.bottom,
};
});
expect(geometry.summaryHeight).toBeGreaterThanOrEqual(44);
expect(geometry.titleScrollWidth).toBeLessThanOrEqual(geometry.titleWidth);
expect(geometry.versionTop).toBeGreaterThanOrEqual(geometry.titleBottom);
await summary.click();
await expect(details.nth(index)).toHaveAttribute("open", "");
}
}
});
// 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<string, unknown> | 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");
});
});