477 lines
19 KiB
TypeScript
477 lines
19 KiB
TypeScript
/* =====================================================================
|
|
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<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),
|
|
});
|
|
});
|
|
}
|
|
|
|
async function mockUnauthenticatedMe(page: Page) {
|
|
await page.route(ME_PATTERN, async (route) => {
|
|
await route.fulfill({
|
|
status: 401,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "not_authenticated" }),
|
|
});
|
|
});
|
|
}
|
|
|
|
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 mockUnauthenticatedMe(page);
|
|
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<HTMLElement>(".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 mockUnauthenticatedMe(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");
|
|
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,
|
|
}) => {
|
|
await mockUnauthenticatedMe(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(?:$|[/?#])/);
|
|
});
|
|
});
|