diff --git a/src/app/App.protected-errors.test.tsx b/src/app/App.protected-errors.test.tsx
index c2afdf7..816670d 100644
--- a/src/app/App.protected-errors.test.tsx
+++ b/src/app/App.protected-errors.test.tsx
@@ -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();
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();
- 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();
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 () => {
diff --git a/src/app/App.protected-shell.test.tsx b/src/app/App.protected-shell.test.tsx
index 13b002c..214822a 100644
--- a/src/app/App.protected-shell.test.tsx
+++ b/src/app/App.protected-shell.test.tsx
@@ -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();
+ 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();
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();
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();
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();
diff --git a/src/app/App.tsx b/src/app/App.tsx
index e7e7825..a4302fe 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -22,13 +22,6 @@ const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인
type ProtectedRouteError = {
readonly message: string;
readonly session: NonNullable["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 다시 시도
+ 관리자 권한 다시 확인
@@ -74,7 +67,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
);
const location = useBrowserLocation();
const [routeError, setRouteError] = useState(null);
- const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState(null);
+ const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState(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 ? (
-
+
) : (
diff --git a/src/features/characters/tests/character-mutation-reload.test.tsx b/src/features/characters/tests/character-mutation-reload.test.tsx
index 3e106f6..f5692a8 100644
--- a/src/features/characters/tests/character-mutation-reload.test.tsx
+++ b/src/features/characters/tests/character-mutation-reload.test.tsx
@@ -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));
});