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 { 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 { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
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");
render(<App />);
const alert = await screen.findByRole("alert");
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
const retryButton = screen.getByRole("button", { name: "관리자 권한 다시 확인" });
retryButton.focus();
expect(alert).toHaveTextContent("없습니다.");
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;
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("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishRetry();
@@ -170,37 +170,40 @@ test("keeps retry available and the protected shell hidden when a network retry
}));
window.history.pushState({}, "", "/ai-characters");
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(requestCount).toBe(1);
fireEvent.click(retryButton);
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.queryByRole("main", { name: "AI 캐릭터 관리" })).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();
useAiCharactersResponse();
const verificationRequests: Request[] = [];
useAiCharactersResponse(200, (request) => verificationRequests.push(request));
useAiCharacterDetailResponse("101");
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
await waitFor(() => expect(verificationRequests).toHaveLength(2));
window.history.pushState({}, "", "/login");
fireEvent.popState(window);
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
useAiCharactersFailure(404);
window.history.pushState({}, "", "/ai-characters");
window.history.pushState({}, "", "/ai-characters/101/edit");
fireEvent.popState(window);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
expect(verificationRequests).toHaveLength(2);
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 () => {

View File

@@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
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 { 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));
});
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 () => {
saveAdminSession();
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 />);
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("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe();
@@ -84,7 +104,7 @@ test("keeps malformed protected routes behind the ADMIN probe", async () => {
render(<App />);
const finishProbe = await probeReady;
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
finishProbe();
expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
@@ -122,7 +142,7 @@ test.each([
render(<App />);
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("button", { name: "로그아웃" })).not.toBeInTheDocument();
finishProbe();

View File

@@ -22,13 +22,6 @@ const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인
type ProtectedRouteError = {
readonly message: string;
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;
};
@@ -44,7 +37,7 @@ function ProtectedRouteErrorPage({ message, onRetry }: { readonly message: strin
onClick={onRetry}
type="button"
>
route
</button>
</div>
</main>
@@ -74,7 +67,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
);
const location = useBrowserLocation();
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);
useEffect(() => {
@@ -84,13 +77,12 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
}, [auth.session, location.path]);
useEffect(() => {
if (!isAiCharactersRoute(location.path) || auth.session === null) {
if (!isAiCharactersRoute(location.path) || auth.session === null || verifiedProtectedRouteSession === auth.session) {
return undefined;
}
let isCurrent = true;
const session = auth.session;
const routeVisitKey = location.visitKey;
const currentProtectedRouteRetryKey = protectedRouteRetryKey;
void protectedRouteApiClient
.request({
@@ -101,7 +93,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
.then(() => {
if (isCurrent) {
setRouteError(null);
setVerifiedProtectedRouteSession({ session, routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey });
setVerifiedProtectedRouteSession(session);
}
})
.catch((error: unknown) => {
@@ -115,9 +107,8 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
}
setRouteError({
message: error instanceof ApiError ? error.message : "보호 route 확인에 실패했습니다.",
message: error instanceof ApiError ? error.message : "관리자 권한을 확인하지 못했습니다.",
session,
routeVisitKey,
protectedRouteRetryKey: currentProtectedRouteRetryKey,
});
});
@@ -125,7 +116,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
return () => {
isCurrent = false;
};
}, [auth.session, location.path, location.visitKey, protectedRouteApiClient, protectedRouteRetryKey]);
}, [auth.session, location.path, protectedRouteApiClient, protectedRouteRetryKey, verifiedProtectedRouteSession]);
if (location.path === routePaths.login) {
return (
@@ -154,22 +145,15 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
}
const currentRouteError =
routeError?.session === auth.session &&
routeError.routeVisitKey === location.visitKey &&
routeError.protectedRouteRetryKey === protectedRouteRetryKey
routeError?.session === auth.session && routeError.protectedRouteRetryKey === protectedRouteRetryKey
? routeError.message
: null;
if (
isAiCharactersRoute(location.path) &&
(verifiedProtectedRouteSession?.session !== auth.session ||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
) {
if (isAiCharactersRoute(location.path) && verifiedProtectedRouteSession !== auth.session) {
return currentRouteError === null ? (
<RouteFrame apiMode={apiMode}>
<main className="min-h-[100dvh] bg-background p-4 text-foreground">
<PageState state="loading" title="보호 route 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
<PageState state="loading" title="관리자 권한 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
</main>
</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"));
expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 비활성화했습니다.");
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
await waitFor(() => expect(listRequests).toBeGreaterThanOrEqual(3));
await waitFor(() => expect(listRequests).toBe(2));
});