feat(ai-character): 관리자 인증 셸 구현
This commit is contained in:
31
src/features/auth/api/auth-api.ts
Normal file
31
src/features/auth/api/auth-api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
|
||||
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
|
||||
const loginResponseSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
role: z.literal("ADMIN"),
|
||||
});
|
||||
const logoutResponseSchema = z.object({});
|
||||
|
||||
export function login(apiClient: ApiClient, credentials: LoginCredentials): Promise<AuthSessionRecord> {
|
||||
return apiClient.request({
|
||||
path: "/admin/member/login",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
|
||||
responseSchema: loginResponseSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(apiClient: ApiClient): Promise<void> {
|
||||
await apiClient.request({
|
||||
path: "/member/logout",
|
||||
method: "POST",
|
||||
responseSchema: logoutResponseSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
}
|
||||
32
src/features/auth/model/auth-session-context.ts
Normal file
32
src/features/auth/model/auth-session-context.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
|
||||
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
|
||||
|
||||
class MissingAuthSessionProviderError extends Error {
|
||||
override readonly name = "MissingAuthSessionProviderError";
|
||||
|
||||
constructor() {
|
||||
super("AuthSessionProvider is required.");
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthSessionContextValue = {
|
||||
readonly session: AuthSessionRecord | null;
|
||||
readonly loginNotice: string | null;
|
||||
readonly login: (credentials: LoginCredentials) => Promise<void>;
|
||||
readonly logout: () => Promise<void>;
|
||||
readonly clearSession: (loginNotice?: string | null) => void;
|
||||
};
|
||||
|
||||
export const AuthSessionContext = createContext<AuthSessionContextValue | null>(null);
|
||||
|
||||
export function useAuthSession(): AuthSessionContextValue {
|
||||
const context = useContext(AuthSessionContext);
|
||||
|
||||
if (context === null) {
|
||||
throw new MissingAuthSessionProviderError();
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
35
src/features/auth/model/auth-session-storage.test.ts
Normal file
35
src/features/auth/model/auth-session-storage.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
authSessionStorage,
|
||||
type AuthSessionRecord,
|
||||
} from "./auth-session-storage";
|
||||
|
||||
describe("authSessionStorage", () => {
|
||||
const session: AuthSessionRecord = {
|
||||
token: "header.payload.signature",
|
||||
role: "ADMIN",
|
||||
};
|
||||
|
||||
test("reads a saved ADMIN session", () => {
|
||||
// Given
|
||||
authSessionStorage.save(session);
|
||||
|
||||
// When
|
||||
const restoredSession = authSessionStorage.read();
|
||||
|
||||
// Then
|
||||
expect(restoredSession).toEqual(session);
|
||||
});
|
||||
|
||||
test("removes a saved session", () => {
|
||||
// Given
|
||||
authSessionStorage.save(session);
|
||||
|
||||
// When
|
||||
authSessionStorage.remove();
|
||||
|
||||
// Then
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
});
|
||||
});
|
||||
38
src/features/auth/model/auth-session-storage.ts
Normal file
38
src/features/auth/model/auth-session-storage.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type AuthSessionRecord = {
|
||||
readonly token: string;
|
||||
readonly role: "ADMIN";
|
||||
};
|
||||
|
||||
const authSessionStorageKey = "ai-character-admin-auth-session";
|
||||
const authSessionRecordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
role: z.literal("ADMIN"),
|
||||
});
|
||||
|
||||
function parseAuthSession(value: string): AuthSessionRecord | null {
|
||||
try {
|
||||
return authSessionRecordSchema.parse(JSON.parse(value));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError || error instanceof z.ZodError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const authSessionStorage = {
|
||||
read(): AuthSessionRecord | null {
|
||||
const value = sessionStorage.getItem(authSessionStorageKey);
|
||||
|
||||
return value === null ? null : parseAuthSession(value);
|
||||
},
|
||||
save(session: AuthSessionRecord): void {
|
||||
sessionStorage.setItem(authSessionStorageKey, JSON.stringify(session));
|
||||
},
|
||||
remove(): void {
|
||||
sessionStorage.removeItem(authSessionStorageKey);
|
||||
},
|
||||
};
|
||||
77
src/features/auth/model/auth-session.tsx
Normal file
77
src/features/auth/model/auth-session.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { login as requestLogin, logout as requestLogout } from "@/features/auth/api/auth-api";
|
||||
import { AuthSessionContext } from "@/features/auth/model/auth-session-context";
|
||||
import type { AuthSessionContextValue } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
|
||||
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
|
||||
const logoutFailureWarning = "서버 로그아웃 확인에 실패했습니다.";
|
||||
|
||||
export type AuthSessionProviderProps = {
|
||||
readonly apiClient: ApiClient;
|
||||
readonly children: ReactNode;
|
||||
readonly onNavigateLogin?: (path: "/login") => void;
|
||||
};
|
||||
|
||||
export function AuthSessionProvider({ apiClient, children, onNavigateLogin }: AuthSessionProviderProps) {
|
||||
const [session, setSession] = useState<AuthSessionRecord | null>(() => authSessionStorage.read());
|
||||
const [loginNotice, setLoginNotice] = useState<string | null>(null);
|
||||
const logoutPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const clearSession = useCallback((nextLoginNotice: string | null = null) => {
|
||||
authSessionStorage.remove();
|
||||
setSession(null);
|
||||
setLoginNotice(nextLoginNotice);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (credentials: LoginCredentials) => {
|
||||
const nextSession = await requestLogin(apiClient, credentials);
|
||||
authSessionStorage.save(nextSession);
|
||||
setSession(nextSession);
|
||||
setLoginNotice(null);
|
||||
},
|
||||
[apiClient],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (logoutPromiseRef.current !== null) {
|
||||
return logoutPromiseRef.current;
|
||||
}
|
||||
|
||||
const logoutPromise = (async () => {
|
||||
let didLogoutRequestFail = false;
|
||||
|
||||
try {
|
||||
await requestLogout(apiClient);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
didLogoutRequestFail = true;
|
||||
} else {
|
||||
didLogoutRequestFail = true;
|
||||
}
|
||||
}
|
||||
|
||||
authSessionStorage.remove();
|
||||
setSession(null);
|
||||
setLoginNotice(didLogoutRequestFail ? logoutFailureWarning : null);
|
||||
onNavigateLogin?.("/login");
|
||||
})().finally(() => {
|
||||
logoutPromiseRef.current = null;
|
||||
});
|
||||
|
||||
logoutPromiseRef.current = logoutPromise;
|
||||
return logoutPromise;
|
||||
}, [apiClient, onNavigateLogin]);
|
||||
|
||||
const value = useMemo<AuthSessionContextValue>(
|
||||
() => ({ session, loginNotice, login, logout, clearSession }),
|
||||
[clearSession, login, loginNotice, logout, session],
|
||||
);
|
||||
|
||||
return <AuthSessionContext.Provider value={value}>{children}</AuthSessionContext.Provider>;
|
||||
}
|
||||
156
src/features/auth/pages/LoginPage.tsx
Normal file
156
src/features/auth/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { loginSchema } from "@/features/auth/schemas/login-schema";
|
||||
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
|
||||
|
||||
type LoginFieldErrors = {
|
||||
readonly email: string | null;
|
||||
readonly password: string | null;
|
||||
};
|
||||
|
||||
export type LoginPageProps = {
|
||||
readonly notice?: string | null;
|
||||
readonly onSubmit: (credentials: LoginCredentials) => Promise<void>;
|
||||
};
|
||||
|
||||
const emptyErrors: LoginFieldErrors = { email: null, password: null };
|
||||
|
||||
function getCredentials(form: HTMLFormElement): LoginCredentials {
|
||||
const formData = new FormData(form);
|
||||
|
||||
return {
|
||||
email: String(formData.get("email") ?? ""),
|
||||
password: String(formData.get("password") ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function getFieldErrors(error: z.ZodError<LoginCredentials>): LoginFieldErrors {
|
||||
const fieldErrors = z.flattenError(error).fieldErrors;
|
||||
|
||||
return {
|
||||
email: fieldErrors.email?.[0] ?? null,
|
||||
password: fieldErrors.password?.[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function LoginPage({ notice = null, onSubmit }: LoginPageProps) {
|
||||
const emailErrorId = useId();
|
||||
const passwordErrorId = useId();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const [errors, setErrors] = useState<LoginFieldErrors>(emptyErrors);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(form: HTMLFormElement): Promise<void> {
|
||||
const parsedCredentials = loginSchema.safeParse(getCredentials(form));
|
||||
|
||||
if (!parsedCredentials.success) {
|
||||
const nextErrors = getFieldErrors(parsedCredentials.error);
|
||||
setErrors(nextErrors);
|
||||
|
||||
if (nextErrors.email !== null) {
|
||||
emailRef.current?.focus();
|
||||
} else if (nextErrors.password !== null) {
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors(emptyErrors);
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(parsedCredentials.data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setSubmitError(error.message || "로그인에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-[100dvh] bg-background px-4 py-12 text-foreground sm:px-6">
|
||||
<section className="mx-auto flex w-full max-w-sm flex-col gap-6 rounded-lg border border-border bg-card p-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<h1 className="text-2xl font-bold leading-tight">관리자 로그인</h1>
|
||||
<p className="text-sm text-muted-foreground">ADMIN 계정으로 로그인하세요.</p>
|
||||
</header>
|
||||
{notice === null ? null : (
|
||||
<p className="rounded-md border border-warning bg-warning-surface p-3 text-sm font-semibold text-warning" role="alert">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit(event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor="login-email">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
aria-describedby={errors.email === null ? undefined : emailErrorId}
|
||||
aria-invalid={errors.email !== null}
|
||||
autoComplete="email"
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
|
||||
id="login-email"
|
||||
name="email"
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
/>
|
||||
{errors.email === null ? null : (
|
||||
<p className="text-sm font-semibold text-destructive" id={emailErrorId} role="alert">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor="login-password">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
aria-describedby={errors.password === null ? undefined : passwordErrorId}
|
||||
aria-invalid={errors.password !== null}
|
||||
autoComplete="current-password"
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
|
||||
id="login-password"
|
||||
name="password"
|
||||
ref={passwordRef}
|
||||
type="password"
|
||||
/>
|
||||
{errors.password === null ? null : (
|
||||
<p className="text-sm font-semibold text-destructive" id={passwordErrorId} role="alert">
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{submitError === null ? null : (
|
||||
<p className="text-sm font-semibold text-destructive" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60"
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
8
src/features/auth/schemas/login-schema.ts
Normal file
8
src/features/auth/schemas/login-schema.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.email("올바른 이메일을 입력하세요."),
|
||||
password: z.string().min(1, "비밀번호를 입력하세요."),
|
||||
});
|
||||
|
||||
export type LoginCredentials = z.infer<typeof loginSchema>;
|
||||
107
src/features/auth/tests/auth-api.test.ts
Normal file
107
src/features/auth/tests/auth-api.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { login, logout } from "@/features/auth/api/auth-api";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
|
||||
function createClient(token: string | null = "header.payload.signature") {
|
||||
return createApiClient({
|
||||
getToken: () => token,
|
||||
clearSession: vi.fn(),
|
||||
onAuthExpired: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("auth API", () => {
|
||||
test("posts email and password JSON only to admin login without Authorization", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const client = createClient("caller-token");
|
||||
const observedRequests: string[] = [];
|
||||
let authorization: string | null = null;
|
||||
let contentType: string | null = null;
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, async ({ request }) => {
|
||||
observedRequests.push(new URL(request.url).pathname);
|
||||
authorization = request.headers.get("Authorization");
|
||||
contentType = request.headers.get("Content-Type");
|
||||
body = await request.json();
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { token: "jwt-token", role: "ADMIN" },
|
||||
errorProperty: null,
|
||||
});
|
||||
}),
|
||||
http.all(`${apiBaseUrl}/refresh`, ({ request }) => {
|
||||
observedRequests.push(new URL(request.url).pathname);
|
||||
return HttpResponse.json({ success: false, message: "unexpected", data: null }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
const session = await login(client, { email: "admin@test.com", password: "password" });
|
||||
|
||||
// Then
|
||||
expect(session).toEqual({ token: "jwt-token", role: "ADMIN" });
|
||||
expect(authorization).toBeNull();
|
||||
expect(contentType).toContain("application/json");
|
||||
expect(body).toEqual({ email: "admin@test.com", password: "password" });
|
||||
expect(observedRequests).toEqual(["/admin/member/login"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "empty token", data: { token: "", role: "ADMIN" } },
|
||||
{ name: "missing token", data: { role: "ADMIN" } },
|
||||
{ name: "missing role", data: { token: "jwt-token" } },
|
||||
{ name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } },
|
||||
])("rejects $name login response", async ({ data }) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const client = createClient();
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () =>
|
||||
HttpResponse.json({ success: true, message: null, data, errorProperty: null }),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = login(client, { email: "admin@test.com", password: "password" });
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toThrow("API 응답 형식이 올바르지 않습니다.");
|
||||
});
|
||||
|
||||
test("posts logout once with Bearer and no body", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const client = createClient("logout-token");
|
||||
let calls = 0;
|
||||
let authorization: string | null = null;
|
||||
let body = "not-read";
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, async ({ request }) => {
|
||||
calls += 1;
|
||||
authorization = request.headers.get("Authorization");
|
||||
body = await request.text();
|
||||
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await logout(client);
|
||||
|
||||
// Then
|
||||
expect(calls).toBe(1);
|
||||
expect(authorization).toBe("Bearer logout-token");
|
||||
expect(body).toBe("");
|
||||
});
|
||||
});
|
||||
203
src/features/auth/tests/auth-session.test.tsx
Normal file
203
src/features/auth/tests/auth-session.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { AuthSessionProvider } from "@/features/auth/model/auth-session";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
|
||||
function SessionProbe() {
|
||||
const auth = useAuthSession();
|
||||
const [loginFailed, setLoginFailed] = useState(false);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div>{auth.session === null ? "보호 콘텐츠 없음" : "보호 콘텐츠"}</div>
|
||||
<div>{auth.session?.token ?? "토큰 없음"}</div>
|
||||
{loginFailed ? <p role="status">로그인 거부</p> : null}
|
||||
{auth.loginNotice === null ? null : <p role="alert">{auth.loginNotice}</p>}
|
||||
<button
|
||||
onClick={() =>
|
||||
void auth.login({ email: "admin@test.com", password: "password" }).catch(() => {
|
||||
setLoginFailed(true);
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
로그인 실행
|
||||
</button>
|
||||
<button onClick={() => void auth.logout()} type="button">
|
||||
로그아웃 실행
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSession(onNavigateLogin = vi.fn()) {
|
||||
const client = createApiClient({
|
||||
getToken: () => authSessionStorage.read()?.token ?? null,
|
||||
clearSession: authSessionStorage.remove,
|
||||
onAuthExpired: onNavigateLogin,
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthSessionProvider apiClient={client} onNavigateLogin={onNavigateLogin}>
|
||||
<SessionProbe />
|
||||
</AuthSessionProvider>,
|
||||
);
|
||||
|
||||
return { onNavigateLogin };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
document.cookie = "auth=; Max-Age=0; path=/";
|
||||
});
|
||||
|
||||
describe("auth session model", () => {
|
||||
test("rejects stored session with empty token", () => {
|
||||
// Given
|
||||
sessionStorage.setItem(
|
||||
"ai-character-admin-auth-session",
|
||||
JSON.stringify({ token: "", role: "ADMIN" }),
|
||||
);
|
||||
|
||||
// When, Then
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
});
|
||||
|
||||
test("stores successful ADMIN login only in sessionStorage and restores it in the same tab", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const indexedDbOpen = vi.fn();
|
||||
vi.stubGlobal("indexedDB", { open: indexedDbOpen });
|
||||
const cookieBefore = document.cookie;
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { token: "jwt-token", role: "ADMIN" },
|
||||
errorProperty: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
renderSession();
|
||||
screen.getByRole("button", { name: "로그인 실행" }).click();
|
||||
|
||||
// Then
|
||||
await screen.findByText("jwt-token");
|
||||
expect(authSessionStorage.read()).toEqual({ token: "jwt-token", role: "ADMIN" });
|
||||
expect(localStorage).toHaveLength(0);
|
||||
expect(indexedDbOpen).not.toHaveBeenCalled();
|
||||
expect(document.cookie).toBe(cookieBefore);
|
||||
|
||||
// When
|
||||
renderSession();
|
||||
|
||||
// Then
|
||||
expect(screen.getAllByText("jwt-token")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "empty token", data: { token: "", role: "ADMIN" } },
|
||||
{ name: "missing token", data: { role: "ADMIN" } },
|
||||
{ name: "missing role", data: { token: "jwt-token" } },
|
||||
{ name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } },
|
||||
])("rejects $name without storing or exposing protected content", async ({ data }) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () =>
|
||||
HttpResponse.json({ success: true, message: null, data, errorProperty: null }),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
renderSession();
|
||||
screen.getByRole("button", { name: "로그인 실행" }).click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
|
||||
expect(screen.queryByText("보호 콘텐츠", { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "success", response: "success" },
|
||||
{ name: "non-2xx", response: "server-error" },
|
||||
{ name: "network error", response: "network-error" },
|
||||
])("removes local session and navigates to login after logout $name", async ({ response }) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
authSessionStorage.save({ token: "logout-token", role: "ADMIN" });
|
||||
const onNavigateLogin = vi.fn();
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () => {
|
||||
if (response === "network-error") {
|
||||
return HttpResponse.error();
|
||||
}
|
||||
if (response === "server-error") {
|
||||
return HttpResponse.json(
|
||||
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
renderSession(onNavigateLogin);
|
||||
screen.getByRole("button", { name: "로그아웃 실행" }).click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
|
||||
expect(onNavigateLogin).toHaveBeenCalledExactlyOnceWith("/login");
|
||||
expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument();
|
||||
if (response === "success") {
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
|
||||
}
|
||||
});
|
||||
|
||||
test("coalesces duplicate logout clicks while the logout request is in flight", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
authSessionStorage.save({ token: "logout-token", role: "ADMIN" });
|
||||
let logoutCount = 0;
|
||||
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
|
||||
const logoutReady = new Promise<() => void>((resolve) => {
|
||||
resolveLogoutReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () => {
|
||||
logoutCount += 1;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
renderSession();
|
||||
const logoutButton = screen.getByRole("button", { name: "로그아웃 실행" });
|
||||
logoutButton.click();
|
||||
logoutButton.click();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(logoutCount).toBe(1));
|
||||
const finishLogout = await logoutReady;
|
||||
finishLogout();
|
||||
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
|
||||
});
|
||||
});
|
||||
89
src/features/auth/tests/login-page.test.tsx
Normal file
89
src/features/auth/tests/login-page.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { LoginPage } from "@/features/auth/pages/LoginPage";
|
||||
|
||||
describe("LoginPage", () => {
|
||||
test("renders visible email and password labels", () => {
|
||||
// Given, When
|
||||
render(<LoginPage onSubmit={vi.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("이메일")).toHaveAttribute("type", "email");
|
||||
expect(screen.getByLabelText("비밀번호")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
test("renders a login notice as an alert", () => {
|
||||
// Given, When
|
||||
render(<LoginPage notice="세션이 만료되었습니다. 다시 로그인하세요." onSubmit={vi.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
|
||||
});
|
||||
|
||||
test("connects field errors and focuses the first invalid field", async () => {
|
||||
// Given
|
||||
render(<LoginPage onSubmit={vi.fn()} />);
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
// Then
|
||||
const email = screen.getByLabelText("이메일");
|
||||
const password = screen.getByLabelText("비밀번호");
|
||||
const emailError = await screen.findByText("올바른 이메일을 입력하세요.");
|
||||
const passwordError = screen.getByText("비밀번호를 입력하세요.");
|
||||
expect(email).toHaveAccessibleDescription("올바른 이메일을 입력하세요.");
|
||||
expect(password).toHaveAccessibleDescription("비밀번호를 입력하세요.");
|
||||
expect(email).toHaveAttribute("aria-describedby", emailError.id);
|
||||
expect(password).toHaveAttribute("aria-describedby", passwordError.id);
|
||||
expect(email).toHaveFocus();
|
||||
});
|
||||
|
||||
test("submits valid email and password", async () => {
|
||||
// Given
|
||||
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.resolve());
|
||||
render(<LoginPage onSubmit={onSubmit} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
|
||||
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
|
||||
email: "admin@test.com",
|
||||
password: "password",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("re-enables submit button when login submission fails", async () => {
|
||||
// Given
|
||||
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.reject(new Error("login failed")));
|
||||
render(<LoginPage onSubmit={onSubmit} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
|
||||
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "로그인" })).toBeEnabled());
|
||||
});
|
||||
|
||||
test("shows the server Korean error message when login submission fails with one", async () => {
|
||||
// Given
|
||||
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.reject(new Error("이메일 또는 비밀번호가 올바르지 않습니다.")));
|
||||
render(<LoginPage onSubmit={onSubmit} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
|
||||
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user