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 { 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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user