fix(ai-character): 관리자 권한 확인 중복 요청 방지

This commit is contained in:
Yu Sung
2026-08-03 13:08:12 +09:00
parent 8265fe4947
commit 344674c756
4 changed files with 49 additions and 42 deletions

View File

@@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App"; import { App } from "./App";
import { apiBaseUrl, saveAdminSession, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support"; import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error"; import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
import { server } from "@/shared/test/server"; import { server } from "@/shared/test/server";
@@ -142,7 +142,7 @@ test("retries a protected route 404 and reveals the shell only after the current
window.history.pushState({}, "", "/ai-characters"); window.history.pushState({}, "", "/ai-characters");
render(<App />); render(<App />);
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" }); const retryButton = screen.getByRole("button", { name: "관리자 권한 다시 확인" });
retryButton.focus(); retryButton.focus();
expect(alert).toHaveTextContent("없습니다."); expect(alert).toHaveTextContent("없습니다.");
expect(retryButton).toHaveFocus(); expect(retryButton).toHaveFocus();
@@ -153,7 +153,7 @@ test("retries a protected route 404 and reveals the shell only after the current
const finishRetry = await retryReady; const finishRetry = await retryReady;
expect(requestCount).toBe(2); expect(requestCount).toBe(2);
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishRetry(); finishRetry();
@@ -170,37 +170,40 @@ test("keeps retry available and the protected shell hidden when a network retry
})); }));
window.history.pushState({}, "", "/ai-characters"); window.history.pushState({}, "", "/ai-characters");
render(<App />); render(<App />);
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" }); const retryButton = await screen.findByRole("button", { name: "관리자 권한 다시 확인" });
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE); expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
expect(requestCount).toBe(1); expect(requestCount).toBe(1);
fireEvent.click(retryButton); fireEvent.click(retryButton);
await waitFor(() => expect(requestCount).toBe(2)); await waitFor(() => expect(requestCount).toBe(2));
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "관리자 권한 다시 확인" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE); expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
}); });
test("clears a previous protected route verification before the same session re-enters the route", async () => { test("reuses successful protected route verification when the same session re-enters", async () => {
saveAdminSession(); saveAdminSession();
useAiCharactersResponse(); const verificationRequests: Request[] = [];
useAiCharactersResponse(200, (request) => verificationRequests.push(request));
useAiCharacterDetailResponse("101");
window.history.pushState({}, "", "/ai-characters"); window.history.pushState({}, "", "/ai-characters");
render(<App />); render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument(); expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
await waitFor(() => expect(verificationRequests).toHaveLength(2));
window.history.pushState({}, "", "/login"); window.history.pushState({}, "", "/login");
fireEvent.popState(window); fireEvent.popState(window);
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
useAiCharactersFailure(404); window.history.pushState({}, "", "/ai-characters/101/edit");
window.history.pushState({}, "", "/ai-characters");
fireEvent.popState(window); fireEvent.popState(window);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다."); expect(verificationRequests).toHaveLength(2);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
}); });
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => { test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {

View File

@@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App"; import { App } from "./App";
import { apiBaseUrl, installDesktopMediaQuery, requireElement, saveAdminSession, useAiCharactersResponse } from "./app-test-support"; import { apiBaseUrl, installDesktopMediaQuery, requireElement, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "./app-test-support";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server"; import { server } from "@/shared/test/server";
@@ -41,6 +41,26 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
await waitFor(() => expect(requests).toHaveLength(2)); await waitFor(() => expect(requests).toHaveLength(2));
}); });
test("reuses successful ADMIN verification during protected intra-app navigation", async () => {
saveAdminSession();
const verificationRequests: Request[] = [];
useAiCharactersResponse(200, (request) => verificationRequests.push(request));
useAiCharacterDetailResponse("101");
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
await waitFor(() => expect(verificationRequests).toHaveLength(2));
window.history.pushState({}, "", "/ai-characters/101/edit");
fireEvent.popState(window);
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "관리자 권한 확인 중" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
expect(verificationRequests).toHaveLength(2);
});
test("shows an accessible status while the initial protected route probe is pending", async () => { test("shows an accessible status while the initial protected route probe is pending", async () => {
saveAdminSession(); saveAdminSession();
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined; let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
@@ -59,7 +79,7 @@ test("shows an accessible status while the initial protected route probe is pend
render(<App />); render(<App />);
const finishProbe = await probeReady; const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe(); finishProbe();
@@ -84,7 +104,7 @@ test("keeps malformed protected routes behind the ADMIN probe", async () => {
render(<App />); render(<App />);
const finishProbe = await probeReady; const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
finishProbe(); finishProbe();
expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
@@ -122,7 +142,7 @@ test.each([
render(<App />); render(<App />);
const finishProbe = await probeReady; const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중"); expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument(); expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe(); finishProbe();

View File

@@ -22,13 +22,6 @@ const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인
type ProtectedRouteError = { type ProtectedRouteError = {
readonly message: string; readonly message: string;
readonly session: NonNullable<ReturnType<typeof useAuthSession>["session"]>; readonly session: NonNullable<ReturnType<typeof useAuthSession>["session"]>;
readonly routeVisitKey: number;
readonly protectedRouteRetryKey: number;
};
type ProtectedRouteVerification = {
readonly session: ProtectedRouteError["session"];
readonly routeVisitKey: number;
readonly protectedRouteRetryKey: number; readonly protectedRouteRetryKey: number;
}; };
@@ -44,7 +37,7 @@ function ProtectedRouteErrorPage({ message, onRetry }: { readonly message: strin
onClick={onRetry} onClick={onRetry}
type="button" type="button"
> >
route
</button> </button>
</div> </div>
</main> </main>
@@ -74,7 +67,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
); );
const location = useBrowserLocation(); const location = useBrowserLocation();
const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null); const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null);
const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState<ProtectedRouteVerification | null>(null); const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState<ProtectedRouteError["session"] | null>(null);
const [protectedRouteRetryKey, setProtectedRouteRetryKey] = useState(0); const [protectedRouteRetryKey, setProtectedRouteRetryKey] = useState(0);
useEffect(() => { useEffect(() => {
@@ -84,13 +77,12 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
}, [auth.session, location.path]); }, [auth.session, location.path]);
useEffect(() => { useEffect(() => {
if (!isAiCharactersRoute(location.path) || auth.session === null) { if (!isAiCharactersRoute(location.path) || auth.session === null || verifiedProtectedRouteSession === auth.session) {
return undefined; return undefined;
} }
let isCurrent = true; let isCurrent = true;
const session = auth.session; const session = auth.session;
const routeVisitKey = location.visitKey;
const currentProtectedRouteRetryKey = protectedRouteRetryKey; const currentProtectedRouteRetryKey = protectedRouteRetryKey;
void protectedRouteApiClient void protectedRouteApiClient
.request({ .request({
@@ -101,7 +93,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
.then(() => { .then(() => {
if (isCurrent) { if (isCurrent) {
setRouteError(null); setRouteError(null);
setVerifiedProtectedRouteSession({ session, routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey }); setVerifiedProtectedRouteSession(session);
} }
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -115,9 +107,8 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
} }
setRouteError({ setRouteError({
message: error instanceof ApiError ? error.message : "보호 route 확인에 실패했습니다.", message: error instanceof ApiError ? error.message : "관리자 권한을 확인하지 못했습니다.",
session, session,
routeVisitKey,
protectedRouteRetryKey: currentProtectedRouteRetryKey, protectedRouteRetryKey: currentProtectedRouteRetryKey,
}); });
}); });
@@ -125,7 +116,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
return () => { return () => {
isCurrent = false; isCurrent = false;
}; };
}, [auth.session, location.path, location.visitKey, protectedRouteApiClient, protectedRouteRetryKey]); }, [auth.session, location.path, protectedRouteApiClient, protectedRouteRetryKey, verifiedProtectedRouteSession]);
if (location.path === routePaths.login) { if (location.path === routePaths.login) {
return ( return (
@@ -154,22 +145,15 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
} }
const currentRouteError = const currentRouteError =
routeError?.session === auth.session && routeError?.session === auth.session && routeError.protectedRouteRetryKey === protectedRouteRetryKey
routeError.routeVisitKey === location.visitKey &&
routeError.protectedRouteRetryKey === protectedRouteRetryKey
? routeError.message ? routeError.message
: null; : null;
if ( if (isAiCharactersRoute(location.path) && verifiedProtectedRouteSession !== auth.session) {
isAiCharactersRoute(location.path) &&
(verifiedProtectedRouteSession?.session !== auth.session ||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
) {
return currentRouteError === null ? ( return currentRouteError === null ? (
<RouteFrame apiMode={apiMode}> <RouteFrame apiMode={apiMode}>
<main className="min-h-[100dvh] bg-background p-4 text-foreground"> <main className="min-h-[100dvh] bg-background p-4 text-foreground">
<PageState state="loading" title="보호 route 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." /> <PageState state="loading" title="관리자 권한 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
</main> </main>
</RouteFrame> </RouteFrame>
) : ( ) : (

View File

@@ -165,5 +165,5 @@ test("soft delete success re-enters the list route and fetches the list", async
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters")); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 비활성화했습니다."); expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 비활성화했습니다.");
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
await waitFor(() => expect(listRequests).toBeGreaterThanOrEqual(3)); await waitFor(() => expect(listRequests).toBe(2));
}); });