416 lines
18 KiB
TypeScript
416 lines
18 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) {
|
|
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: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·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: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다
|
|
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<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");
|
|
});
|
|
});
|