diff --git a/src/app/RefineContext.test.tsx b/src/app/RefineContext.test.tsx new file mode 100644 index 0000000..8b80725 --- /dev/null +++ b/src/app/RefineContext.test.tsx @@ -0,0 +1,137 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { useAccessStore } from "@/store/accessStore"; +import { useAuthStore } from "@/store/authStore"; +import { App } from "./RefineContext"; + +let mockSessionState: { + data: { + accessToken: string; + user: { id: string; name: string }; + } | null; + status: "authenticated" | "unauthenticated"; +} = { + data: { + accessToken: "expired-access-token", + user: { id: "user-1", name: "Test User" }, + }, + status: "authenticated", +}; + +jest.mock("next-auth/react", () => ({ + SessionProvider: ({ children }: { children: ReactNode }) => children, + signIn: jest.fn().mockResolvedValue(undefined), + useSession: () => mockSessionState, +})); + +jest.mock("next/navigation", () => ({ + usePathname: () => "/network-simulation", +})); + +jest.mock("@refinedev/core", () => ({ + Refine: ({ children }: { children: ReactNode }) => children, +})); + +jest.mock("@refinedev/kbar", () => ({ + RefineKbar: () => null, + RefineKbarProvider: ({ children }: { children: ReactNode }) => children, +})); + +jest.mock("@refinedev/mui", () => ({ + RefineSnackbarProvider: ({ children }: { children: ReactNode }) => children, +})); + +jest.mock("@refinedev/nextjs-router", () => ({})); +jest.mock("@providers/data-provider", () => ({ dataProvider: {} })); +jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({ + useAppNotificationProvider: {}, +})); +jest.mock("@contexts/color-mode", () => ({ + ColorModeContextProvider: ({ children }: { children: ReactNode }) => children, +})); +jest.mock("@/contexts/ProjectContext", () => ({ + ProjectProvider: ({ children }: { children: ReactNode }) => children, +})); +jest.mock("@/lib/authToken", () => ({ + getAccessToken: jest.fn().mockResolvedValue("expired-access-token"), +})); + +describe("RefineContext access authentication", () => { + const originalFetch = global.fetch; + const originalRequest = global.Request; + + beforeEach(() => { + mockSessionState = { + data: { + accessToken: "expired-access-token", + user: { id: "user-1", name: "Test User" }, + }, + status: "authenticated", + }; + useAuthStore.setState({ + accessToken: null, + sessionExpired: false, + sessionExpiryReason: null, + }); + useAccessStore.setState({ + context: null, + permissions: [], + loading: true, + }); + global.Request = class TestRequest {} as unknown as typeof Request; + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + headers: new Headers(), + } as Response); + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + global.Request = originalRequest; + }); + + it("marks the session expired when access-context rejects an expired token", async () => { + render( + +
应用内容
+
, + ); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/access-context"), + expect.not.objectContaining({ skipAuthRedirect: true }), + ); + }); + + await waitFor(() => { + expect(useAuthStore.getState()).toMatchObject({ + sessionExpired: true, + sessionExpiryReason: "unauthorized", + }); + }); + }); + + it("prioritizes an unauthenticated session over route permissions", async () => { + mockSessionState = { data: null, status: "unauthenticated" }; + + render( + +
应用内容
+
, + ); + + expect(screen.getByText("登录状态已失效")).toBeInTheDocument(); + expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument(); + + await waitFor(() => { + expect(useAuthStore.getState()).toMatchObject({ + sessionExpired: true, + sessionExpiryReason: "unauthorized", + }); + }); + }); +}); diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index b02f022..1e6cfb6 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -56,7 +56,7 @@ type AppProps = { defaultMode?: string; }; -const App = (props: React.PropsWithChildren) => { +export const App = (props: React.PropsWithChildren) => { const { data, status } = useSession(); const to = usePathname(); const setAccessToken = useAuthStore((state) => state.setAccessToken); @@ -84,6 +84,10 @@ const App = (props: React.PropsWithChildren) => { markSessionExpired("refresh_failed"); return; } + if (status === "unauthenticated") { + markSessionExpired("unauthorized"); + return; + } if (status === "authenticated") { clearSessionExpired(); } @@ -99,7 +103,6 @@ const App = (props: React.PropsWithChildren) => { setAccessLoading(true); apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, { projectHeaderMode: currentProjectId ? "include" : "omit", - skipAuthRedirect: true, }) .then(async (response) => { if (cancelled) return; @@ -368,7 +371,9 @@ const App = (props: React.PropsWithChildren) => { }} > - {props.children} + + {props.children} + diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 46009fc..1288c50 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -657,7 +657,6 @@ export const SystemAdminPanel = () => { try { const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, { projectHeaderMode: "omit", - skipAuthRedirect: true, }); if (!adminResponse.ok) { if (!cancelled) { diff --git a/src/components/auth/RoutePermissionGuard.test.tsx b/src/components/auth/RoutePermissionGuard.test.tsx index 08f8051..acf5f49 100644 --- a/src/components/auth/RoutePermissionGuard.test.tsx +++ b/src/components/auth/RoutePermissionGuard.test.tsx @@ -29,7 +29,7 @@ describe("RoutePermissionGuard", () => { }); render( - +
受保护内容
, ); @@ -43,7 +43,7 @@ describe("RoutePermissionGuard", () => { it("shows the permission error when the session is still valid", () => { render( - +
受保护内容
, ); @@ -51,4 +51,15 @@ describe("RoutePermissionGuard", () => { expect(screen.getByText("无权访问此功能")).toBeInTheDocument(); expect(screen.getByText(/simulation\.view/)).toBeInTheDocument(); }); + + it("checks authentication before the expired state effect runs", () => { + render( + +
受保护内容
+
, + ); + + expect(screen.getByText("登录状态已失效")).toBeInTheDocument(); + expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx index 4894fb1..fa90f8b 100644 --- a/src/components/auth/RoutePermissionGuard.tsx +++ b/src/components/auth/RoutePermissionGuard.tsx @@ -11,8 +11,10 @@ import { useAuthStore } from "@/store/authStore"; export const RoutePermissionGuard = ({ children, + authenticated, }: { children: ReactNode; + authenticated: boolean; }) => { const pathname = usePathname(); const permissions = useAccessStore((state) => state.permissions); @@ -20,7 +22,7 @@ export const RoutePermissionGuard = ({ const sessionExpired = useAuthStore((state) => state.sessionExpired); const requiredPermission = permissionForPath(pathname); - if (sessionExpired) { + if (!authenticated || sessionExpired) { return (