feat(ai-character): 관리자 인증 셸 구현

This commit is contained in:
Yu Sung
2026-07-27 15:04:03 +09:00
parent ba43dd829e
commit 5356bd1e6a
82 changed files with 6017 additions and 18 deletions

View 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",
});
}

View 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;
}

View 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();
});
});

View 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);
},
};

View 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>;
}

View 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>
);
}

View 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>;

View 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("");
});
});

View 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());
});
});

View 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("이메일 또는 비밀번호가 올바르지 않습니다.");
});
});