255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { useRealApi } from "./support";
|
|
|
|
const apiBase = process.env.E2E_API_BASE ?? "http://127.0.0.1:8000";
|
|
const publicBase = process.env.E2E_PUBLIC_BASE_URL ?? "https://vignette.chanpaca.net";
|
|
const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
|
|
|
|
function isLocalHostname(hostname: string): boolean {
|
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
}
|
|
|
|
test.describe("auth domain policy", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await useRealApi(page);
|
|
});
|
|
|
|
test("requires authentication for the persona catalog", async ({ page }) => {
|
|
const response = await page.request.get("/api/personas");
|
|
expect(response.status(), await response.text()).toBe(401);
|
|
});
|
|
|
|
test("allows only the configured school and operator email domains", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
const allowed = [
|
|
`domain-learner.${slug}@hs.ac.kr`,
|
|
`domain-admin.${slug}@twentyoz.kr`,
|
|
];
|
|
|
|
for (const email of allowed) {
|
|
const response = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email: email.toLowerCase(),
|
|
role: email.endsWith("@twentyoz.kr") ? "admin" : "learner",
|
|
display_name: "Domain Policy E2E",
|
|
},
|
|
});
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
}
|
|
|
|
const denied = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email: `domain-denied.${slug}@example.com`.toLowerCase(),
|
|
role: "learner",
|
|
display_name: "Denied Domain",
|
|
},
|
|
});
|
|
|
|
const deniedText = await denied.text();
|
|
expect(denied.status(), deniedText).toBe(403);
|
|
expect(deniedText).toContain("email domain is not allowed");
|
|
});
|
|
|
|
test("shows Google OAuth readiness without exposing a raw JSON error page", async ({
|
|
page,
|
|
}) => {
|
|
const configResponse = await page.request.get("/api/auth/config");
|
|
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
|
|
const config = (await configResponse.json()) as {
|
|
google_oauth_configured: boolean;
|
|
allowed_email_domains: string[];
|
|
redirect_uri: string;
|
|
dev_login_enabled: boolean;
|
|
};
|
|
|
|
await page.goto("/login");
|
|
|
|
const googleButtons = page.locator(".lg-obtn");
|
|
await expect(googleButtons).toHaveCount(2);
|
|
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
|
|
const redirectHost = new URL(config.redirect_uri).hostname;
|
|
const devOAuthUnavailable =
|
|
config.dev_login_enabled &&
|
|
!isLocalHostname(redirectHost);
|
|
if (config.dev_login_enabled) {
|
|
await expect(page.locator(".lg-dev")).toBeVisible();
|
|
} else {
|
|
await expect(page.locator(".lg-dev")).toHaveCount(0);
|
|
}
|
|
|
|
if (config.google_oauth_configured && !devOAuthUnavailable) {
|
|
await expect(googleButtons.first()).toBeEnabled();
|
|
await expect(page.locator(".lg-config")).toHaveCount(0);
|
|
} else {
|
|
await expect(googleButtons.first()).toBeDisabled();
|
|
await expect(googleButtons.nth(1)).toBeDisabled();
|
|
await expect(page.locator(".lg-config")).toBeVisible();
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
|
|
|
|
if (devOAuthUnavailable) {
|
|
await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인");
|
|
await page.goto("/api/auth/login?provider=google&next=%2Flearn");
|
|
await expect(page).toHaveURL(/\/login\?oauth=local_oauth_unavailable$/);
|
|
await expect(page.locator(".lg-error")).toContainText("로컬 개발 주소에서는 Google OAuth");
|
|
return;
|
|
}
|
|
|
|
await page.goto("/api/auth/login?provider=google&next=%2Flearn");
|
|
await expect(page).toHaveURL(/\/login\?oauth=not_configured$/);
|
|
await expect(page.locator(".lg-error")).toContainText("Google 로그인이 아직 연결되지 않았습니다");
|
|
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
|
|
}
|
|
});
|
|
|
|
test("shows the concrete OAuth failure reason on the login screen", async ({ page }) => {
|
|
await page.goto("/login?oauth=provider_error");
|
|
const error = page.locator(".lg-error");
|
|
|
|
await expect(error).toContainText("Google이 인증 코드를 발급하지 못했습니다");
|
|
await expect(error).toContainText("오류 코드: provider_error");
|
|
});
|
|
|
|
test("logs in locally, completes onboarding, and redirects to learner home", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.goto("/login");
|
|
await expect(page.locator(".lg-dev")).toBeVisible();
|
|
|
|
const email = `onboarding.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`;
|
|
const login = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email,
|
|
role: "learner",
|
|
display_name: "온보딩 미완료 학습자",
|
|
},
|
|
});
|
|
expect(login.ok(), await login.text()).toBeTruthy();
|
|
|
|
await page.goto("/");
|
|
await page.waitForURL(/\/onboarding(?:$|[/?#])/);
|
|
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
|
|
await expect(page.locator(".vg-topbar")).toHaveCount(0);
|
|
await expect(page.locator(".vg-nav")).toHaveCount(0);
|
|
await expect(page.locator(".ob-card")).toHaveCount(0);
|
|
await expect(page.locator(".ob-docs")).toHaveCount(0);
|
|
await expect(page.getByText("온보딩 정보를 확인하고 있습니다.")).toHaveCount(0);
|
|
await expect(page.locator(".ob-legal")).toContainText("서비스 이용약관");
|
|
await expect(page.locator(".ob-legal")).toContainText("개인정보 처리방침");
|
|
|
|
for (const blockedPath of [
|
|
"/learn",
|
|
"/settings",
|
|
"/admin",
|
|
"/dev/avatar-preview",
|
|
"/login",
|
|
]) {
|
|
await page.goto(blockedPath);
|
|
await page.waitForURL(/\/onboarding(?:$|[/?#])/);
|
|
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
|
|
await expect(page.locator(".vg-nav")).toHaveCount(0);
|
|
}
|
|
|
|
await page.getByLabel("닉네임").fill("비넷 학습자");
|
|
await page.getByLabel("자기소개").fill("상담 시뮬레이션에서 라포 형성을 집중 연습합니다.");
|
|
await page.getByLabel("아바타 이미지").setInputFiles({
|
|
name: "avatar.png",
|
|
mimeType: "image/png",
|
|
buffer: Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
),
|
|
});
|
|
await expect(page.getByText("다른 이미지 선택")).toBeVisible();
|
|
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 Promise.all([
|
|
page.waitForURL(/\/learn(?:$|[/?#])/),
|
|
page.getByRole("button", { name: /가입 설정 완료/ }).click(),
|
|
]);
|
|
|
|
const me = await page.request.get("/api/auth/me");
|
|
expect(me.status(), await me.text()).toBe(200);
|
|
const meBody = (await me.json()) as {
|
|
onboarding_completed_at?: number | null;
|
|
nickname?: string;
|
|
self_introduction?: string;
|
|
avatar_url?: string;
|
|
};
|
|
expect(meBody.onboarding_completed_at).toBeTruthy();
|
|
expect(meBody.nickname).toBe("비넷 학습자");
|
|
expect(meBody.self_introduction).toContain("라포 형성");
|
|
expect(meBody.avatar_url).toContain("/uploads/profile-avatars/");
|
|
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
|
|
});
|
|
|
|
test("keeps dev login closed for the public API origin", async ({ request }) => {
|
|
const publicHeaders = {
|
|
origin: "https://vignette.chanpaca.net",
|
|
"x-forwarded-host": "api-vignette.chanpaca.net",
|
|
};
|
|
|
|
const configResponse = await request.get(`${apiBase}/auth/config`, {
|
|
headers: publicHeaders,
|
|
});
|
|
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
|
|
const config = (await configResponse.json()) as { dev_login_enabled: boolean };
|
|
expect(config.dev_login_enabled).toBe(false);
|
|
|
|
const response = await request.post(`${apiBase}/auth/dev-login`, {
|
|
headers: publicHeaders,
|
|
data: {
|
|
email: "public-dev-login-probe@hs.ac.kr",
|
|
role: "learner",
|
|
display_name: "Public Probe",
|
|
},
|
|
});
|
|
|
|
expect(response.status(), await response.text()).toBe(404);
|
|
});
|
|
|
|
test("keeps the public login screen on real Google OAuth only", async ({ page, request }) => {
|
|
const configResponse = await request.get(`${publicApiBase}/auth/config`, {
|
|
headers: {
|
|
origin: publicBase,
|
|
"x-forwarded-host": "api-vignette.chanpaca.net",
|
|
},
|
|
});
|
|
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
|
|
const config = (await configResponse.json()) as {
|
|
google_oauth_configured: boolean;
|
|
allowed_email_domains: string[];
|
|
dev_login_enabled: boolean;
|
|
};
|
|
expect(config).toMatchObject({
|
|
google_oauth_configured: true,
|
|
dev_login_enabled: false,
|
|
});
|
|
expect(config.allowed_email_domains).toEqual(expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]));
|
|
|
|
await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
|
|
|
|
await expect(page.locator(".lg-obtn")).toHaveCount(2);
|
|
await expect(page.locator(".lg-dev")).toHaveCount(0);
|
|
await expect(page.getByText("로컬 테스트")).toHaveCount(0);
|
|
await expect(page.getByText("로컬 테스트 계정으로 계속")).toHaveCount(0);
|
|
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
|
|
await expect(page.locator(".lg-obtn").first()).toBeEnabled();
|
|
|
|
await Promise.all([
|
|
page.waitForURL(/accounts\.google\.com/, { timeout: 20_000 }),
|
|
page.locator(".lg-obtn").first().click(),
|
|
]);
|
|
expect(page.url()).toContain("client_id=");
|
|
expect(page.url()).toContain("redirect_uri=https%3A%2F%2Fapi-vignette.chanpaca.net%2Fauth%2Fcallback");
|
|
});
|
|
});
|