fix(ai-character): 관리자 권한 확인 중복 요청 방지
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user